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

fine-tuning

Signed-off-by: Roman <ixrock@gmail.com>
This commit is contained in:
Roman 2020-12-15 18:36:43 +02:00
parent 3a4a07ca7f
commit daf373168f
14 changed files with 259 additions and 188 deletions

View File

@ -1,43 +1,61 @@
import { Component, LensRendererExtension, Navigation } from "@k8slens/extensions";
import { Component, Interface, K8sApi, LensRendererExtension } from "@k8slens/extensions";
import React from "react";
import path from "path";
import { observer } from "mobx-react";
import { CoffeeDoodle } from "react-open-doodles";
export const exampleId = Navigation.createPageParam({
name: "exampleId",
defaultValue: "demo",
});
export function ExampleIcon(props: Component.IconProps) {
return <Component.Icon {...props} material="pages" tooltip={path.basename(__filename)}/>;
export interface ExamplePageProps extends Interface.PageComponentProps<ExamplePageParams> {
extension: LensRendererExtension; // provided in "./renderer.tsx"
}
export interface ExamplePageParams {
exampleId: string;
selectedNamespaces: K8sApi.Namespace[];
}
export const namespaceStore = K8sApi.apiManager.getStore<K8sApi.NamespaceStore>(K8sApi.namespacesApi);
@observer
export class ExamplePage extends React.Component<{ extension: LensRendererExtension }> {
export class ExamplePage extends React.Component<ExamplePageProps> {
async componentDidMount() {
await namespaceStore.loadAll();
}
deactivate = () => {
const { extension } = this.props;
extension.disable();
};
renderSelectedNamespaces() {
const { selectedNamespaces } = this.props.params;
return (
<div className="flex gaps inline">
{selectedNamespaces.get().map(ns => {
const name = ns.getName();
return <Component.Badge key={name} label={name} tooltip={`Created: ${ns.getAge()}`}/>;
})}
</div>
);
}
render() {
const exampleName = exampleId.get();
const doodleStyle = {
width: "200px"
};
const { exampleId } = this.props.params;
return (
<div className="flex column gaps align-flex-start" style={{ padding: 24 }}>
<div style={doodleStyle}><CoffeeDoodle accent="#3d90ce"/></div>
<div style={{ width: 200 }}>
<CoffeeDoodle accent="#3d90ce"/>
</div>
<p>Hello from Example extension!</p>
<p>File: <i>{__filename}</i></p>
<p>Location: <i>{location.href}</i></p>
<div>Hello from Example extension!</div>
<div>Location: <i>{location.href}</i></div>
<div>Namespaces: {this.renderSelectedNamespaces()}</div>
<p className="url-params-demo flex column gaps">
<a onClick={() => exampleId.set("secret")}>Show secret button</a>
{exampleName === "secret" && (
{exampleId.get() === "secret" && (
<Component.Button accent label="Deactivate" onClick={this.deactivate}/>
)}
</p>

View File

@ -1,47 +1,45 @@
import { LensRendererExtension } from "@k8slens/extensions";
import { ExampleIcon, ExamplePage } from "./page";
import { Component, Interface, K8sApi, LensRendererExtension } from "@k8slens/extensions";
import { ExamplePage, ExamplePageParams, namespaceStore } from "./page";
import React from "react";
import path from "path";
export default class ExampleExtension extends LensRendererExtension {
clusterPages = [
clusterPages: Interface.PageRegistration[] = [
{
id: "example",
title: "Example Extension",
components: {
Page: () => <ExamplePage extension={this}/>,
Page: (props: Interface.PageComponentProps<ExamplePageParams>) => {
return <ExamplePage {...props} extension={this}/>;
},
},
params: {
// setup param "exampleId" with default value "demo"
// could be also {[paramName: string]: UrlParam} for advanced use-cases (custom parse/stringify)
exampleId: "demo"
// setup basic param "exampleId" with default value "demo"
exampleId: "demo",
// setup advanced multi-values param "selectedNamespaces" with custom parsing/stringification
selectedNamespaces: {
defaultValueStringified: ["default", "kube-system"],
multiValues: true,
parse(values: string[]) { // from URL
return values.map(name => namespaceStore.getByName(name)).filter(Boolean);
},
stringify(values: K8sApi.Namespace[]) { // to URL
return values.map(namespace => namespace.getName());
},
}
}
}
];
clusterPageMenus = [
clusterPageMenus: Interface.PageMenuRegistration[] = [
{
title: "Example extension",
components: {
Icon: ExampleIcon,
},
target: {
pageId: "example",
params: {
exampleId: "demo-sample-2"
},
},
},
{
title: "Example secret page",
components: {
Icon: ExampleIcon,
},
target: {
pageId: "example",
params: {
exampleId: "secret"
},
},
},
];
}
export function ExampleIcon(props: Component.IconProps) {
return <Component.Icon {...props} material="pages" tooltip={path.basename(__filename)}/>;
}

View File

@ -3,6 +3,6 @@ export type { ClusterFeatureRegistration, ClusterFeatureComponents } from "../re
export type { KubeObjectDetailRegistration, KubeObjectDetailComponents } from "../registries/kube-object-detail-registry";
export type { KubeObjectMenuRegistration, KubeObjectMenuComponents } from "../registries/kube-object-menu-registry";
export type { KubeObjectStatusRegistration } from "../registries/kube-object-status-registry";
export type { PageRegistration, PageComponents } from "../registries/page-registry";
export type { PageRegistration, RegisteredPage, PageParams, PageComponentProps, PageComponents, PageTarget } from "../registries/page-registry";
export type { PageMenuRegistration, PageMenuComponents } from "../registries/page-menu-registry";
export type { StatusBarRegistration } from "../registries/status-bar-registry";

View File

@ -1,4 +1,4 @@
import { getExtensionPageUrl, globalPageRegistry, PageTargetParams } from "../page-registry";
import { getExtensionPageUrl, globalPageRegistry, PageParams } from "../page-registry";
import { LensExtension } from "../../lens-extension";
import React from "react";
@ -50,7 +50,7 @@ describe("getPageUrl", () => {
});
it("gets page url with custom params", () => {
const params: PageTargetParams<string> = { test1: "one", test2: "2" };
const params: PageParams<string> = { test1: "one", test2: "2" };
const searchParams = new URLSearchParams(params);
const pageUrl = getExtensionPageUrl({ extensionId: ext.name, pageId: "page-with-params", params });

View File

@ -2,27 +2,33 @@
import { action, observable } from "mobx";
import { LensExtension } from "../lens-extension";
export class BaseRegistry<T> {
private items = observable<T>([], { deep: false });
export class BaseRegistry<T, I = T> {
private items = observable.map<T, I>();
getItems(): T[] {
return this.items.toJS();
getItems(): I[] {
return Array.from(this.items.values());
}
add(items: T | T[], ext?: LensExtension): () => void; // allow method overloading with required "ext"
@action
add(items: T | T[]) {
add(items: T | T[], extension?: LensExtension) {
const itemArray = [items].flat() as T[];
this.items.push(...itemArray);
itemArray.forEach(item => {
this.items.set(item, this.getRegisteredItem(item, extension));
});
return () => this.remove(...itemArray);
}
// eslint-disable-next-line unused-imports/no-unused-vars-ts
protected getRegisteredItem(item: T, extension?: LensExtension): I {
return item as any;
}
@action
remove(...items: T[]) {
items.forEach(item => {
this.items.remove(item); // works because of {deep: false};
this.items.delete(item);
});
}
}

View File

@ -21,7 +21,7 @@ export interface PageMenuComponents {
Icon: React.ComponentType<IconProps>;
}
export class PageMenuRegistry<T extends PageMenuRegistration = any> extends BaseRegistry<T> {
export class PageMenuRegistry<T extends PageMenuRegistration> extends BaseRegistry<T> {
@action
add(items: T[], ext: LensExtension) {
const normalizedItems = items.map(menuItem => {

View File

@ -1,43 +1,51 @@
// Extensions-api -> Custom page registration
import type React from "react";
import { action } from "mobx";
import React from "react";
import { observer } from "mobx-react";
import { BaseRegistry } from "./base-registry";
import { LensExtension, sanitizeExtensionName } from "../lens-extension";
import { PageParam } from "../../renderer/navigation/page-param";
import logger from "../../main/logger";
import { isPageParamInit, PageParam, PageParamInit } from "../../renderer/navigation/page-param";
import { createPageParam } from "../../renderer/navigation/helpers";
export interface PageRegistration {
/**
* Page-id, part of of extension's page url, must be unique within same extension
* Page ID, part of extension's page url, must be unique within same extension
* When not provided, first registered page without "id" would be used for page-menus without target.pageId for same extension
*/
id?: string;
params?: PageParams<string | ExtensionPageParamInit>;
components: PageComponents;
/**
* Registered page params.
* Used to generate final page url when provided in getExtensionPageUrl()-helper.
* Advanced usage: provide `UrlParam` as values to customize parsing/stringification from/to URL.
*/
params?: PageTargetParams<string | PageParam>;
}
// exclude "name" field since provided as key in page.params
export type ExtensionPageParamInit = Omit<PageParamInit, "name" | "isSystem">;
export interface PageComponents {
Page: React.ComponentType<any>;
}
export interface PageTarget<P = PageTargetParams> {
export interface PageTarget<P = PageParams> {
extensionId?: string;
pageId?: string;
params?: P;
}
export interface PageTargetParams<V = any> {
export interface PageParams<V = any> {
[paramName: string]: V;
}
export interface RegisteredPage extends PageRegistration {
export interface PageComponentProps<P extends PageParams = {}> {
params?: {
[N in keyof P]: PageParam<P[N]>;
}
}
export interface RegisteredPage {
id: string;
extensionId: string;
url: string; // registered extension's page URL (without page params)
params: PageParams<PageParam>; // normalized params
components: PageComponents; // normalized components
}
export function getExtensionPageUrl(target: PageTarget): string {
@ -54,16 +62,11 @@ export function getExtensionPageUrl(target: PageTarget): string {
if (registeredPage?.params) {
Object.entries(registeredPage.params).forEach(([name, param]) => {
const targetParamValue = targetParams[name];
if (param instanceof PageParam) {
pageUrl.searchParams.set(name, param.stringify(targetParamValue));
const paramValue = param.stringify(targetParams[name]);
if (param.init.skipEmpty && param.isEmpty(paramValue)) {
pageUrl.searchParams.delete(name);
} else {
const value = String(targetParamValue ?? param);
if (value) {
pageUrl.searchParams.set(name, value);
}
pageUrl.searchParams.set(name, paramValue);
}
});
}
@ -71,31 +74,41 @@ export function getExtensionPageUrl(target: PageTarget): string {
return pageUrl.href.replace(pageUrl.origin, "");
}
export class PageRegistry extends BaseRegistry<RegisteredPage> {
@action
add(pages: PageRegistration | PageRegistration[], extension: LensExtension) {
try {
const items = [pages].flat().map(page => this.registerPage(page, extension));
export class PageRegistry extends BaseRegistry<PageRegistration, RegisteredPage> {
protected getRegisteredItem(page: PageRegistration, ext: LensExtension): RegisteredPage {
const { id: pageId } = page;
const extensionId = ext.name;
const params = this.normalizeParams(page.params);
const components = this.normalizeComponents(page.components, params);
const url = getExtensionPageUrl({ extensionId, pageId });
return super.add(items);
} catch (error) {
return Function; // no-op
}
return {
id: pageId, extensionId, params, components, url,
};
}
registerPage(page: PageRegistration, ext: LensExtension): RegisteredPage {
try {
const { id: pageId } = page;
const extensionId = ext.name;
protected normalizeComponents(components: PageComponents, params?: PageParams<PageParam>): PageComponents {
if (params) {
const { Page } = components;
return {
...page,
extensionId,
url: getExtensionPageUrl({ extensionId, pageId }),
};
} catch (error) {
logger.error(`Failed to register page: ${error}`, { error });
components.Page = observer((props: object) => React.createElement(Page, { params, ...props }));
}
return components;
}
protected normalizeParams(params?: PageParams<string | ExtensionPageParamInit>): PageParams<PageParam> {
if (!params) {
return;
}
Object.entries(params).forEach(([name, value]) => {
const paramInit: PageParamInit = isPageParamInit(value) ? value : { name, defaultValue: value };
paramInit.name ??= name;
params[name] = createPageParam(paramInit);
});
return params as PageParams<PageParam>;
}
getByPageTarget(target: PageTarget): RegisteredPage | null {

View File

@ -1,4 +1,4 @@
export { PageParam, PageParamInit } from "../../renderer/navigation/page-param";
export { navigate, isActiveRoute, createPageParam } from "../../renderer/navigation";
export { PageParamInit, PageParam } from "../../renderer/navigation/page-param";
export { navigate, isActiveRoute, createPageParam } from "../../renderer/navigation/helpers";
export { hideDetails, showDetails, getDetailsUrl } from "../../renderer/components/kube-object/kube-object-details";
export { IURLParams } from "../../common/utils/buildUrl";

View File

@ -46,8 +46,8 @@ export class ApiManager {
});
}
getStore(api: string | KubeApi): KubeObjectStore {
return this.stores.get(this.resolveApi(api));
getStore<S extends KubeObjectStore>(api: string | KubeApi): S {
return this.stores.get(this.resolveApi(api)) as S;
}
}

View File

@ -0,0 +1,31 @@
import { ipcRenderer } from "electron";
import { reaction } from "mobx";
import { getMatchedClusterId, navigate } from "./helpers";
import { broadcastMessage, subscribeToBroadcast } from "../../common/ipc";
import logger from "../../main/logger";
export function bindEvents() {
if (!ipcRenderer) {
return;
}
if (process.isMainFrame) {
// Keep track of active cluster-id for handling IPC/menus/etc.
reaction(() => getMatchedClusterId(), clusterId => {
broadcastMessage("cluster-view:current-id", clusterId);
}, {
fireImmediately: true
});
}
// Handle navigation via IPC (e.g. from top menu)
subscribeToBroadcast("renderer:navigate", (event, url: string) => {
logger.info(`[IPC]: ${event.type} ${JSON.stringify(url)}`, event);
navigate(url);
});
// Reload dashboard window
subscribeToBroadcast("renderer:reload", () => {
location.reload();
});
}

View File

@ -0,0 +1,36 @@
import type { LocationDescriptor } from "history";
import { matchPath, RouteProps } from "react-router";
import { PageParam, PageParamInit } from "./page-param";
import { clusterViewRoute, IClusterViewRouteParams } from "../components/cluster-manager/cluster-view.route";
import { navigation } from "./history";
export function navigate(location: LocationDescriptor) {
const currentLocation = navigation.getPath();
navigation.push(location);
if (currentLocation === navigation.getPath()) {
navigation.goBack(); // prevent sequences of same url in history
}
}
export function createPageParam<V = string>(init: PageParamInit<V>) {
return new PageParam<V>(init, navigation);
}
export function matchRoute<P>(route: string | string[] | RouteProps) {
return matchPath<P>(navigation.location.pathname, route);
}
export function isActiveRoute(route: string | string[] | RouteProps): boolean {
return !!matchRoute(route);
}
export function getMatchedClusterId(): string {
const matched = matchPath<IClusterViewRouteParams>(navigation.location.pathname, {
exact: true,
path: clusterViewRoute.path
});
return matched?.params.clusterId;
}

View File

@ -0,0 +1,6 @@
import { ipcRenderer } from "electron";
import { createBrowserHistory, createMemoryHistory } from "history";
import { createObservableHistory } from "mobx-observable-history";
export const history = ipcRenderer ? createBrowserHistory() : createMemoryHistory();
export const navigation = createObservableHistory(history);

View File

@ -1,70 +1,8 @@
// Navigation helpers
// Navigation (renderer)
import { ipcRenderer } from "electron";
import { reaction } from "mobx";
import { matchPath, RouteProps } from "react-router";
import { createObservableHistory } from "mobx-observable-history";
import { createBrowserHistory, createMemoryHistory, LocationDescriptor } from "history";
import { broadcastMessage, subscribeToBroadcast } from "../../common/ipc";
import { PageParam, PageParamInit } from "./page-param";
import { clusterViewRoute, IClusterViewRouteParams } from "../components/cluster-manager/cluster-view.route";
import logger from "../../main/logger";
import { bindEvents } from "./events";
export let history = ipcRenderer ? createBrowserHistory() : createMemoryHistory();
export let navigation = createObservableHistory(history);
export * from "./history";
export * from "./helpers";
export function navigate(location: LocationDescriptor) {
const currentLocation = navigation.getPath();
navigation.push(location);
if (currentLocation === navigation.getPath()) {
navigation.goBack(); // prevent sequences of same url in history
}
}
export function matchParams<P>(route: string | string[] | RouteProps) {
return matchPath<P>(navigation.location.pathname, route);
}
export function isActiveRoute(route: string | string[] | RouteProps): boolean {
return !!matchParams(route);
}
export function getMatchedClusterId(): string {
const matched = matchPath<IClusterViewRouteParams>(navigation.location.pathname, {
exact: true,
path: clusterViewRoute.path
});
return matched?.params.clusterId;
}
export function createPageParam<V = string>(init: PageParamInit<V>) {
return new PageParam<V>(init, navigation);
}
if (ipcRenderer) {
history = createBrowserHistory();
navigation = createObservableHistory(history);
if (process.isMainFrame) {
// Keep track of active cluster-id for handling IPC/menus/etc.
reaction(() => getMatchedClusterId(), clusterId => {
broadcastMessage("cluster-view:current-id", clusterId);
}, {
fireImmediately: true
});
}
// Handle navigation via IPC (e.g. from top menu)
subscribeToBroadcast("renderer:navigate", (event, url: string) => {
logger.info(`[IPC]: ${event.type} ${JSON.stringify(url)}`, event);
navigate(url);
});
// Reload dashboard window
subscribeToBroadcast("renderer:reload", () => {
location.reload();
});
}
bindEvents();

View File

@ -1,24 +1,25 @@
// Manage observable URL-param via location.search
// Manage observable URL-param from document.location.search
import { IObservableHistory } from "mobx-observable-history";
export interface PageParamInit<V = any> {
name: string;
isSystem?: boolean;
defaultValue?: V;
defaultValueStringified?: string | string[]; // serialized version of "defaultValue"
multiValues?: boolean; // false == by default
multiValueSep?: string; // joining multiple values with separator, default: ","
skipEmpty?: boolean; // skip empty value(s), e.g. "?param=", default: true
parse?(values: string[]): V; // deserialize from URL
stringify?(values: V): string | string[]; // serialize params to URL
parse?(value: string[]): V; // deserialize from URL
stringify?(value: V): string | string[]; // serialize params to URL
}
export class PageParam<V = any | any[]> {
export class PageParam<V = any> {
static SYSTEM_PREFIX = "lens-";
readonly name: string;
protected urlName: string;
constructor(private init: PageParamInit<V>, private history: IObservableHistory) {
constructor(readonly init: PageParamInit<V>, protected history: IObservableHistory) {
const { isSystem, name, skipEmpty = true } = init;
this.name = name;
@ -28,7 +29,7 @@ export class PageParam<V = any | any[]> {
this.urlName = `${isSystem ? PageParam.SYSTEM_PREFIX : ""}${name}`;
}
isEmpty(value: V) {
isEmpty(value: V | any) {
return [value].flat().every(value => value == "" || value == null);
}
@ -59,12 +60,10 @@ export class PageParam<V = any | any[]> {
}
get(): V {
const { history, urlName } = this;
const { multiValueSep, defaultValue, skipEmpty } = this.init;
const value = this.parse(history.searchParams.getAsArray(urlName, multiValueSep));
const value = this.parse(this.getRaw());
if (skipEmpty && this.isEmpty(value)) {
return defaultValue;
if (this.init.skipEmpty && this.isEmpty(value)) {
return this.getDefaultValue();
}
return value;
@ -76,12 +75,29 @@ export class PageParam<V = any | any[]> {
this.history.merge({ search }, replaceHistory);
}
getDefaultValue(){
return this.init.defaultValue;
setRaw(value: string | string[]) {
const { history, urlName } = this;
const { multiValues, multiValueSep, skipEmpty } = this.init;
const paramValue = multiValues ? [value].flat().join(multiValueSep) : String(value);
if (skipEmpty && this.isEmpty(paramValue)) {
history.searchParams.delete(urlName);
} else {
history.searchParams.set(urlName, paramValue);
}
}
isDefault() {
return this.get() === this.getDefaultValue();
getRaw(): string[] {
const { history, urlName } = this;
const { multiValueSep } = this.init;
return history.searchParams.getAsArray(urlName, multiValueSep);
}
getDefaultValue() {
const { defaultValue, defaultValueStringified } = this.init;
return defaultValueStringified ? this.parse([defaultValueStringified].flat()) : defaultValue;
}
clear() {
@ -113,3 +129,12 @@ export class PageParam<V = any | any[]> {
};
}
}
export function isPageParamInit(paramInit: PageParamInit | any = {}): paramInit is PageParamInit {
const init: PageParamInit = paramInit;
return [
init.defaultValue !== undefined || init.defaultValueStringified !== undefined,
typeof init.parse === "function" && typeof init.stringify === "function",
].some(Boolean);
}