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

Extensions-api: initial hello-world example (#817)

Signed-off-by: Roman <ixrock@gmail.com>
Co-authored-by: Alex Andreev <alex.andreev.email@gmail.com>
This commit is contained in:
Roman 2020-09-09 16:19:02 +03:00 committed by GitHub
parent f1b03990ea
commit 5daf53e6cb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 190 additions and 91 deletions

View File

@ -24,7 +24,8 @@ module.exports = {
files: [ files: [
"build/*.ts", "build/*.ts",
"src/**/*.ts", "src/**/*.ts",
"integration/**/*.ts" "integration/**/*.ts",
"src/extensions/**/*.ts*"
], ],
parser: "@typescript-eslint/parser", parser: "@typescript-eslint/parser",
extends: [ extends: [

1
.gitignore vendored
View File

@ -10,5 +10,6 @@ binaries/client/
binaries/server/ binaries/server/
src/extensions/*/*.js src/extensions/*/*.js
src/extensions/*/*.d.ts src/extensions/*/*.d.ts
src/extensions/example-extension/src/**
locales/**/**.js locales/**/**.js
lens.log lens.log

View File

@ -1,17 +0,0 @@
import { LensExtension, Icon, LensRuntimeRendererEnv } from "@lens/extensions"; // fixme: map to generated types from "extension-api.d.ts"
// todo: register custom icon in cluster-menu
// todo: register custom view by clicking the item
export default class ExampleExtension extends LensExtension {
async enable(runtime: /*LensRuntimeRendererEnv*/ any): Promise<any> {
try {
super.enable(runtime);
runtime.logger.info('EXAMPLE EXTENSION: ENABLE() override');
} catch (err){
console.error(err)
}
}
}
// <Icon material="camera" onClick={() => console.log("done")}/>

View File

@ -0,0 +1,47 @@
import { Button, DynamicPageType, Icon, LensExtension } from "@lens/extensions"; // fixme: map to generated types from "extension-api.ts"
import React from "react";
import path from "path";
export default class ExampleExtension extends LensExtension {
onActivate() {
console.log('EXAMPLE EXTENSION: ACTIVATED', this.getMeta());
this.registerPage({
type: DynamicPageType.CLUSTER,
path: "/extension-example",
menuTitle: "Example Extension",
components: {
Page: () => <ExtensionPage extension={this}/>,
MenuIcon: ExtensionIcon,
}
})
}
onDeactivate() {
console.log('EXAMPLE EXTENSION: DEACTIVATED', this.getMeta());
}
}
export function ExtensionIcon(props: {} /*IconProps |*/) {
return <Icon {...props} material="camera" tooltip={path.basename(__filename)}/>
}
export class ExtensionPage extends React.Component<{ extension: ExampleExtension }> {
deactivate = () => {
const { extension } = this.props;
extension.runtime.navigate("/")
extension.disable();
}
render() {
const { MainLayout } = this.props.extension.runtime.components;
return (
<MainLayout className="ExampleExtension">
<div className="flex column gaps align-flex-start">
<p>Hello from extensions-api!</p>
<p>File: <i>{__filename}</i></p>
<Button accent label="Deactivate" onClick={this.deactivate}/>
</div>
</MainLayout>
)
}
}

View File

@ -2,9 +2,10 @@
"name": "extension-example", "name": "extension-example",
"version": "1.0.0", "version": "1.0.0",
"description": "Example extension", "description": "Example extension",
"main": "example-extension.ts", "main": "example-extension.js",
"lens": { "lens": {
"metadata": {} "metadata": {},
"styles": []
}, },
"dependencies": { "dependencies": {
} }

View File

@ -8,6 +8,6 @@
}, },
"include": [ "include": [
"../../../types", "../../../types",
"./example-extension.ts" "./example-extension.tsx"
] ]
} }

View File

@ -2,7 +2,8 @@
export type { LensRuntimeRendererEnv } from "./lens-runtime"; export type { LensRuntimeRendererEnv } from "./lens-runtime";
// APIs // APIs
export * from "./extension" export * from "./lens-extension"
export { DynamicPageType } from "./register-page";
// Common UI components // Common UI components
export * from "../renderer/components/icon" export * from "../renderer/components/icon"

View File

@ -3,17 +3,17 @@ import path from "path";
import fs from "fs-extra"; import fs from "fs-extra";
import { action, observable, reaction, toJS, } from "mobx"; import { action, observable, reaction, toJS, } from "mobx";
import { BaseStore } from "../common/base-store"; import { BaseStore } from "../common/base-store";
import { ExtensionId, ExtensionManifest, ExtensionVersion, LensExtension } from "./extension"; import { ExtensionId, ExtensionManifest, ExtensionVersion, LensExtension } from "./lens-extension";
import { isDevelopment, isProduction, isTestEnv } from "../common/vars"; import { isDevelopment } from "../common/vars";
import logger from "../main/logger"; import logger from "../main/logger";
export interface ExtensionStoreModel { export interface ExtensionStoreModel {
version: ExtensionVersion; version: ExtensionVersion;
extensions: Record<ExtensionId, ExtensionModel> extensions: [ExtensionId, ExtensionModel][]
} }
export interface ExtensionModel { export interface ExtensionModel {
id?: ExtensionId; // available in lens-extension instance id: ExtensionId;
version: ExtensionVersion; version: ExtensionVersion;
name: string; name: string;
manifestPath: string; manifestPath: string;
@ -35,7 +35,6 @@ export class ExtensionStore extends BaseStore<ExtensionStoreModel> {
private constructor() { private constructor() {
super({ super({
configName: "lens-extension-store", configName: "lens-extension-store",
syncEnabled: false,
}); });
} }
@ -48,7 +47,7 @@ export class ExtensionStore extends BaseStore<ExtensionStoreModel> {
if (isDevelopment) { if (isDevelopment) {
return path.resolve(__static, "../src/extensions"); return path.resolve(__static, "../src/extensions");
} }
return path.resolve(__static, "../extensions"); //todo figure out prod return path.resolve(__static, "../extensions");
} }
async load() { async load() {
@ -80,7 +79,6 @@ export class ExtensionStore extends BaseStore<ExtensionStoreModel> {
try { try {
manifestJson = __non_webpack_require__(manifestPath); // "__non_webpack_require__" converts to native node's require()-call manifestJson = __non_webpack_require__(manifestPath); // "__non_webpack_require__" converts to native node's require()-call
mainJs = path.resolve(path.dirname(manifestPath), manifestJson.main); mainJs = path.resolve(path.dirname(manifestPath), manifestJson.main);
mainJs = mainJs.replace(/\.ts$/i, ".js"); // todo: compile *.ts on the fly?
const extensionModule = __non_webpack_require__(mainJs); const extensionModule = __non_webpack_require__(mainJs);
return { return {
manifestPath: manifestPath, manifestPath: manifestPath,
@ -132,7 +130,7 @@ export class ExtensionStore extends BaseStore<ExtensionStoreModel> {
this.version = version; this.version = version;
} }
if (extensions) { if (extensions) {
const currentExtensions = new Map(Object.entries(extensions)); const currentExtensions = new Map(extensions);
this.extensions.forEach(extension => { this.extensions.forEach(extension => {
if (!currentExtensions.has(extension.id)) { if (!currentExtensions.has(extension.id)) {
this.removed.set(extension.id, extension); this.removed.set(extension.id, extension);
@ -164,7 +162,7 @@ export class ExtensionStore extends BaseStore<ExtensionStoreModel> {
toJSON(): ExtensionStoreModel { toJSON(): ExtensionStoreModel {
return toJS({ return toJS({
version: this.version, version: this.version,
extensions: this.extensions.toJSON(), extensions: Array.from(this.extensions).map(([id, instance]) => [id, instance.toJSON()]),
}, { }, {
recurseEverything: true, recurseEverything: true,
}) })

View File

@ -1,17 +1,20 @@
import type { ExtensionModel } from "./extension-store"; import type { ExtensionModel } from "./extension-store";
import type { LensRuntimeRendererEnv } from "./lens-runtime"; import type { LensRuntimeRendererEnv } from "./lens-runtime";
import type { PageRegistration } from "./register-page";
import { readJsonSync } from "fs-extra"; import { readJsonSync } from "fs-extra";
import { action, observable, toJS } from "mobx"; import { action, observable, toJS } from "mobx";
import extensionManifest from "./example-extension/package.json" import extensionManifest from "./example-extension/package.json"
import logger from "../main/logger"; import logger from "../main/logger";
export type ExtensionId = string; // instance-id or abs path to "%lens-extension/manifest.json" export type ExtensionId = string | ExtensionPackageJsonPath;
export type ExtensionPackageJsonPath = string;
export type ExtensionVersion = string | number; export type ExtensionVersion = string | number;
export type ExtensionManifest = typeof extensionManifest & ExtensionModel; export type ExtensionManifest = typeof extensionManifest & ExtensionModel;
export class LensExtension implements ExtensionModel { export class LensExtension implements ExtensionModel {
public id: ExtensionId; public id: ExtensionId;
public updateUrl: string; public updateUrl: string;
protected disposers: Function[] = [];
@observable name = ""; @observable name = "";
@observable description = ""; @observable description = "";
@ -19,7 +22,7 @@ export class LensExtension implements ExtensionModel {
@observable manifest: ExtensionManifest; @observable manifest: ExtensionManifest;
@observable manifestPath: string; @observable manifestPath: string;
@observable isEnabled = false; @observable isEnabled = false;
@observable.ref runtime: Partial<LensRuntimeRendererEnv> = {}; @observable.ref runtime: LensRuntimeRendererEnv;
constructor(model: ExtensionModel, manifest: ExtensionManifest) { constructor(model: ExtensionModel, manifest: ExtensionManifest) {
this.importModel(model, manifest); this.importModel(model, manifest);
@ -41,14 +44,27 @@ export class LensExtension implements ExtensionModel {
this.isEnabled = true; this.isEnabled = true;
this.runtime = runtime; this.runtime = runtime;
console.log(`[EXTENSION]: enabled ${this.name}@${this.version}`, this.getMeta()); console.log(`[EXTENSION]: enabled ${this.name}@${this.version}`, this.getMeta());
this.onActivate();
} }
async disable() { async disable() {
this.onDeactivate();
this.isEnabled = false; this.isEnabled = false;
this.runtime = {}; this.runtime = null;
this.disposers.forEach(cleanUp => cleanUp());
this.disposers.length = 0;
console.log(`[EXTENSION]: disabled ${this.name}@${this.version}`, this.getMeta()); console.log(`[EXTENSION]: disabled ${this.name}@${this.version}`, this.getMeta());
} }
// todo: add more hooks
protected onActivate() {
// mock
}
protected onDeactivate() {
// mock
}
// todo // todo
async install(downloadUrl?: string) { async install(downloadUrl?: string) {
return; return;
@ -76,7 +92,7 @@ export class LensExtension implements ExtensionModel {
} }
toJSON(): ExtensionModel { toJSON(): ExtensionModel {
return { return toJS({
id: this.id, id: this.id,
name: this.name, name: this.name,
version: this.version, version: this.version,
@ -84,6 +100,16 @@ export class LensExtension implements ExtensionModel {
manifestPath: this.manifestPath, manifestPath: this.manifestPath,
enabled: this.isEnabled, enabled: this.isEnabled,
updateUrl: this.updateUrl, updateUrl: this.updateUrl,
}, {
recurseEverything: true,
})
}
// Runtime helpers
protected registerPage(params: PageRegistration, autoDisable = true) {
const dispose = this.runtime.dynamicPages.register(params);
if (autoDisable) {
this.disposers.push(dispose);
} }
} }
} }

View File

@ -1,19 +1,26 @@
// Lens runtime for injecting to extension on activation // Lens renderer runtime params available to extensions after activation
import { apiManager } from "../renderer/api/api-manager";
import logger from "../main/logger"; import logger from "../main/logger";
import { dynamicPages } from "../renderer/components/cluster-manager/register-page"; import { dynamicPages } from "./register-page";
import { MainLayout } from "../renderer/components/layout/main-layout";
import { navigate } from "../renderer/navigation";
export interface LensRuntimeRendererEnv { export interface LensRuntimeRendererEnv {
apiManager: typeof apiManager; navigate: typeof navigate;
logger: typeof logger; logger: typeof logger;
dynamicPages: typeof dynamicPages dynamicPages: typeof dynamicPages
components: {
MainLayout: typeof MainLayout
}
} }
// todo: expose more public runtime apis: stores, managers, etc.
export function getLensRuntime(): LensRuntimeRendererEnv { export function getLensRuntime(): LensRuntimeRendererEnv {
return { return {
apiManager,
logger, logger,
navigate,
dynamicPages, dynamicPages,
components: {
MainLayout // fixme: refactor, import as pure component from "@lens/extensions"
}
} }
} }

View File

@ -0,0 +1,46 @@
// Extensions-api -> Dynamic pages
import { computed, observable } from "mobx";
import React from "react";
import type { IconProps } from "../renderer/components/icon";
export enum DynamicPageType {
GLOBAL = "lens-scope",
CLUSTER = "cluster-view-scope",
}
export interface PageRegistration {
path: string; // route-path
menuTitle: string;
type: DynamicPageType;
components: PageComponents;
}
export interface PageComponents {
Page: React.ComponentType<any>;
MenuIcon: React.ComponentType<IconProps>;
}
export class PagesStore {
protected pages = observable.array<PageRegistration>([], { deep: false });
@computed get globalPages() {
return this.pages.filter(page => page.type === DynamicPageType.GLOBAL);
}
@computed get clusterPages() {
return this.pages.filter(page => page.type === DynamicPageType.CLUSTER);
}
// todo: verify paths to avoid collision with existing pages
register(params: PageRegistration) {
this.pages.push(params);
return () => {
this.pages.replace(
this.pages.filter(page => page.components !== params.components)
)
};
}
}
export const dynamicPages = new PagesStore();

View File

@ -10,6 +10,7 @@ import { i18nStore } from "./i18n";
import { themeStore } from "./theme.store"; import { themeStore } from "./theme.store";
import { App } from "./components/app"; import { App } from "./components/app";
import { LensApp } from "./lens-app"; import { LensApp } from "./lens-app";
import { getLensRuntime } from "../extensions/lens-runtime";
type AppComponent = React.ComponentType & { type AppComponent = React.ComponentType & {
init?(): void; init?(): void;
@ -32,6 +33,7 @@ export async function bootstrap(App: AppComponent) {
// init app's dependencies if any // init app's dependencies if any
if (App.init) { if (App.init) {
await App.init(); await App.init();
extensionStore.autoEnableOnLoad(getLensRuntime);
} }
render(<App/>, rootElem); render(<App/>, rootElem);
} }

View File

@ -35,6 +35,7 @@ import { getHostedCluster, getHostedClusterId } from "../../common/cluster-store
import logger from "../../main/logger"; import logger from "../../main/logger";
import { clusterIpc } from "../../common/cluster-ipc"; import { clusterIpc } from "../../common/cluster-ipc";
import { webFrame } from "electron"; import { webFrame } from "electron";
import { dynamicPages } from "../../extensions/register-page";
@observer @observer
export class App extends React.Component { export class App extends React.Component {
@ -71,6 +72,9 @@ export class App extends React.Component {
<Route component={CustomResources} {...crdRoute}/> <Route component={CustomResources} {...crdRoute}/>
<Route component={UserManagement} {...usersManagementRoute}/> <Route component={UserManagement} {...usersManagementRoute}/>
<Route component={Apps} {...appsRoute}/> <Route component={Apps} {...appsRoute}/>
{dynamicPages.clusterPages.map(({ path, components: { Page } }) => {
return <Route key={path} path={path} component={Page}/>
})}
<Redirect exact from="/" to={this.startURL}/> <Redirect exact from="/" to={this.startURL}/>
<Route component={NotFound}/> <Route component={NotFound}/>
</Switch> </Switch>

View File

@ -15,6 +15,7 @@ import { Extensions, extensionsRoute } from "../+extensions";
import { clusterViewRoute, clusterViewURL, getMatchedCluster, getMatchedClusterId } from "./cluster-view.route"; import { clusterViewRoute, clusterViewURL, getMatchedCluster, getMatchedClusterId } 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 { dynamicPages } from "../../../extensions/register-page";
@observer @observer
export class ClusterManager extends React.Component { export class ClusterManager extends React.Component {
@ -62,6 +63,9 @@ export class ClusterManager extends React.Component {
<Route component={ClusterView} {...clusterViewRoute}/> <Route component={ClusterView} {...clusterViewRoute}/>
<Route component={ClusterSettings} {...clusterSettingsRoute}/> <Route component={ClusterSettings} {...clusterSettingsRoute}/>
<Route component={Extensions} {...extensionsRoute}/> <Route component={Extensions} {...extensionsRoute}/>
{dynamicPages.globalPages.map(({ path, components: { Page } }) => {
return <Route key={path} path={path} component={Page}/>
})}
<Redirect exact to={this.startUrl}/> <Redirect exact to={this.startUrl}/>
</Switch> </Switch>
</main> </main>

View File

@ -10,7 +10,7 @@ import { ClusterId, clusterStore } from "../../../common/cluster-store";
import { workspaceStore } from "../../../common/workspace-store"; import { workspaceStore } from "../../../common/workspace-store";
import { ClusterIcon } from "../cluster-icon"; import { ClusterIcon } from "../cluster-icon";
import { Icon } from "../icon"; import { Icon } from "../icon";
import { cssNames, IClassName, autobind } from "../../utils"; import { autobind, cssNames, IClassName } from "../../utils";
import { Badge } from "../badge"; import { Badge } from "../badge";
import { navigate } from "../../navigation"; import { navigate } from "../../navigation";
import { addClusterURL } from "../+add-cluster"; import { addClusterURL } from "../+add-cluster";
@ -20,8 +20,8 @@ import { Tooltip } from "../tooltip";
import { ConfirmDialog } from "../confirm-dialog"; import { ConfirmDialog } from "../confirm-dialog";
import { clusterIpc } from "../../../common/cluster-ipc"; import { clusterIpc } from "../../../common/cluster-ipc";
import { clusterViewURL, getMatchedClusterId } from "./cluster-view.route"; import { clusterViewURL, getMatchedClusterId } from "./cluster-view.route";
import { DragDropContext, Droppable, Draggable, DropResult, DroppableProvided, DraggableProvided } from "react-beautiful-dnd"; import { DragDropContext, Draggable, DraggableProvided, Droppable, DroppableProvided, DropResult } from "react-beautiful-dnd";
import { dynamicPages } from "./register-page"; import { dynamicPages } from "../../../extensions/register-page";
interface Props { interface Props {
className?: IClassName; className?: IClassName;
@ -149,9 +149,8 @@ export class ClustersMenu extends React.Component<Props> {
)} )}
</div> </div>
<div className="dynamic-pages"> <div className="dynamic-pages">
{Array.from(dynamicPages.all).map(([path, { MenuIcon }]) => { {dynamicPages.globalPages.map(({ path, components: { MenuIcon } }) => {
if (!MenuIcon) return; return <MenuIcon key={path} onClick={() => navigate(path)}/>
return <MenuIcon onClick={() => navigate(path)}/>
})} })}
</div> </div>
</div> </div>

View File

@ -1,28 +0,0 @@
// Dynamic pages
import React from "react";
import { observable } from "mobx";
import type { IconProps } from "../icon";
export interface PageComponents {
Main: React.ComponentType<any>;
MenuIcon: React.ComponentType<IconProps>;
}
export class PagesStore {
all = observable.map<string, PageComponents>();
getComponents(path: string): PageComponents | null {
return this.all.get(path);
}
register(path: string, components: PageComponents) {
this.all.set(path, components);
}
unregister(path: string) {
this.all.delete(path);
}
}
export const dynamicPages = new PagesStore();

View File

@ -17,7 +17,7 @@ export interface TabRoute extends RouteProps {
url: string; url: string;
} }
interface Props { export interface MainLayoutProps {
className?: any; className?: any;
tabs?: TabRoute[]; tabs?: TabRoute[];
footer?: React.ReactNode; footer?: React.ReactNode;
@ -27,7 +27,7 @@ interface Props {
} }
@observer @observer
export class MainLayout extends React.Component<Props> { export class MainLayout extends React.Component<MainLayoutProps> {
public storage = createStorage("main_layout", { pinnedSidebar: true }); public storage = createStorage("main_layout", { pinnedSidebar: true });
@observable isPinned = this.storage.get().pinnedSidebar; @observable isPinned = this.storage.get().pinnedSidebar;

View File

@ -28,6 +28,7 @@ import { CrdList, crdResourcesRoute, crdRoute, crdURL } from "../+custom-resourc
import { CustomResources } from "../+custom-resources/custom-resources"; import { CustomResources } from "../+custom-resources/custom-resources";
import { navigation } from "../../navigation"; import { navigation } from "../../navigation";
import { isAllowedResource } from "../../../common/rbac" import { isAllowedResource } from "../../../common/rbac"
import { dynamicPages } from "../../../extensions/register-page";
const SidebarContext = React.createContext<SidebarContextValue>({ pinned: false }); const SidebarContext = React.createContext<SidebarContextValue>({ pinned: false });
type SidebarContextValue = { type SidebarContextValue = {
@ -183,6 +184,18 @@ export class Sidebar extends React.Component<Props> {
> >
{this.renderCustomResources()} {this.renderCustomResources()}
</SidebarNavItem> </SidebarNavItem>
{dynamicPages.clusterPages.map(({ path, menuTitle, components: { MenuIcon } }) => {
return (
<SidebarNavItem
key={path}
id={`extension-${path}`}
url={path}
routePath={path}
text={menuTitle}
icon={<MenuIcon/>}
/>
)
})}
</div> </div>
</div> </div>
</SidebarContext.Provider> </SidebarContext.Provider>

View File

@ -11,15 +11,9 @@ import { ErrorBoundary } from "./components/error-boundary";
import { WhatsNew, whatsNewRoute } from "./components/+whats-new"; import { WhatsNew, whatsNewRoute } from "./components/+whats-new";
import { Notifications } from "./components/notifications"; import { Notifications } from "./components/notifications";
import { ConfirmDialog } from "./components/confirm-dialog"; import { ConfirmDialog } from "./components/confirm-dialog";
import { extensionStore } from "../extensions/extension-store";
import { getLensRuntime } from "../extensions/lens-runtime";
@observer @observer
export class LensApp extends React.Component { export class LensApp extends React.Component {
componentDidMount() {
extensionStore.autoEnableOnLoad(getLensRuntime);
}
render() { render() {
return ( return (
<I18nProvider i18n={_i18n}> <I18nProvider i18n={_i18n}>