From 0bfdb97ea0ab8ca1df16aabd250be4468f6b4d46 Mon Sep 17 00:00:00 2001 From: Jim Ehrismann <40840436+jim-docker@users.noreply.github.com> Date: Mon, 7 Dec 2020 15:25:17 -0500 Subject: [PATCH 1/8] extension store guide (#1663) * extension store guide Signed-off-by: Jim Ehrismann * improve docs as per reviews and rereading Signed-off-by: Jim Ehrismann * more doc tweaks Signed-off-by: Jim Ehrismann --- docs/extensions/guides/stores.md | 158 +++++++++++++++++++++++++++++-- mkdocs.yml | 3 +- 2 files changed, 153 insertions(+), 8 deletions(-) diff --git a/docs/extensions/guides/stores.md b/docs/extensions/guides/stores.md index 13982179e0..981a7cda3e 100644 --- a/docs/extensions/guides/stores.md +++ b/docs/extensions/guides/stores.md @@ -1,11 +1,155 @@ ---- -WIP ---- - # Stores -## ClusterStore +Stores are components that persist and synchronize state data. Lens utilizes a number of stores for maintaining a variety of state information. +A few of these are exposed by the extensions api for use by the extension developer. -## WorkspaceStore +- The `ClusterStore` manages cluster state data such as cluster details, and which cluster is active. +- The `WorkspaceStore` similarly manages workspace state data, such as workspace name, and which clusters belong to a given workspace. +- The `ExtensionStore` is a store for managing custom extension state data. -## ExtensionStore \ No newline at end of file +## ExtensionStore + +Extension developers can create their own store for managing state data by extending the `ExtensionStore` class. +This guide shows how to create a store for the [`appPreferences` guide example](../renderer-extension#apppreferences), which demonstrates how to add a custom preference to the Preferences page. +The preference is a simple boolean that indicates whether something is enabled or not. +The problem with that example is that the enabled state is not stored anywhere, and reverts to the default the next time Lens is started. + +The following example code creates a store for the `appPreferences` guide example: + +``` typescript +import { Store } from "@k8slens/extensions"; +import { observable, toJS } from "mobx"; + +export type ExamplePreferencesModel = { + enabled: boolean; +}; + +export class ExamplePreferencesStore extends Store.ExtensionStore { + + @observable enabled = false; + + private constructor() { + super({ + configName: "example-preferences-store", + defaults: { + enabled: false + } + }); + } + + protected fromStore({ enabled }: ExamplePreferencesModel): void { + this.enabled = enabled; + } + + toJSON(): ExamplePreferencesModel { + return toJS({ + enabled: this.enabled + }, { + recurseEverything: true + }); + } +} + +export const examplePreferencesStore = ExamplePreferencesStore.getInstance(); +``` + +First the extension's data model is defined using a simple type, `ExamplePreferencesModel`, which has a single field, `enabled`, representing the preference's state. +`ExamplePreferencesStore` extends `Store.ExtensionStore`, based on the `ExamplePreferencesModel`. +The field `enabled` is added to the `ExamplePreferencesStore` class to hold the "live" or current state of the preference. +Note the use of the `observer` decorator on the `enabled` field. +As for the [`appPreferences` guide example](../renderer-extension#apppreferences), [`mobx`](https://mobx.js.org/README.html) is used for the UI state management, ensuring the checkbox updates when activated by the user. + +Then the constructor and two abstract methods are implemented. +In the constructor, the name of the store (`"example-preferences-store"`), and the default (initial) value for the preference state (`enabled: false`) are specified. +The `fromStore()` method is called by Lens internals when the store is loaded, and gives the extension the opportunity to retrieve the stored state data values based on the defined data model. +Here, the `enabled` field of the `ExamplePreferencesStore` is set to the value from the store whenever `fromStore()` is invoked. +The `toJSON()` method is complementary to `fromStore()`, and is called when the store is being saved. +`toJSON()` must provide a JSON serializable object, facilitating its storage in JSON format. +The `toJS()` function from [`mobx`](https://mobx.js.org/README.html) is convenient for this purpose, and is used here. + +Finally, `examplePreferencesStore` is created by calling `ExamplePreferencesStore.getInstance()`, and exported for use by other parts of the extension. +Note that `examplePreferencesStore` is a singleton, calling this function again will not create a new store. + +The following example code, modified from the [`appPreferences` guide example](../renderer-extension#apppreferences) demonstrates how to use the extension store. +`examplePreferencesStore` must be loaded in the main process, where loaded stores are automatically saved when exiting Lens. This can be done in `./main.ts`: + +``` typescript +import { LensMainExtension } from "@k8slens/extensions"; +import { examplePreferencesStore } from "./src/example-preference-store"; + +export default class ExampleMainExtension extends LensMainExtension { + async onActivate() { + await examplePreferencesStore.loadExtension(this); + } +} +``` + +Here, `examplePreferencesStore` is loaded with `examplePreferencesStore.loadExtension(this)`, which is conveniently called from the `onActivate()` method of `ExampleMainExtension`. +Similarly, `examplePreferencesStore` must be loaded in the renderer process where the `appPreferences` are handled. This can be done in `./renderer.ts`: + +``` typescript +import { LensRendererExtension } from "@k8slens/extensions"; +import { ExamplePreferenceHint, ExamplePreferenceInput } from "./src/example-preference"; +import { examplePreferencesStore } from "./src/example-preference-store"; +import React from "react"; + +export default class ExampleRendererExtension extends LensRendererExtension { + + async onActivate() { + await examplePreferencesStore.loadExtension(this); + } + + appPreferences = [ + { + title: "Example Preferences", + components: { + Input: () => , + Hint: () => + } + } + ]; +} +``` + +Again, `examplePreferencesStore.loadExtension(this)` is called to load `examplePreferencesStore`, this time from the `onActivate()` method of `ExampleRendererExtension`. +Also, there is no longer the need for the `preference` field in the `ExampleRendererExtension` class, as the props for `ExamplePreferenceInput` is now `examplePreferencesStore`. +`ExamplePreferenceInput` is defined in `./src/example-preference.tsx`: + +``` typescript +import { Component } from "@k8slens/extensions"; +import { observer } from "mobx-react"; +import React from "react"; +import { ExamplePreferencesStore } from "./example-preference-store"; + +export class ExamplePreferenceProps { + preference: ExamplePreferencesStore; +} + +@observer +export class ExamplePreferenceInput extends React.Component { + + render() { + const { preference } = this.props; + + return ( + { preference.enabled = v; }} + /> + ); + } +} + +export class ExamplePreferenceHint extends React.Component { + render() { + return ( + This is an example of an appPreference for extensions. + ); + } +} +``` + +The only change here is that `ExamplePreferenceProps` defines its `preference` field as an `ExamplePreferencesStore` type. +Everything else works as before except now the `enabled` state persists across Lens restarts because it is managed by the +`examplePreferencesStore`. \ No newline at end of file diff --git a/mkdocs.yml b/mkdocs.yml index 3e95eae065..ad3e19572e 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -30,9 +30,10 @@ nav: - Color Reference: extensions/capabilities/color-reference.md - Extension Guides: - Overview: extensions/guides/README.md + - Generator: extensions/guides/generator.md - Main Extension: extensions/guides/main-extension.md - Renderer Extension: extensions/guides/renderer-extension.md - - Generator: extensions/guides/generator.md + - Stores: extensions/guides/stores.md - Working with mobx: extensions/guides/working-with-mobx.md - Testing and Publishing: - Testing Extensions: extensions/testing-and-publishing/testing.md From 2c25e1ee13417e600e29a839c570cf540036c574 Mon Sep 17 00:00:00 2001 From: Jari Kolehmainen Date: Tue, 8 Dec 2020 13:21:18 +0200 Subject: [PATCH 2/8] Register cluster page component properly to a route (#1688) * remove observer from app class Signed-off-by: Jari Kolehmainen * proper fix Signed-off-by: Jari Kolehmainen --- src/renderer/components/app.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/renderer/components/app.tsx b/src/renderer/components/app.tsx index 8fe31fc45d..1fb57fc0b9 100755 --- a/src/renderer/components/app.tsx +++ b/src/renderer/components/app.tsx @@ -157,9 +157,7 @@ export class App extends React.Component { const page = clusterPageRegistry.getByPageMenuTarget(menu.target); if (page) { - const pageComponent = () => ; - - return ; + return ; } } }); From 36031d222f51fe2c200db3e0693773fb55e1bc9e Mon Sep 17 00:00:00 2001 From: Jari Kolehmainen Date: Tue, 8 Dec 2020 16:08:28 +0200 Subject: [PATCH 3/8] Query all objects using single api call if admin and namespace list is not overridden (#1692) Signed-off-by: Jari Kolehmainen --- src/renderer/kube-object.store.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/renderer/kube-object.store.ts b/src/renderer/kube-object.store.ts index 03e582905e..e23adf3566 100644 --- a/src/renderer/kube-object.store.ts +++ b/src/renderer/kube-object.store.ts @@ -91,9 +91,14 @@ export abstract class KubeObjectStore extends ItemSt let items: T[]; try { - const { allowedNamespaces } = getHostedCluster(); + const { allowedNamespaces, accessibleNamespaces, isAdmin } = getHostedCluster(); + + if (isAdmin && accessibleNamespaces.length == 0) { + items = await this.loadItems(); + } else { + items = await this.loadItems(allowedNamespaces); + } - items = await this.loadItems(allowedNamespaces); items = this.filterItemsOnLoad(items); } finally { if (items) { From da41370486d02139954cc9553c2313131ef30405 Mon Sep 17 00:00:00 2001 From: pashevskii <53330707+pashevskii@users.noreply.github.com> Date: Tue, 8 Dec 2020 18:30:58 +0400 Subject: [PATCH 4/8] Fix status brick in pod-menu-extension (#1698) Signed-off-by: Pavel Ashevskii --- extensions/pod-menu/src/logs-menu.tsx | 2 +- extensions/pod-menu/src/shell-menu.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/extensions/pod-menu/src/logs-menu.tsx b/extensions/pod-menu/src/logs-menu.tsx index dfe3870d12..706efcf128 100644 --- a/extensions/pod-menu/src/logs-menu.tsx +++ b/extensions/pod-menu/src/logs-menu.tsx @@ -47,7 +47,7 @@ export class PodLogsMenu extends React.Component { return ( this.showLogs(container))} className="flex align-center"> {brick} - {name} + {name} ); }) diff --git a/extensions/pod-menu/src/shell-menu.tsx b/extensions/pod-menu/src/shell-menu.tsx index a93739e89f..4a5562b836 100644 --- a/extensions/pod-menu/src/shell-menu.tsx +++ b/extensions/pod-menu/src/shell-menu.tsx @@ -54,7 +54,7 @@ export class PodShellMenu extends React.Component { return ( this.execShell(name))} className="flex align-center"> - {name} + {name} ); }) From eed539d8d80a96188621c8042f00eae81d223716 Mon Sep 17 00:00:00 2001 From: Panu Horsmalahti Date: Tue, 8 Dec 2020 17:30:47 +0200 Subject: [PATCH 5/8] Add check to extension file watch (#1677) * Add check to extension file watch Signed-off-by: Panu Horsmalahti * Fix tests Signed-off-by: Panu Horsmalahti * Fix tests on Windows. Signed-off-by: Panu Horsmalahti * Add logging for Windows test debugging purposes. Signed-off-by: Panu Horsmalahti * Try to fix tests on Windows again. Signed-off-by: Panu Horsmalahti --- .../__tests__/extension-discovery.test.ts | 100 ++++++++++++++++++ src/extensions/extension-discovery.ts | 15 ++- src/jest.setup.ts | 3 + 3 files changed, 115 insertions(+), 3 deletions(-) create mode 100644 src/extensions/__tests__/extension-discovery.test.ts diff --git a/src/extensions/__tests__/extension-discovery.test.ts b/src/extensions/__tests__/extension-discovery.test.ts new file mode 100644 index 0000000000..0e72cf16fb --- /dev/null +++ b/src/extensions/__tests__/extension-discovery.test.ts @@ -0,0 +1,100 @@ +import { watch } from "chokidar"; +import { join, normalize } from "path"; +import { ExtensionDiscovery, InstalledExtension } from "../extension-discovery"; + +jest.mock("../../common/ipc"); +jest.mock("fs-extra"); +jest.mock("chokidar", () => ({ + watch: jest.fn() +})); +jest.mock("../extension-installer", () => ({ + extensionInstaller: { + extensionPackagesRoot: "", + installPackages: jest.fn() + } +})); + +const mockedWatch = watch as jest.MockedFunction; + +describe("ExtensionDiscovery", () => { + it("emits add for added extension", async done => { + globalThis.__non_webpack_require__.mockImplementationOnce(() => ({ + name: "my-extension" + })); + let addHandler: (filePath: string) => void; + + const mockWatchInstance: any = { + on: jest.fn((event: string, handler: typeof addHandler) => { + if (event === "add") { + addHandler = handler; + } + + return mockWatchInstance; + }) + }; + + mockedWatch.mockImplementationOnce(() => + (mockWatchInstance) as any + ); + const extensionDiscovery = new ExtensionDiscovery(); + + // Need to force isLoaded to be true so that the file watching is started + extensionDiscovery.isLoaded = true; + + await extensionDiscovery.initMain(); + + extensionDiscovery.events.on("add", (extension: InstalledExtension) => { + expect(extension).toEqual({ + absolutePath: expect.any(String), + id: normalize("node_modules/my-extension/package.json"), + isBundled: false, + isEnabled: false, + manifest: { + name: "my-extension", + }, + manifestPath: normalize("node_modules/my-extension/package.json"), + }); + done(); + }); + + addHandler(join(extensionDiscovery.localFolderPath, "/my-extension/package.json")); + }); + + it("doesn't emit add for added file under extension", async done => { + globalThis.__non_webpack_require__.mockImplementationOnce(() => ({ + name: "my-extension" + })); + let addHandler: (filePath: string) => void; + + const mockWatchInstance: any = { + on: jest.fn((event: string, handler: typeof addHandler) => { + if (event === "add") { + addHandler = handler; + } + + return mockWatchInstance; + }) + }; + + mockedWatch.mockImplementationOnce(() => + (mockWatchInstance) as any + ); + const extensionDiscovery = new ExtensionDiscovery(); + + // Need to force isLoaded to be true so that the file watching is started + extensionDiscovery.isLoaded = true; + + await extensionDiscovery.initMain(); + + const onAdd = jest.fn(); + + extensionDiscovery.events.on("add", onAdd); + + addHandler(join(extensionDiscovery.localFolderPath, "/my-extension/node_modules/dep/package.json")); + + setTimeout(() => { + expect(onAdd).not.toHaveBeenCalled(); + done(); + }, 10); + }); +}); diff --git a/src/extensions/extension-discovery.ts b/src/extensions/extension-discovery.ts index ab52027f15..d0bfbc4c16 100644 --- a/src/extensions/extension-discovery.ts +++ b/src/extensions/extension-discovery.ts @@ -1,4 +1,4 @@ -import chokidar from "chokidar"; +import { watch } from "chokidar"; import { ipcRenderer } from "electron"; import { EventEmitter } from "events"; import fs from "fs-extra"; @@ -138,7 +138,7 @@ export class ExtensionDiscovery { await this.whenLoaded; // chokidar works better than fs.watch - chokidar.watch(this.localFolderPath, { + watch(this.localFolderPath, { // For adding and removing symlinks to work, the depth has to be 1. depth: 1, // Try to wait until the file has been completely copied. @@ -156,9 +156,18 @@ export class ExtensionDiscovery { } handleWatchFileAdd = async (filePath: string) => { - if (path.basename(filePath) === manifestFilename) { + // e.g. "foo/package.json" + const relativePath = path.relative(this.localFolderPath, filePath); + + // Converts "foo/package.json" to ["foo", "package.json"], where length of 2 implies + // that the added file is in a folder under local folder path. + // This safeguards against a file watch being triggered under a sub-directory which is not an extension. + const isUnderLocalFolderPath = relativePath.split(path.sep).length === 2; + + if (path.basename(filePath) === manifestFilename && isUnderLocalFolderPath) { try { const absPath = path.dirname(filePath); + // this.loadExtensionFromPath updates this.packagesJson const extension = await this.loadExtensionFromPath(absPath); diff --git a/src/jest.setup.ts b/src/jest.setup.ts index cccef274f0..ef6565c907 100644 --- a/src/jest.setup.ts +++ b/src/jest.setup.ts @@ -1,3 +1,6 @@ import fetchMock from "jest-fetch-mock"; // rewire global.fetch to call 'fetchMock' fetchMock.enableMocks(); + +// Mock __non_webpack_require__ for tests +globalThis.__non_webpack_require__ = jest.fn(); From 25deade77043b984260db2082d888d2df7ed7f99 Mon Sep 17 00:00:00 2001 From: Panu Horsmalahti Date: Tue, 8 Dec 2020 21:04:01 +0200 Subject: [PATCH 6/8] Remove broken symlink from node_modules on uninstall (#1695) Signed-off-by: Panu Horsmalahti --- src/extensions/extension-discovery.ts | 33 +++++++++++++++---- .../+extensions/__tests__/extensions.test.tsx | 2 +- .../components/+extensions/extensions.tsx | 2 +- 3 files changed, 29 insertions(+), 8 deletions(-) diff --git a/src/extensions/extension-discovery.ts b/src/extensions/extension-discovery.ts index d0bfbc4c16..287c232be9 100644 --- a/src/extensions/extension-discovery.ts +++ b/src/extensions/extension-discovery.ts @@ -206,7 +206,7 @@ export class ExtensionDiscovery { // The path to the manifest file is the lens extension id // Note that we need to use the symlinked path - const lensExtensionId = path.join(this.nodeModulesPath, extensionName, "package.json"); + const lensExtensionId = path.join(this.nodeModulesPath, extensionName, manifestFilename); logger.info(`${logModule} removed extension ${extensionName}`); this.events.emit("remove", lensExtensionId as LensExtensionId); @@ -217,12 +217,17 @@ export class ExtensionDiscovery { }; /** - * Uninstalls extension by path. + * Uninstalls extension. * The application will detect the folder unlink and remove the extension from the UI automatically. - * @param absolutePath Path to the non-symlinked folder of the extension + * @param extension Extension to unistall. */ - async uninstallExtension(absolutePath: string) { - logger.info(`${logModule} Uninstalling ${absolutePath}`); + async uninstallExtension({ absolutePath, manifest }: InstalledExtension) { + logger.info(`${logModule} Uninstalling ${manifest.name}`); + + // remove the symlink under node_modules. + // If we don't remove the symlink, the uninstall would leave a non-working symlink, + // which wouldn't be fixed if the extension was reinstalled, causing the extension not to work. + await fs.remove(this.getInstalledPath(manifest.name)); const exists = await fs.pathExists(absolutePath); @@ -269,6 +274,22 @@ export class ExtensionDiscovery { return extensions; } + /** + * Returns the symlinked path to the extension folder, + * e.g. "/Users//Library/Application Support/Lens/node_modules/@publisher/extension" + */ + protected getInstalledPath(name: string) { + return path.join(this.nodeModulesPath, name); + } + + /** + * Returns the symlinked path to the package.json, + * e.g. "/Users//Library/Application Support/Lens/node_modules/@publisher/extension/package.json" + */ + protected getInstalledManifestPath(name: string) { + return path.join(this.getInstalledPath(name), manifestFilename); + } + protected async getByManifest(manifestPath: string, { isBundled = false }: { isBundled?: boolean; } = {}): Promise { @@ -279,7 +300,7 @@ export class ExtensionDiscovery { fs.accessSync(manifestPath, fs.constants.F_OK); manifestJson = __non_webpack_require__(manifestPath); - const installedManifestPath = path.join(this.nodeModulesPath, manifestJson.name, "package.json"); + const installedManifestPath = this.getInstalledManifestPath(manifestJson.name); this.packagesJson.dependencies[manifestJson.name] = path.dirname(manifestPath); const isEnabled = isBundled || extensionsStore.isEnabled(installedManifestPath); diff --git a/src/renderer/components/+extensions/__tests__/extensions.test.tsx b/src/renderer/components/+extensions/__tests__/extensions.test.tsx index cb4db0fece..8899d9d74c 100644 --- a/src/renderer/components/+extensions/__tests__/extensions.test.tsx +++ b/src/renderer/components/+extensions/__tests__/extensions.test.tsx @@ -68,7 +68,7 @@ describe("Extensions", () => { // Approve confirm dialog fireEvent.click(screen.getByText("Yes")); - expect(extensionDiscovery.uninstallExtension).toHaveBeenCalledWith("/absolute/path"); + expect(extensionDiscovery.uninstallExtension).toHaveBeenCalled(); expect(screen.getByText("Disable").closest("button")).toBeDisabled(); expect(screen.getByText("Uninstall").closest("button")).toBeDisabled(); }); diff --git a/src/renderer/components/+extensions/extensions.tsx b/src/renderer/components/+extensions/extensions.tsx index 6a94b49480..cb38791a03 100644 --- a/src/renderer/components/+extensions/extensions.tsx +++ b/src/renderer/components/+extensions/extensions.tsx @@ -413,7 +413,7 @@ export class Extensions extends React.Component { displayName }); - await extensionDiscovery.uninstallExtension(extension.absolutePath); + await extensionDiscovery.uninstallExtension(extension); } catch (error) { Notifications.error(

Uninstalling extension {displayName} has failed: {error?.message ?? ""}

From f5e331e706b7165e39b6b1010b0fb1ed546b6b80 Mon Sep 17 00:00:00 2001 From: Jari Kolehmainen Date: Wed, 9 Dec 2020 09:19:28 +0200 Subject: [PATCH 7/8] Do not call initMainWindow if windowManager is not ready (#1714) Signed-off-by: Jari Kolehmainen --- src/main/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/index.ts b/src/main/index.ts index cad2235743..ea3c53f9b9 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -115,7 +115,7 @@ app.on("activate", (event, hasVisibleWindows) => { logger.info("APP:ACTIVATE", { hasVisibleWindows }); if (!hasVisibleWindows) { - windowManager.initMainWindow(); + windowManager?.initMainWindow(false); } }); From fc5c42c8079c46a47e14db7a5afdb362b2381ff5 Mon Sep 17 00:00:00 2001 From: Jari Kolehmainen Date: Wed, 9 Dec 2020 09:42:39 +0200 Subject: [PATCH 8/8] v4.0.1 Signed-off-by: Jari Kolehmainen --- package.json | 2 +- static/RELEASE_NOTES.md | 10 +++++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 5d82fa5f50..3b5a340052 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "kontena-lens", "productName": "Lens", "description": "Lens - The Kubernetes IDE", - "version": "4.0.0", + "version": "4.0.1", "main": "static/build/main.js", "copyright": "© 2020, Mirantis, Inc.", "license": "MIT", diff --git a/static/RELEASE_NOTES.md b/static/RELEASE_NOTES.md index e5e463f363..e914b6ad01 100644 --- a/static/RELEASE_NOTES.md +++ b/static/RELEASE_NOTES.md @@ -2,7 +2,15 @@ Here you can find description of changes we've built into each release. While we try our best to make each upgrade automatic and as smooth as possible, there may be some cases where you might need to do something to ensure the application works smoothly. So please read through the release highlights! -## 4.0.0 (current version) +## 4.0.1 (current version) + +- Extension install/uninstall fixes +- Fix status brick styles in pod-menu-extension +- MacOS: fix error on app start +- Performance fix: query all objects using single api call if admin and namespace list is not overridden +- Extension API fix: register a cluster page component properly to a route + +## 4.0.0 - Extension API - Improved pod logs