diff --git a/src/common/__tests__/cluster-store.test.ts b/src/common/__tests__/cluster-store.test.ts index 2dc36b795c..6fb5f40659 100644 --- a/src/common/__tests__/cluster-store.test.ts +++ b/src/common/__tests__/cluster-store.test.ts @@ -43,13 +43,17 @@ jest.mock("electron", () => { }, ipcMain: { handle: jest.fn(), - on: jest.fn() + on: jest.fn(), + removeAllListeners: jest.fn(), + off: jest.fn(), + send: jest.fn(), } }; }); describe("empty config", () => { beforeEach(async () => { + ClusterStore.getInstance(false)?.unregisterIpcListener(); ClusterStore.resetInstance(); const mockOpts = { "tmp": { diff --git a/src/common/base-store.ts b/src/common/base-store.ts index 91d3ef8312..4597059dca 100644 --- a/src/common/base-store.ts +++ b/src/common/base-store.ts @@ -124,8 +124,8 @@ export abstract class BaseStore extends Singleton { } unregisterIpcListener() { - ipcRenderer.removeAllListeners(this.syncMainChannel); - ipcRenderer.removeAllListeners(this.syncRendererChannel); + ipcRenderer?.removeAllListeners(this.syncMainChannel); + ipcRenderer?.removeAllListeners(this.syncRendererChannel); } disableSync() { @@ -167,7 +167,7 @@ export abstract class BaseStore extends Singleton { /** * toJSON is called when syncing the store to the filesystem. It should - * produce a JSON serializable object representaion of the current state. + * produce a JSON serializable object representation of the current state. * * It is recommended that a round trip is valid. Namely, calling * `this.fromStore(this.toJSON())` shouldn't change the state. diff --git a/src/common/cluster-store.ts b/src/common/cluster-store.ts index 56deb35758..57cf900f57 100644 --- a/src/common/cluster-store.ts +++ b/src/common/cluster-store.ts @@ -1,5 +1,5 @@ import path from "path"; -import { app, ipcRenderer, remote, webFrame } from "electron"; +import { app, ipcMain, ipcRenderer, remote, webFrame } from "electron"; import { unlink } from "fs-extra"; import { action, comparer, computed, observable, reaction, toJS } from "mobx"; import { BaseStore } from "./base-store"; @@ -12,6 +12,7 @@ import { saveToAppFiles } from "./utils/saveToAppFiles"; import { KubeConfig } from "@kubernetes/client-node"; import { handleRequest, requestMain, subscribeToBroadcast, unsubscribeAllFromBroadcast } from "./ipc"; import { ResourceType } from "../renderer/components/cluster-settings/components/cluster-metrics-setting"; +import { disposer, noop } from "./utils"; export interface ClusterIconUpload { clusterId: string; @@ -111,6 +112,7 @@ export class ClusterStore extends BaseStore { @observable clusters = observable.map(); private static stateRequestChannel = "cluster:states"; + protected disposer = disposer(); constructor() { super({ @@ -143,7 +145,7 @@ export class ClusterStore extends BaseStore { cluster.setState(clusterState.state); } }); - } else { + } else if (ipcMain) { handleRequest(ClusterStore.stateRequestChannel, (): clusterStateSync[] => { const states: clusterStateSync[] = []; @@ -160,13 +162,16 @@ export class ClusterStore extends BaseStore { } protected pushStateToViewsAutomatically() { - if (!ipcRenderer) { - reaction(() => this.enabledClustersList, () => { - this.pushState(); - }); - reaction(() => this.connectedClustersList, () => { - this.pushState(); - }); + if (ipcMain) { + this.disposer.push( + reaction(() => this.enabledClustersList, () => { + this.pushState(); + }), + reaction(() => this.connectedClustersList, () => { + this.pushState(); + }), + () => unsubscribeAllFromBroadcast("cluster:state"), + ); } } @@ -180,7 +185,7 @@ export class ClusterStore extends BaseStore { unregisterIpcListener() { super.unregisterIpcListener(); - unsubscribeAllFromBroadcast("cluster:state"); + this.disposer(); } pushState() { @@ -288,7 +293,7 @@ export class ClusterStore extends BaseStore { // remove only custom kubeconfigs (pasted as text) if (cluster.kubeConfigPath == ClusterStore.getCustomKubeConfigPath(clusterId)) { - unlink(cluster.kubeConfigPath).catch(() => null); + await unlink(cluster.kubeConfigPath).catch(noop); } } } diff --git a/src/common/ipc/ipc.ts b/src/common/ipc/ipc.ts index b104b31f4a..ebb3520fa3 100644 --- a/src/common/ipc/ipc.ts +++ b/src/common/ipc/ipc.ts @@ -28,7 +28,7 @@ export async function broadcastMessage(channel: string, ...args: any[]) { if (ipcRenderer) { ipcRenderer.send(channel, ...args); - } else { + } else if (ipcMain) { ipcMain.emit(channel, ...args); } @@ -55,7 +55,7 @@ export async function broadcastMessage(channel: string, ...args: any[]) { export function subscribeToBroadcast(channel: string, listener: (...args: any[]) => any) { if (ipcRenderer) { ipcRenderer.on(channel, listener); - } else { + } else if (ipcMain) { ipcMain.on(channel, listener); } @@ -65,7 +65,7 @@ export function subscribeToBroadcast(channel: string, listener: (...args: any[]) export function unsubscribeFromBroadcast(channel: string, listener: (...args: any[]) => any) { if (ipcRenderer) { ipcRenderer.off(channel, listener); - } else { + } else if (ipcMain) { ipcMain.off(channel, listener); } } @@ -73,7 +73,7 @@ export function unsubscribeFromBroadcast(channel: string, listener: (...args: an export function unsubscribeAllFromBroadcast(channel: string) { if (ipcRenderer) { ipcRenderer.removeAllListeners(channel); - } else { + } else if (ipcMain) { ipcMain.removeAllListeners(channel); } } diff --git a/src/common/protocol-handler/router.ts b/src/common/protocol-handler/router.ts index 80c2076cd3..7c2399e81f 100644 --- a/src/common/protocol-handler/router.ts +++ b/src/common/protocol-handler/router.ts @@ -23,8 +23,8 @@ export const ProtocolHandlerExtension= `${ProtocolHandlerIpcPrefix}:extension`; * Though under the current (2021/01/18) implementation, these are never matched * against in the final matching so their names are less of a concern. */ -const EXTENSION_PUBLISHER_MATCH = "LENS_INTERNAL_EXTENSION_PUBLISHER_MATCH"; -const EXTENSION_NAME_MATCH = "LENS_INTERNAL_EXTENSION_NAME_MATCH"; +export const EXTENSION_PUBLISHER_MATCH = "LENS_INTERNAL_EXTENSION_PUBLISHER_MATCH"; +export const EXTENSION_NAME_MATCH = "LENS_INTERNAL_EXTENSION_NAME_MATCH"; export abstract class LensProtocolRouter extends Singleton { // Map between path schemas and the handlers @@ -32,7 +32,7 @@ export abstract class LensProtocolRouter extends Singleton { public static readonly LoggingPrefix = "[PROTOCOL ROUTER]"; - protected static readonly ExtensionUrlSchema = `/:${EXTENSION_PUBLISHER_MATCH}(\@[A-Za-z0-9_]+)?/:${EXTENSION_NAME_MATCH}`; + static readonly ExtensionUrlSchema = `/:${EXTENSION_PUBLISHER_MATCH}(\@[A-Za-z0-9_]+)?/:${EXTENSION_NAME_MATCH}`; /** * diff --git a/src/common/utils/disposer.ts b/src/common/utils/disposer.ts new file mode 100644 index 0000000000..5e26bcd0e1 --- /dev/null +++ b/src/common/utils/disposer.ts @@ -0,0 +1,20 @@ +export type Disposer = () => void; + +interface Extendable { + push(...vals: T[]): void; +} + +export type ExtendableDisposer = Disposer & Extendable; + +export function disposer(...args: Disposer[]): ExtendableDisposer { + const res = () => { + args.forEach(dispose => dispose?.()); + args.length = 0; + }; + + res.push = (...vals: Disposer[]) => { + args.push(...vals); + }; + + return res; +} diff --git a/src/common/utils/downloadFile.ts b/src/common/utils/downloadFile.ts index dfa549da07..cd01db29ac 100644 --- a/src/common/utils/downloadFile.ts +++ b/src/common/utils/downloadFile.ts @@ -6,13 +6,13 @@ export interface DownloadFileOptions { timeout?: number; } -export interface DownloadFileTicket { +export interface DownloadFileTicket { url: string; - promise: Promise; + promise: Promise; cancel(): void; } -export function downloadFile({ url, timeout, gzip = true }: DownloadFileOptions): DownloadFileTicket { +export function downloadFile({ url, timeout, gzip = true }: DownloadFileOptions): DownloadFileTicket { const fileChunks: Buffer[] = []; const req = request(url, { gzip, timeout }); const promise: Promise = new Promise((resolve, reject) => { @@ -35,3 +35,12 @@ export function downloadFile({ url, timeout, gzip = true }: DownloadFileOptions) } }; } + +export function downloadJson(args: DownloadFileOptions): DownloadFileTicket { + const { promise, ...rest } = downloadFile(args); + + return { + promise: promise.then(res => JSON.parse(res.toString())), + ...rest + }; +} diff --git a/src/common/utils/index.ts b/src/common/utils/index.ts index 16a077277e..7ed9fc9d05 100644 --- a/src/common/utils/index.ts +++ b/src/common/utils/index.ts @@ -19,6 +19,8 @@ export * from "./downloadFile"; export * from "./escapeRegExp"; export * from "./tar"; export * from "./type-narrowing"; +export * from "./disposer"; + import * as iter from "./iter"; export { iter }; diff --git a/src/common/utils/type-narrowing.ts b/src/common/utils/type-narrowing.ts index 6a239c43ee..9cfe6934c5 100644 --- a/src/common/utils/type-narrowing.ts +++ b/src/common/utils/type-narrowing.ts @@ -1,13 +1,89 @@ /** * Narrows `val` to include the property `key` (if true is returned) * @param val The object to be tested - * @param key The key to test if it is present on the object + * @param key The key to test if it is present on the object (must be a literal for tsc to do any meaningful typing) */ -export function hasOwnProperty(val: V, key: K): val is (V & { [key in K]: unknown }) { +export function hasOwnProperty(val: S, key: K): val is (S & { [key in K]: unknown }) { // this call syntax is for when `val` was created by `Object.create(null)` return Object.prototype.hasOwnProperty.call(val, key); } -export function hasOwnProperties(val: V, ...keys: K[]): val is (V & { [key in K]: unknown}) { +/** + * Narrows `val` to a static type that includes fields of names in `keys` + * @param val the value that we are trying to type narrow + * @param keys the key names (must be literals for tsc to do any meaningful typing) + */ +export function hasOwnProperties(val: S, ...keys: K[]): val is (S & { [key in K]: unknown }) { return keys.every(key => hasOwnProperty(val, key)); } + +/** + * Narrows `val` to include the property `key` with type `V` + * @param val the value that we are trying to type narrow + * @param key The key to test if it is present on the object (must be a literal for tsc to do any meaningful typing) + * @param isValid a function to check if the field is valid + */ +export function hasTypedProperty(val: S, key: K, isValid: (value: unknown) => value is V): val is (S & { [key in K]: V }) { + return hasOwnProperty(val, key) && isValid(val[key]); +} + +/** + * Narrows `val` to include the property `key` with type `V | undefined` or doesn't contain it + * @param val the value that we are trying to type narrow + * @param key The key to test if it is present on the object (must be a literal for tsc to do any meaningful typing) + * @param isValid a function to check if the field (when present) is valid + */ +export function hasOptionalProperty(val: S, key: K, isValid: (value: unknown) => value is V): val is (S & { [key in K]?: V }) { + if (hasOwnProperty(val, key)) { + return typeof val[key] === "undefined" || isValid(val[key]); + } + + return true; +} + +/** + * isRecord checks if `val` matches the signature `Record` or `{ [label in T]: V }` + * @param val The value to be checked + * @param isKey a function for checking if the key is of the correct type + * @param isValue a function for checking if a value is of the correct type + */ +export function isRecord(val: unknown, isKey: (key: unknown) => key is T, isValue: (value: unknown) => value is V): val is Record { + return isObject(val) && Object.entries(val).every(([key, value]) => isKey(key) && isValue(value)); +} + +/** + * isTypedArray checks if `val` is an array and all of its entries are of type `T` + * @param val The value to be checked + * @param isEntry a function for checking if an entry is the correct type + */ +export function isTypedArray(val: unknown, isEntry: (entry: unknown) => entry is T): val is T[] { + return Array.isArray(val) && val.every(isEntry); +} + +/** + * checks if val is of type string + * @param val the value to be checked + */ +export function isString(val: unknown): val is string { + return typeof val === "string"; +} + +/** + * checks if val is of type object and isn't null + * @param val the value to be checked + */ +export function isObject(val: unknown): val is object { + return typeof val === "object" && val !== null; +} + +/** + * Creates a new predicate function (with the same predicate) from `fn`. Such + * that it can be called with just the value to be tested. + * + * This is useful for when using `hasOptionalProperty` and `hasTypedProperty` + * @param fn A typescript user predicate function to be bound + * @param boundArgs the set of arguments to be passed to `fn` in the new function + */ +export function bindPredicate(fn: (arg1: unknown, ...args: FnArgs) => arg1 is T, ...boundArgs: FnArgs): (arg1: unknown) => arg1 is T { + return (arg1: unknown): arg1 is T => fn(arg1, ...boundArgs); +} diff --git a/src/common/vars.ts b/src/common/vars.ts index e30c050a02..03d3f1c63d 100644 --- a/src/common/vars.ts +++ b/src/common/vars.ts @@ -1,5 +1,6 @@ // App's common configuration for any process (main, renderer, build pipeline, etc.) import path from "path"; +import { SemVer } from "semver"; import packageInfo from "../../package.json"; import { defineGlobal } from "./utils/defineGlobal"; @@ -44,5 +45,11 @@ export const apiKubePrefix = "/api-kube"; // k8s cluster apis // Links export const issuesTrackerUrl = "https://github.com/lensapp/lens/issues"; export const slackUrl = "https://join.slack.com/t/k8slens/shared_invite/enQtOTc5NjAyNjYyOTk4LWU1NDQ0ZGFkOWJkNTRhYTc2YjVmZDdkM2FkNGM5MjhiYTRhMDU2NDQ1MzIyMDA4ZGZlNmExOTc0N2JmY2M3ZGI"; -export const docsUrl = "https://docs.k8slens.dev/"; export const supportUrl = "https://docs.k8slens.dev/latest/support/"; + +// This explicitly ignores the prerelease info on the package version +const { major, minor, patch } = new SemVer(packageInfo.version); +const mmpVersion = [major, minor, patch].join("."); +const docsVersion = isProduction ? `v${mmpVersion}` : "latest"; + +export const docsUrl = `https://docs.k8slens.dev/${docsVersion}`; diff --git a/src/extensions/__tests__/extension-discovery.test.ts b/src/extensions/__tests__/extension-discovery.test.ts index c4775f29fe..d07dfe975c 100644 --- a/src/extensions/__tests__/extension-discovery.test.ts +++ b/src/extensions/__tests__/extension-discovery.test.ts @@ -1,10 +1,14 @@ +import mockFs from "mock-fs"; import { watch } from "chokidar"; -import { join, normalize } from "path"; -import { ExtensionDiscovery, InstalledExtension } from "../extension-discovery"; import { ExtensionsStore } from "../extensions-store"; +import path from "path"; +import { ExtensionDiscovery } from "../extension-discovery"; +import os from "os"; +import { Console } from "console"; + +jest.setTimeout(60_000); jest.mock("../../common/ipc"); -jest.mock("fs-extra"); jest.mock("chokidar", () => ({ watch: jest.fn() })); @@ -15,6 +19,7 @@ jest.mock("../extension-installer", () => ({ } })); +console = new Console(process.stdout, process.stderr); // fix mockFS const mockedWatch = watch as jest.MockedFunction; describe("ExtensionDiscovery", () => { @@ -24,47 +29,59 @@ describe("ExtensionDiscovery", () => { ExtensionsStore.createInstance(); }); - it("emits add for added extension", async done => { - globalThis.__non_webpack_require__.mockImplementation(() => ({ - 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 = ExtensionDiscovery.createInstance(); - - // Need to force isLoaded to be true so that the file watching is started - extensionDiscovery.isLoaded = true; - - await extensionDiscovery.watchExtensions(); - - 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"), + describe("with mockFs", () => { + beforeEach(() => { + mockFs({ + [`${os.homedir()}/.k8slens/extensions/my-extension/package.json`]: JSON.stringify({ + name: "my-extension" + }), }); - done(); }); - addHandler(join(extensionDiscovery.localFolderPath, "/my-extension/package.json")); + afterEach(() => { + mockFs.restore(); + }); + + it("emits add for added extension", async (done) => { + 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 = ExtensionDiscovery.createInstance(); + + // Need to force isLoaded to be true so that the file watching is started + extensionDiscovery.isLoaded = true; + + await extensionDiscovery.watchExtensions(); + + extensionDiscovery.events.on("add", extension => { + expect(extension).toEqual({ + absolutePath: expect.any(String), + id: path.normalize("node_modules/my-extension/package.json"), + isBundled: false, + isEnabled: false, + manifest: { + name: "my-extension", + }, + manifestPath: path.normalize("node_modules/my-extension/package.json"), + }); + done(); + }); + + addHandler(path.join(extensionDiscovery.localFolderPath, "/my-extension/package.json")); + }); }); it("doesn't emit add for added file under extension", async done => { @@ -94,7 +111,7 @@ describe("ExtensionDiscovery", () => { extensionDiscovery.events.on("add", onAdd); - addHandler(join(extensionDiscovery.localFolderPath, "/my-extension/node_modules/dep/package.json")); + addHandler(path.join(extensionDiscovery.localFolderPath, "/my-extension/node_modules/dep/package.json")); setTimeout(() => { expect(onAdd).not.toHaveBeenCalled(); diff --git a/src/extensions/extension-discovery.ts b/src/extensions/extension-discovery.ts index 2ed8f377f3..4b08631b07 100644 --- a/src/extensions/extension-discovery.ts +++ b/src/extensions/extension-discovery.ts @@ -1,31 +1,33 @@ import { watch } from "chokidar"; import { ipcRenderer } from "electron"; import { EventEmitter } from "events"; -import fs from "fs-extra"; +import fse from "fs-extra"; import { observable, reaction, toJS, when } from "mobx"; import os from "os"; import path from "path"; import { broadcastMessage, handleRequest, requestMain, subscribeToBroadcast } from "../common/ipc"; import { Singleton } from "../common/utils"; import logger from "../main/logger"; +import { ExtensionInstallationStateStore } from "../renderer/components/+extensions/extension-install.store"; import { extensionInstaller, PackageJson } from "./extension-installer"; import { ExtensionsStore } from "./extensions-store"; +import { ExtensionLoader } from "./extension-loader"; import type { LensExtensionId, LensExtensionManifest } from "./lens-extension"; export interface InstalledExtension { - id: LensExtensionId; + id: LensExtensionId; - readonly manifest: LensExtensionManifest; + readonly manifest: LensExtensionManifest; - // Absolute path to the non-symlinked source folder, - // e.g. "/Users/user/.k8slens/extensions/helloworld" - readonly absolutePath: string; + // Absolute path to the non-symlinked source folder, + // e.g. "/Users/user/.k8slens/extensions/helloworld" + readonly absolutePath: string; - // Absolute to the symlinked package.json file - readonly manifestPath: string; - readonly isBundled: boolean; // defined in project root's package.json - isEnabled: boolean; - } + // Absolute to the symlinked package.json file + readonly manifestPath: string; + readonly isBundled: boolean; // defined in project root's package.json + isEnabled: boolean; +} const logModule = "[EXTENSION-DISCOVERY]"; @@ -39,7 +41,7 @@ interface ExtensionDiscoveryChannelMessage { * Returns true if the lstat is for a directory-like file (e.g. isDirectory or symbolic link) * @param lstat the stats to compare */ -const isDirectoryLike = (lstat: fs.Stats) => lstat.isDirectory() || lstat.isSymbolicLink(); +const isDirectoryLike = (lstat: fse.Stats) => lstat.isDirectory() || lstat.isSymbolicLink(); /** * Discovers installed bundled and local extensions from the filesystem. @@ -64,12 +66,7 @@ export class ExtensionDiscovery extends Singleton { // IPC channel to broadcast changes to extension-discovery from main protected static readonly extensionDiscoveryChannel = "extension-discovery:main"; - public events: EventEmitter; - - constructor() { - super(); - this.events = new EventEmitter(); - } + public events = new EventEmitter(); get localFolderPath(): string { return path.join(os.homedir(), ".k8slens", "extensions"); @@ -146,8 +143,10 @@ export class ExtensionDiscovery extends Singleton { }) // Extension add is detected by watching "/package.json" add .on("add", this.handleWatchFileAdd) - // Extension remove is detected by watching " unlink - .on("unlinkDir", this.handleWatchUnlinkDir); + // Extension remove is detected by watching "" unlink + .on("unlinkDir", this.handleWatchUnlinkEvent) + // Extension remove is detected by watching "" unlink + .on("unlink", this.handleWatchUnlinkEvent); } handleWatchFileAdd = async (manifestPath: string) => { @@ -161,6 +160,7 @@ export class ExtensionDiscovery extends Singleton { if (path.basename(manifestPath) === manifestFilename && isUnderLocalFolderPath) { try { + ExtensionInstallationStateStore.setInstallingFromMain(manifestPath); const absPath = path.dirname(manifestPath); // this.loadExtensionFromPath updates this.packagesJson @@ -168,7 +168,7 @@ export class ExtensionDiscovery extends Singleton { if (extension) { // Remove a broken symlink left by a previous installation if it exists. - await this.removeSymlinkByManifestPath(manifestPath); + await fse.remove(extension.manifestPath); // Install dependencies for the new extension await this.installPackage(extension.absolutePath); @@ -178,40 +178,46 @@ export class ExtensionDiscovery extends Singleton { this.events.emit("add", extension); } } catch (error) { - console.error(error); + logger.error(`${logModule}: failed to add extension: ${error}`, { error }); + } finally { + ExtensionInstallationStateStore.clearInstallingFromMain(manifestPath); } } }; - handleWatchUnlinkDir = async (filePath: string) => { - // filePath is the non-symlinked path to the extension folder - // this.packagesJson.dependencies value is the non-symlinked path to the extension folder - // LensExtensionId in extension-loader is the symlinked path to the extension folder manifest file - + /** + * Handle any unlink event, filtering out non-package.json links so the delete code + * only happens once per extension. + * @param filePath The absolute path to either a folder or file in the extensions folder + */ + handleWatchUnlinkEvent = async (filePath: string): Promise => { // Check that the removed path is directly under this.localFolderPath // Note that the watcher can create unlink events for subdirectories of the extension const extensionFolderName = path.basename(filePath); + const expectedPath = path.relative(this.localFolderPath, filePath); - if (path.relative(this.localFolderPath, filePath) === extensionFolderName) { - const extension = Array.from(this.extensions.values()).find((extension) => extension.absolutePath === filePath); - - if (extension) { - const extensionName = extension.manifest.name; - - // If the extension is deleted manually while the application is running, also remove the symlink - await this.removeSymlinkByPackageName(extensionName); - - // The path to the manifest file is the lens extension id - // Note that we need to use the symlinked path - const lensExtensionId = extension.manifestPath; - - this.extensions.delete(extension.id); - logger.info(`${logModule} removed extension ${extensionName}`); - this.events.emit("remove", lensExtensionId as LensExtensionId); - } else { - logger.warn(`${logModule} extension ${extensionFolderName} not found, can't remove`); - } + if (expectedPath !== extensionFolderName) { + return; } + + const extension = Array.from(this.extensions.values()).find((extension) => extension.absolutePath === filePath); + + if (!extension) { + return void logger.warn(`${logModule} extension ${extensionFolderName} not found, can't remove`); + } + + const extensionName = extension.manifest.name; + + // If the extension is deleted manually while the application is running, also remove the symlink + await this.removeSymlinkByPackageName(extensionName); + + // The path to the manifest file is the lens extension id + // Note: that we need to use the symlinked path + const lensExtensionId = extension.manifestPath; + + this.extensions.delete(extension.id); + logger.info(`${logModule} removed extension ${extensionName}`); + this.events.emit("remove", lensExtensionId); }; /** @@ -221,31 +227,23 @@ export class ExtensionDiscovery extends Singleton { * @param name e.g. "@mirantis/lens-extension-cc" */ removeSymlinkByPackageName(name: string) { - return fs.remove(this.getInstalledPath(name)); - } - - /** - * Remove the symlink under node_modules if it exists. - * @param manifestPath Path to package.json - */ - removeSymlinkByManifestPath(manifestPath: string) { - const manifestJson = __non_webpack_require__(manifestPath); - - return this.removeSymlinkByPackageName(manifestJson.name); + return fse.remove(this.getInstalledPath(name)); } /** * Uninstalls extension. * The application will detect the folder unlink and remove the extension from the UI automatically. - * @param extension Extension to uninstall. + * @param extensionId The ID of the extension to uninstall. */ - async uninstallExtension({ absolutePath, manifest }: InstalledExtension) { + async uninstallExtension(extensionId: LensExtensionId) { + const { manifest, absolutePath } = this.extensions.get(extensionId) ?? ExtensionLoader.getInstance().getExtension(extensionId); + logger.info(`${logModule} Uninstalling ${manifest.name}`); await this.removeSymlinkByPackageName(manifest.name); // fs.remove does nothing if the path doesn't exist anymore - await fs.remove(absolutePath); + await fse.remove(absolutePath); } async load(): Promise> { @@ -259,12 +257,11 @@ export class ExtensionDiscovery extends Singleton { logger.info(`${logModule} loading extensions from ${extensionInstaller.extensionPackagesRoot}`); // fs.remove won't throw if path is missing - await fs.remove(path.join(extensionInstaller.extensionPackagesRoot, "package-lock.json")); - + await fse.remove(path.join(extensionInstaller.extensionPackagesRoot, "package-lock.json")); try { // Verify write access to static/extensions, which is needed for symlinking - await fs.access(this.inTreeFolderPath, fs.constants.W_OK); + await fse.access(this.inTreeFolderPath, fse.constants.W_OK); // Set bundled folder path to static/extensions this.bundledFolderPath = this.inTreeFolderPath; @@ -273,20 +270,20 @@ export class ExtensionDiscovery extends Singleton { // The error can happen if there is read-only rights to static/extensions, which would fail symlinking. // Remove e.g. /Users//Library/Application Support/LensDev/extensions - await fs.remove(this.inTreeTargetPath); + await fse.remove(this.inTreeTargetPath); // Create folder e.g. /Users//Library/Application Support/LensDev/extensions - await fs.ensureDir(this.inTreeTargetPath); + await fse.ensureDir(this.inTreeTargetPath); // Copy static/extensions to e.g. /Users//Library/Application Support/LensDev/extensions - await fs.copy(this.inTreeFolderPath, this.inTreeTargetPath); + await fse.copy(this.inTreeFolderPath, this.inTreeTargetPath); // Set bundled folder path to e.g. /Users//Library/Application Support/LensDev/extensions this.bundledFolderPath = this.inTreeTargetPath; } - await fs.ensureDir(this.nodeModulesPath); - await fs.ensureDir(this.localFolderPath); + await fse.ensureDir(this.nodeModulesPath); + await fse.ensureDir(this.localFolderPath); const extensions = await this.ensureExtensions(); @@ -315,30 +312,22 @@ export class ExtensionDiscovery extends Singleton { * Returns InstalledExtension from path to package.json file. * Also updates this.packagesJson. */ - protected async getByManifest(manifestPath: string, { isBundled = false }: { - isBundled?: boolean; - } = {}): Promise { - let manifestJson: LensExtensionManifest; - + protected async getByManifest(manifestPath: string, { isBundled = false } = {}): Promise { try { - // check manifest file for existence - fs.accessSync(manifestPath, fs.constants.F_OK); - - manifestJson = __non_webpack_require__(manifestPath); - const installedManifestPath = this.getInstalledManifestPath(manifestJson.name); - - const isEnabled = isBundled || ExtensionsStore.getInstance().isEnabled(installedManifestPath); + const manifest = await fse.readJson(manifestPath); + const installedManifestPath = this.getInstalledManifestPath(manifest.name); + const isEnabled = isBundled || ExtensionsStore.getInstance().isEnabled(installedManifestPath); return { id: installedManifestPath, absolutePath: path.dirname(manifestPath), manifestPath: installedManifestPath, - manifest: manifestJson, + manifest, isBundled, isEnabled }; } catch (error) { - logger.error(`${logModule}: can't load extension manifest at ${manifestPath}: ${error}`, { manifestJson }); + logger.error(`${logModule}: can't load extension manifest at ${manifestPath}: ${error}`); return null; } @@ -352,7 +341,7 @@ export class ExtensionDiscovery extends Singleton { const userExtensions = await this.loadFromFolder(this.localFolderPath, bundledExtensions.map((extension) => extension.manifest.name)); for (const extension of userExtensions) { - if (await fs.pathExists(extension.manifestPath) === false) { + if (await fse.pathExists(extension.manifestPath) === false) { await this.installPackage(extension.absolutePath); } } @@ -383,7 +372,7 @@ export class ExtensionDiscovery extends Singleton { async loadBundledExtensions() { const extensions: InstalledExtension[] = []; const folderPath = this.bundledFolderPath; - const paths = await fs.readdir(folderPath); + const paths = await fse.readdir(folderPath); for (const fileName of paths) { const absPath = path.resolve(folderPath, fileName); @@ -400,7 +389,7 @@ export class ExtensionDiscovery extends Singleton { async loadFromFolder(folderPath: string, bundledExtensions: string[]): Promise { const extensions: InstalledExtension[] = []; - const paths = await fs.readdir(folderPath); + const paths = await fse.readdir(folderPath); for (const fileName of paths) { // do not allow to override bundled extensions @@ -410,11 +399,11 @@ export class ExtensionDiscovery extends Singleton { const absPath = path.resolve(folderPath, fileName); - if (!fs.existsSync(absPath)) { + if (!fse.existsSync(absPath)) { continue; } - const lstat = await fs.lstat(absPath); + const lstat = await fse.lstat(absPath); // skip non-directories if (!isDirectoryLike(lstat)) { diff --git a/src/extensions/extension-loader.ts b/src/extensions/extension-loader.ts index 78dee4fd8d..4846dd7e82 100644 --- a/src/extensions/extension-loader.ts +++ b/src/extensions/extension-loader.ts @@ -13,8 +13,6 @@ import type { LensExtension, LensExtensionConstructor, LensExtensionId } from ". import type { LensMainExtension } from "./lens-main-extension"; import type { LensRendererExtension } from "./lens-renderer-extension"; import * as registries from "./registries"; -import fs from "fs"; - export function extensionPackagesRoot() { return path.join((app || remote.app).getPath("userData")); @@ -290,28 +288,20 @@ export class ExtensionLoader extends Singleton { }); } - protected requireExtension(extension: InstalledExtension): LensExtensionConstructor { - let extEntrypoint = ""; + protected requireExtension(extension: InstalledExtension): LensExtensionConstructor | null { + const entryPointName = ipcRenderer ? "renderer" : "main"; + const extRelativePath = extension.manifest[entryPointName]; + + if (!extRelativePath) { + return null; + } + + const extAbsolutePath = path.resolve(path.join(path.dirname(extension.manifestPath), extRelativePath)); try { - if (ipcRenderer && extension.manifest.renderer) { - extEntrypoint = path.resolve(path.join(path.dirname(extension.manifestPath), extension.manifest.renderer)); - } else if (!ipcRenderer && extension.manifest.main) { - extEntrypoint = path.resolve(path.join(path.dirname(extension.manifestPath), extension.manifest.main)); - } - - if (extEntrypoint !== "") { - if (!fs.existsSync(extEntrypoint)) { - console.log(`${logModule}: entrypoint ${extEntrypoint} not found, skipping ...`); - - return; - } - - return __non_webpack_require__(extEntrypoint).default; - } - } catch (err) { - console.error(`${logModule}: can't load extension main at ${extEntrypoint}: ${err}`, { extension }); - console.trace(err); + return __non_webpack_require__(extAbsolutePath).default; + } catch (error) { + logger.error(`${logModule}: can't load extension main at ${extAbsolutePath}: ${error}`, { extension, error }); } } diff --git a/src/extensions/registries/kube-object-status-registry.ts b/src/extensions/registries/kube-object-status-registry.ts index 5f7aab8d5d..3ca20f569a 100644 --- a/src/extensions/registries/kube-object-status-registry.ts +++ b/src/extensions/registries/kube-object-status-registry.ts @@ -9,9 +9,17 @@ export interface KubeObjectStatusRegistration { export class KubeObjectStatusRegistry extends BaseRegistry { getItemsForKind(kind: string, apiVersion: string) { - return this.getItems().filter((item) => { - return item.kind === kind && item.apiVersions.includes(apiVersion); - }); + return this.getItems() + .filter((item) => ( + item.kind === kind + && item.apiVersions.includes(apiVersion) + )); + } + + getItemsForObject(src: KubeObject) { + return this.getItemsForKind(src.kind, src.apiVersion) + .map(item => item.resolve(src)) + .filter(Boolean); } } diff --git a/src/main/logger.ts b/src/main/logger.ts index 0ddc7bb1f7..f39c7618ad 100644 --- a/src/main/logger.ts +++ b/src/main/logger.ts @@ -1,6 +1,6 @@ import { app, remote } from "electron"; import winston from "winston"; -import { isDebugging } from "../common/vars"; +import { isDebugging, isTestEnv } from "../common/vars"; const logLevel = process.env.LOG_LEVEL ? process.env.LOG_LEVEL : isDebugging ? "debug" : "info"; const consoleOptions: winston.transports.ConsoleTransportOptions = { @@ -23,7 +23,7 @@ const logger = winston.createLogger({ ), transports: [ new winston.transports.Console(consoleOptions), - new winston.transports.File(fileOptions), + ...(isTestEnv ? [] : [new winston.transports.File(fileOptions)]), ], }); diff --git a/src/renderer/api/__tests__/kube-object.test.ts b/src/renderer/api/__tests__/kube-object.test.ts new file mode 100644 index 0000000000..b3f84ea1ae --- /dev/null +++ b/src/renderer/api/__tests__/kube-object.test.ts @@ -0,0 +1,228 @@ +import { KubeObject } from "../kube-object"; + +describe("KubeObject", () => { + describe("isJsonApiData", () => { + { + type TestCase = [any]; + const tests: TestCase[] = [ + [false], + [true], + [null], + [undefined], + [""], + [1], + [(): unknown => void 0], + [Symbol("hello")], + [{}], + ]; + + it.each(tests)("should reject invalid value: %p", (input) => { + expect(KubeObject.isJsonApiData(input)).toBe(false); + }); + } + + { + type TestCase = [string, any]; + const tests: TestCase[] = [ + ["kind", { apiVersion: "", metadata: {uid: "", name: "", resourceVersion: "", selfLink: ""} }], + ["apiVersion", { kind: "", metadata: {uid: "", name: "", resourceVersion: "", selfLink: ""} }], + ["metadata", { kind: "", apiVersion: "" }], + ["metadata.uid", { kind: "", apiVersion: "", metadata: { name: "", resourceVersion: "", selfLink: ""} }], + ["metadata.name", { kind: "", apiVersion: "", metadata: { uid: "", resourceVersion: "", selfLink: "" } }], + ["metadata.resourceVersion", { kind: "", apiVersion: "", metadata: { uid: "", name: "", selfLink: "" } }], + ]; + + it.each(tests)("should reject with missing: %s", (missingField, input) => { + expect(KubeObject.isJsonApiData(input)).toBe(false); + }); + } + + { + type TestCase = [string, any]; + const tests: TestCase[] = [ + ["kind", { kind: 1, apiVersion: "", metadata: {} }], + ["apiVersion", { apiVersion: 1, kind: "", metadata: {} }], + ["metadata", { kind: "", apiVersion: "", metadata: "" }], + ["metadata.uid", { kind: "", apiVersion: "", metadata: { uid: 1 } }], + ["metadata.name", { kind: "", apiVersion: "", metadata: { uid: "", name: 1 } }], + ["metadata.resourceVersion", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: 1 } }], + ["metadata.selfLink", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: 1 } }], + ["metadata.namespace", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", namespace: 1 } }], + ["metadata.creationTimestamp", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", creationTimestamp: 1 } }], + ["metadata.continue", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", continue: 1 } }], + ["metadata.finalizers", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", finalizers: 1 } }], + ["metadata.finalizers", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", finalizers: [1] } }], + ["metadata.finalizers", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", finalizers: {} } }], + ["metadata.labels", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", labels: 1 } }], + ["metadata.labels", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", labels: { food: 1 } } }], + ["metadata.annotations", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", annotations: 1 } }], + ["metadata.annotations", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", annotations: { food: 1 } } }], + ]; + + it.each(tests)("should reject with wrong type for field: %s", (missingField, input) => { + expect(KubeObject.isJsonApiData(input)).toBe(false); + }); + } + + it("should accept valid KubeJsonApiData (ignoring other fields)", () => { + const valid = { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", annotations: { food: "" } } }; + + expect(KubeObject.isJsonApiData(valid)).toBe(true); + }); + }); + + describe("isPartialJsonApiData", () => { + { + type TestCase = [any]; + const tests: TestCase[] = [ + [false], + [true], + [null], + [undefined], + [""], + [1], + [(): unknown => void 0], + [Symbol("hello")], + ]; + + it.each(tests)("should reject invalid value: %p", (input) => { + expect(KubeObject.isPartialJsonApiData(input)).toBe(false); + }); + } + + it("should accept {}", () => { + expect(KubeObject.isPartialJsonApiData({})).toBe(true); + }); + + { + type TestCase = [string, any]; + const tests: TestCase[] = [ + ["kind", { apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "" } }], + ["apiVersion", { kind: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "" } }], + ["metadata", { kind: "", apiVersion: "" }], + ]; + + it.each(tests)("should not reject with missing top level field: %s", (missingField, input) => { + expect(KubeObject.isPartialJsonApiData(input)).toBe(true); + }); + } + + { + type TestCase = [string, any]; + const tests: TestCase[] = [ + ["metadata.uid", { kind: "", apiVersion: "", metadata: { name: "", resourceVersion: "", selfLink: ""} }], + ["metadata.name", { kind: "", apiVersion: "", metadata: { uid: "", resourceVersion: "", selfLink: "" } }], + ["metadata.resourceVersion", { kind: "", apiVersion: "", metadata: { uid: "", name: "", selfLink: "" } }], + ]; + + it.each(tests)("should reject with missing non-top level field: %s", (missingField, input) => { + expect(KubeObject.isPartialJsonApiData(input)).toBe(false); + }); + } + + { + type TestCase = [string, any]; + const tests: TestCase[] = [ + ["kind", { kind: 1, apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "" } }], + ["apiVersion", { apiVersion: 1, kind: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "" } }], + ["metadata", { kind: "", apiVersion: "", metadata: "" }], + ["metadata.uid", { kind: "", apiVersion: "", metadata: { uid: 1, name: "", resourceVersion: "", selfLink: "" } }], + ["metadata.name", { kind: "", apiVersion: "", metadata: { uid: "", name: 1, resourceVersion: "", selfLink: "" } }], + ["metadata.resourceVersion", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: 1, selfLink: "" } }], + ["metadata.selfLink", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: 1 } }], + ["metadata.namespace", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", namespace: 1 } }], + ["metadata.creationTimestamp", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", creationTimestamp: 1 } }], + ["metadata.continue", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", continue: 1 } }], + ["metadata.finalizers", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", finalizers: 1 } }], + ["metadata.finalizers", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", finalizers: [1] } }], + ["metadata.finalizers", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", finalizers: {} } }], + ["metadata.labels", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", labels: 1 } }], + ["metadata.labels", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", labels: { food: 1 } } }], + ["metadata.annotations", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", annotations: 1 } }], + ["metadata.annotations", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", annotations: { food: 1 } } }], + ]; + + it.each(tests)("should reject with wrong type for field: %s", (missingField, input) => { + expect(KubeObject.isPartialJsonApiData(input)).toBe(false); + }); + } + + it("should accept valid Partial (ignoring other fields)", () => { + const valid = { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", annotations: { food: "" } } }; + + expect(KubeObject.isPartialJsonApiData(valid)).toBe(true); + }); + }); + + describe("isJsonApiDataList", () => { + function isAny(val: unknown): val is any { + return !Boolean(void val); + } + + function isNotAny(val: unknown): val is any { + return Boolean(void val); + } + + function isBoolean(val: unknown): val is Boolean { + return typeof val === "boolean"; + } + + { + type TestCase = [any]; + const tests: TestCase[] = [ + [false], + [true], + [null], + [undefined], + [""], + [1], + [(): unknown => void 0], + [Symbol("hello")], + [{}], + ]; + + it.each(tests)("should reject invalid value: %p", (input) => { + expect(KubeObject.isJsonApiDataList(input, isAny)).toBe(false); + }); + } + + { + type TestCase = [string, any]; + const tests: TestCase[] = [ + ["kind", { apiVersion: "", items: [], metadata: { resourceVersion: "", selfLink: "" } }], + ["apiVersion", { kind: "", items: [], metadata: { resourceVersion: "", selfLink: "" } }], + ["metadata", { kind: "", items: [], apiVersion: "" }], + ["metadata.resourceVersion", { kind: "", items: [], apiVersion: "", metadata: { selfLink: "" } }], + ]; + + it.each(tests)("should reject with missing: %s", (missingField, input) => { + expect(KubeObject.isJsonApiDataList(input, isAny)).toBe(false); + }); + } + + { + type TestCase = [string, any]; + const tests: TestCase[] = [ + ["kind", { kind: 1, items: [], apiVersion: "", metadata: { resourceVersion: "", selfLink: "" } }], + ["apiVersion", { kind: "", items: [], apiVersion: 1, metadata: { resourceVersion: "", selfLink: "" } }], + ["metadata", { kind: "", items: [], apiVersion: "", metadata: 1 }], + ["metadata.resourceVersion", { kind: "", items: [], apiVersion: "", metadata: { resourceVersion: 1, selfLink: "" } }], + ["metadata.selfLink", { kind: "", items: [], apiVersion: "", metadata: { resourceVersion: "", selfLink: 1 } }], + ["items", { kind: "", items: 1, apiVersion: "", metadata: { resourceVersion: "", selfLink: "" } }], + ["items", { kind: "", items: "", apiVersion: "", metadata: { resourceVersion: "", selfLink: "" } }], + ["items", { kind: "", items: {}, apiVersion: "", metadata: { resourceVersion: "", selfLink: "" } }], + ["items[0]", { kind: "", items: [""], apiVersion: "", metadata: { resourceVersion: "", selfLink: "" } }], + ]; + + it.each(tests)("should reject with wrong type for field: %s", (missingField, input) => { + expect(KubeObject.isJsonApiDataList(input, isNotAny)).toBe(false); + }); + } + + it("should accept valid KubeJsonApiDataList (ignoring other fields)", () => { + const valid = { kind: "", items: [false], apiVersion: "", metadata: { resourceVersion: "", selfLink: "" } }; + + expect(KubeObject.isJsonApiDataList(valid, isBoolean)).toBe(true); + }); + }); +}); diff --git a/src/renderer/api/endpoints/helm-charts.api.ts b/src/renderer/api/endpoints/helm-charts.api.ts index 093adf9aef..9d4b5d4575 100644 --- a/src/renderer/api/endpoints/helm-charts.api.ts +++ b/src/renderer/api/endpoints/helm-charts.api.ts @@ -16,39 +16,51 @@ const endpoint = compile(`/v2/charts/:repo?/:name?`) as (params?: { name?: string; }) => string; -export const helmChartsApi = { - list() { - return apiBase - .get(endpoint()) - .then(data => { - return Object - .values(data) - .reduce((allCharts, repoCharts) => allCharts.concat(Object.values(repoCharts)), []) - .map(([chart]) => HelmChart.create(chart)); - }); - }, +/** + * Get a list of all helm charts from all saved helm repos + */ +export async function listCharts(): Promise { + const data = await apiBase.get(endpoint()); - get(repo: string, name: string, readmeVersion?: string) { - const path = endpoint({ repo, name }); + return Object + .values(data) + .reduce((allCharts, repoCharts) => allCharts.concat(Object.values(repoCharts)), []) + .map(([chart]) => HelmChart.create(chart)); +} - return apiBase - .get(`${path}?${stringify({ version: readmeVersion })}`) - .then(data => { - const versions = data.versions.map(HelmChart.create); - const readme = data.readme; +export interface GetChartDetailsOptions { + version?: string; + reqInit?: RequestInit; +} - return { - readme, - versions, - }; - }); - }, +/** + * Get the readme and all versions of a chart + * @param repo The repo to get from + * @param name The name of the chart to request the data of + * @param options.version The version of the chart's readme to get, default latest + * @param options.reqInit A way for passing in an abort controller or other browser request options + */ +export async function getChartDetails(repo: string, name: string, { version, reqInit }: GetChartDetailsOptions = {}): Promise { + const path = endpoint({ repo, name }); - getValues(repo: string, name: string, version: string) { - return apiBase - .get(`/v2/charts/${repo}/${name}/values?${stringify({ version })}`); - } -}; + const { readme, ...data } = await apiBase.get(`${path}?${stringify({ version })}`, undefined, reqInit); + const versions = data.versions.map(HelmChart.create); + + return { + readme, + versions, + }; +} + +/** + * Get chart values related to a specific repos' version of a chart + * @param repo The repo to get from + * @param name The name of the chart to request the data of + * @param version The version to get the values from + */ +export async function getChartValues(repo: string, name: string, version: string): Promise { + return apiBase.get(`/v2/charts/${repo}/${name}/values?${stringify({ version })}`); +} @autobind() export class HelmChart { diff --git a/src/renderer/api/json-api.ts b/src/renderer/api/json-api.ts index df12b08ab7..343aecd4c9 100644 --- a/src/renderer/api/json-api.ts +++ b/src/renderer/api/json-api.ts @@ -2,8 +2,8 @@ import { stringify } from "querystring"; import { EventEmitter } from "../../common/event-emitter"; -import { cancelableFetch } from "../utils/cancelableFetch"; import { randomBytes } from "crypto"; + export interface JsonApiData { } @@ -72,13 +72,11 @@ export class JsonApi { reqUrl += (reqUrl.includes("?") ? "&" : "?") + queryString; } - const infoLog: JsonApiLog = { + this.writeLog({ method: reqInit.method.toUpperCase(), reqUrl: reqPath, reqInit, - }; - - this.writeLog({ ...infoLog }); + }); return fetch(reqUrl, reqInit); } @@ -99,7 +97,7 @@ export class JsonApi { return this.request(path, params, { ...reqInit, method: "delete" }); } - protected request(path: string, params?: P, init: RequestInit = {}) { + protected async request(path: string, params?: P, init: RequestInit = {}) { let reqUrl = this.config.apiBase + path; const reqInit: RequestInit = { ...this.reqInit, ...init }; const { data, query } = params || {} as P; @@ -119,48 +117,53 @@ export class JsonApi { reqInit, }; - return cancelableFetch(reqUrl, reqInit).then(res => { - return this.parseResponse(res, infoLog); - }); + const res = await fetch(reqUrl, reqInit); + + return this.parseResponse(res, infoLog); } - protected parseResponse(res: Response, log: JsonApiLog): Promise { + protected async parseResponse(res: Response, log: JsonApiLog): Promise { const { status } = res; - return res.text().then(text => { - let data; + const text = await res.text(); + let data; - try { - data = text ? JSON.parse(text) : ""; // DELETE-requests might not have response-body - } catch (e) { - data = text; - } + try { + data = text ? JSON.parse(text) : ""; // DELETE-requests might not have response-body + } catch (e) { + data = text; + } - if (status >= 200 && status < 300) { - this.onData.emit(data, res); - this.writeLog({ ...log, data }); + if (status >= 200 && status < 300) { + this.onData.emit(data, res); + this.writeLog({ ...log, data }); - return data; - } else if (log.method === "GET" && res.status === 403) { - this.writeLog({ ...log, data }); - } else { - const error = new JsonApiErrorParsed(data, this.parseError(data, res)); + return data; + } - this.onError.emit(error, res); - this.writeLog({ ...log, error }); - throw error; - } - }); + if (log.method === "GET" && res.status === 403) { + this.writeLog({ ...log, error: data }); + throw data; + } + + const error = new JsonApiErrorParsed(data, this.parseError(data, res)); + + this.onError.emit(error, res); + this.writeLog({ ...log, error }); + + throw error; } protected parseError(error: JsonApiError | string, res: Response): string[] { if (typeof error === "string") { return [error]; } - else if (Array.isArray(error.errors)) { + + if (Array.isArray(error.errors)) { return error.errors.map(error => error.title); } - else if (error.message) { + + if (error.message) { return [error.message]; } diff --git a/src/renderer/api/kube-api.ts b/src/renderer/api/kube-api.ts index 98833e3d4d..2c0739c6eb 100644 --- a/src/renderer/api/kube-api.ts +++ b/src/renderer/api/kube-api.ts @@ -7,11 +7,12 @@ import logger from "../../main/logger"; import { apiManager } from "./api-manager"; import { apiKube } from "./index"; import { createKubeApiURL, parseKubeApi } from "./kube-api-parse"; -import { KubeJsonApi, KubeJsonApiData, KubeJsonApiDataList } from "./kube-json-api"; import { IKubeObjectConstructor, KubeObject, KubeStatus } from "./kube-object"; import byline from "byline"; import { IKubeWatchEvent } from "./kube-watch-api"; import { ReadableWebToNodeStream } from "../utils/readableStream"; +import { KubeJsonApi, KubeJsonApiData } from "./kube-json-api"; +import { noop } from "../utils"; export interface IKubeApiOptions { /** @@ -34,6 +35,11 @@ export interface IKubeApiOptions { checkPreferredVersion?: boolean; } +export interface KubeApiListOptions { + namespace?: string; + reqInit?: RequestInit; +} + export interface IKubeApiQueryParams { watch?: boolean | number; resourceVersion?: string; @@ -245,7 +251,7 @@ export class KubeApi { return this.resourceVersions.get(namespace); } - async refreshResourceVersion(params?: { namespace: string }) { + async refreshResourceVersion(params?: KubeApiListOptions) { return this.list(params, { limit: 1 }); } @@ -273,20 +279,12 @@ export class KubeApi { return query; } - protected parseResponse(data: KubeJsonApiData | KubeJsonApiData[] | KubeJsonApiDataList, namespace?: string): any { + protected parseResponse(data: unknown, namespace?: string): T | T[] | null { if (!data) return; const KubeObjectConstructor = this.objectConstructor; - if (KubeObject.isJsonApiData(data)) { - const object = new KubeObjectConstructor(data); - - ensureObjectSelfLink(this, object); - - return object; - } - - // process items list response - if (KubeObject.isJsonApiDataList(data)) { + // process items list response, check before single item since there is overlap + if (KubeObject.isJsonApiDataList(data, KubeObject.isPartialJsonApiData)) { const { apiVersion, items, metadata } = data; this.setResourceVersion(namespace, metadata.resourceVersion); @@ -305,55 +303,90 @@ export class KubeApi { }); } + // process a single item + if (KubeObject.isJsonApiData(data)) { + const object = new KubeObjectConstructor(data); + + ensureObjectSelfLink(this, object); + + return object; + } + // custom apis might return array for list response, e.g. users, groups, etc. if (Array.isArray(data)) { return data.map(data => new KubeObjectConstructor(data)); } - return data; + return null; } - async list({ namespace = "" } = {}, query?: IKubeApiQueryParams): Promise { + async list({ namespace = "", reqInit }: KubeApiListOptions = {}, query?: IKubeApiQueryParams): Promise { await this.checkPreferredVersion(); - return this.request - .get(this.getUrl({ namespace }), { query }) - .then(data => this.parseResponse(data, namespace)); + const url = this.getUrl({ namespace }); + const res = await this.request.get(url, { query }, reqInit); + const parsed = this.parseResponse(res, namespace); + + if (Array.isArray(parsed)) { + return parsed; + } + + if (!parsed) { + return null; + } + + throw new Error(`GET multiple request to ${url} returned not an array: ${JSON.stringify(parsed)}`); } - async get({ name = "", namespace = "default" } = {}, query?: IKubeApiQueryParams): Promise { + async get({ name = "", namespace = "default" } = {}, query?: IKubeApiQueryParams): Promise { await this.checkPreferredVersion(); - return this.request - .get(this.getUrl({ namespace, name }), { query }) - .then(this.parseResponse); + const url = this.getUrl({ namespace, name }); + const res = await this.request.get(url, { query }); + const parsed = this.parseResponse(res); + + if (Array.isArray(parsed)) { + throw new Error(`GET single request to ${url} returned an array: ${JSON.stringify(parsed)}`); + } + + return parsed; } - async create({ name = "", namespace = "default" } = {}, data?: Partial): Promise { + async create({ name = "", namespace = "default" } = {}, data?: Partial): Promise { await this.checkPreferredVersion(); + const apiUrl = this.getUrl({ namespace }); + const res = await this.request.post(apiUrl, { + data: merge({ + kind: this.kind, + apiVersion: this.apiVersionWithGroup, + metadata: { + name, + namespace + } + }, data) + }); + const parsed = this.parseResponse(res); - return this.request - .post(apiUrl, { - data: merge({ - kind: this.kind, - apiVersion: this.apiVersionWithGroup, - metadata: { - name, - namespace - } - }, data) - }) - .then(this.parseResponse); + if (Array.isArray(parsed)) { + throw new Error(`POST request to ${apiUrl} returned an array: ${JSON.stringify(parsed)}`); + } + + return parsed; } - async update({ name = "", namespace = "default" } = {}, data?: Partial): Promise { + async update({ name = "", namespace = "default" } = {}, data?: Partial): Promise { await this.checkPreferredVersion(); const apiUrl = this.getUrl({ namespace, name }); - return this.request - .put(apiUrl, { data }) - .then(this.parseResponse); + const res = await this.request.put(apiUrl, { data }); + const parsed = this.parseResponse(res); + + if (Array.isArray(parsed)) { + throw new Error(`PUT request to ${apiUrl} returned an array: ${JSON.stringify(parsed)}`); + } + + return parsed; } async delete({ name = "", namespace = "default" }) { @@ -372,78 +405,60 @@ export class KubeApi { } watch(opts: KubeApiWatchOptions = { namespace: "" }): () => void { - if (!opts.abortController) { - opts.abortController = new AbortController(); - } let errorReceived = false; let timedRetry: NodeJS.Timeout; - const { abortController, namespace, callback } = opts; + const { abortController: { abort, signal } = new AbortController(), namespace, callback = noop } = opts; - abortController.signal.addEventListener("abort", () => { + signal.addEventListener("abort", () => { clearTimeout(timedRetry); }); const watchUrl = this.getWatchUrl(namespace); - const responsePromise = this.request.getResponse(watchUrl, null, { - signal: abortController.signal - }); + const responsePromise = this.request.getResponse(watchUrl, null, { signal }); - responsePromise.then((response) => { - if (!response.ok && !abortController.signal.aborted) { - callback?.(null, response); - - return; - } - const nodeStream = new ReadableWebToNodeStream(response.body); - - ["end", "close", "error"].forEach((eventName) => { - nodeStream.on(eventName, () => { - if (errorReceived) return; // kubernetes errors should be handled in a callback - - clearTimeout(timedRetry); - timedRetry = setTimeout(() => { // we did not get any kubernetes errors so let's retry - if (abortController.signal.aborted) return; - - this.watch({...opts, namespace, callback}); - }, 1000); - }); - }); - - const stream = byline(nodeStream); - - stream.on("data", (line) => { - try { - const event: IKubeWatchEvent = JSON.parse(line); - - if (event.type === "ERROR" && event.object.kind === "Status") { - errorReceived = true; - callback(null, new KubeStatus(event.object as any)); - - return; - } - - this.modifyWatchEvent(event); - - if (callback) { - callback(event, null); - } - } catch (ignore) { - // ignore parse errors + responsePromise + .then(response => { + if (!response.ok) { + return callback(null, response); } + + const nodeStream = new ReadableWebToNodeStream(response.body); + + ["end", "close", "error"].forEach((eventName) => { + nodeStream.on(eventName, () => { + if (errorReceived) return; // kubernetes errors should be handled in a callback + + clearTimeout(timedRetry); + timedRetry = setTimeout(() => { // we did not get any kubernetes errors so let's retry + this.watch({...opts, namespace, callback}); + }, 1000); + }); + }); + + byline(nodeStream).on("data", (line) => { + try { + const event: IKubeWatchEvent = JSON.parse(line); + + if (event.type === "ERROR" && event.object.kind === "Status") { + errorReceived = true; + + return callback(null, new KubeStatus(event.object as any)); + } + + this.modifyWatchEvent(event); + callback(event, null); + } catch (ignore) { + // ignore parse errors + } + }); + }) + .catch(error => { + if (error instanceof DOMException) return; // AbortController rejects, we can ignore it + + callback(null, error); }); - }, (error) => { - if (error instanceof DOMException) return; // AbortController rejects, we can ignore it - callback?.(null, error); - }).catch((error) => { - callback?.(null, error); - }); - - const disposer = () => { - abortController.abort(); - }; - - return disposer; + return abort; } protected modifyWatchEvent(event: IKubeWatchEvent) { diff --git a/src/renderer/api/kube-json-api.ts b/src/renderer/api/kube-json-api.ts index 362ee5438e..0dfe53c8d2 100644 --- a/src/renderer/api/kube-json-api.ts +++ b/src/renderer/api/kube-json-api.ts @@ -1,34 +1,38 @@ import { JsonApi, JsonApiData, JsonApiError } from "./json-api"; +export interface KubeJsonApiListMetadata { + resourceVersion: string; + selfLink?: string; +} + export interface KubeJsonApiDataList { kind: string; apiVersion: string; items: T[]; - metadata: { - resourceVersion: string; - selfLink: string; + metadata: KubeJsonApiListMetadata; +} + +export interface KubeJsonApiMetadata { + uid: string; + name: string; + namespace?: string; + creationTimestamp?: string; + resourceVersion: string; + continue?: string; + finalizers?: string[]; + selfLink?: string; + labels?: { + [label: string]: string; + }; + annotations?: { + [annotation: string]: string; }; } export interface KubeJsonApiData extends JsonApiData { kind: string; apiVersion: string; - metadata: { - uid: string; - name: string; - namespace?: string; - creationTimestamp?: string; - resourceVersion: string; - continue?: string; - finalizers?: string[]; - selfLink?: string; - labels?: { - [label: string]: string; - }; - annotations?: { - [annotation: string]: string; - }; - }; + metadata: KubeJsonApiMetadata; } export interface KubeJsonApiError extends JsonApiError { diff --git a/src/renderer/api/kube-object.ts b/src/renderer/api/kube-object.ts index 7d0c34de33..8699a3c94f 100644 --- a/src/renderer/api/kube-object.ts +++ b/src/renderer/api/kube-object.ts @@ -1,12 +1,13 @@ // Base class for all kubernetes objects import moment from "moment"; -import { KubeJsonApiData, KubeJsonApiDataList } from "./kube-json-api"; +import { KubeJsonApiData, KubeJsonApiDataList, KubeJsonApiListMetadata, KubeJsonApiMetadata } from "./kube-json-api"; import { autobind, formatDuration } from "../utils"; import { ItemObject } from "../item.store"; import { apiKube } from "./index"; import { JsonApiParams } from "./json-api"; import { resourceApplierApi } from "./endpoints/resource-applier.api"; +import { hasOptionalProperty, hasTypedProperty, isObject, isString, bindPredicate, isTypedArray, isRecord } from "../../common/utils/type-narrowing"; export type IKubeObjectConstructor = (new (data: KubeJsonApiData | any) => T) & { kind?: string; @@ -78,15 +79,59 @@ export class KubeObject implements ItemObject { return !item.metadata.name.startsWith("system:"); } - static isJsonApiData(object: any): object is KubeJsonApiData { - return !object.items && object.metadata; + static isJsonApiData(object: unknown): object is KubeJsonApiData { + return ( + isObject(object) + && hasTypedProperty(object, "kind", isString) + && hasTypedProperty(object, "apiVersion", isString) + && hasTypedProperty(object, "metadata", KubeObject.isKubeJsonApiMetadata) + ); } - static isJsonApiDataList(object: any): object is KubeJsonApiDataList { - return object.items && object.metadata; + static isKubeJsonApiListMetadata(object: unknown): object is KubeJsonApiListMetadata { + return ( + isObject(object) + && hasTypedProperty(object, "resourceVersion", isString) + && hasOptionalProperty(object, "selfLink", isString) + ); } - static stringifyLabels(labels: { [name: string]: string }): string[] { + static isKubeJsonApiMetadata(object: unknown): object is KubeJsonApiMetadata { + return ( + isObject(object) + && hasTypedProperty(object, "uid", isString) + && hasTypedProperty(object, "name", isString) + && hasTypedProperty(object, "resourceVersion", isString) + && hasOptionalProperty(object, "selfLink", isString) + && hasOptionalProperty(object, "namespace", isString) + && hasOptionalProperty(object, "creationTimestamp", isString) + && hasOptionalProperty(object, "continue", isString) + && hasOptionalProperty(object, "finalizers", bindPredicate(isTypedArray, isString)) + && hasOptionalProperty(object, "labels", bindPredicate(isRecord, isString, isString)) + && hasOptionalProperty(object, "annotations", bindPredicate(isRecord, isString, isString)) + ); + } + + static isPartialJsonApiData(object: unknown): object is Partial { + return ( + isObject(object) + && hasOptionalProperty(object, "kind", isString) + && hasOptionalProperty(object, "apiVersion", isString) + && hasOptionalProperty(object, "metadata", KubeObject.isKubeJsonApiMetadata) + ); + } + + static isJsonApiDataList(object: unknown, verifyItem:(val: unknown) => val is T): object is KubeJsonApiDataList { + return ( + isObject(object) + && hasTypedProperty(object, "kind", isString) + && hasTypedProperty(object, "apiVersion", isString) + && hasTypedProperty(object, "metadata", KubeObject.isKubeJsonApiListMetadata) + && hasTypedProperty(object, "items", bindPredicate(isTypedArray, verifyItem)) + ); + } + + static stringifyLabels(labels?: { [name: string]: string }): string[] { if (!labels) return []; return Object.entries(labels).map(([name, value]) => `${name}=${value}`); diff --git a/src/renderer/bootstrap.tsx b/src/renderer/bootstrap.tsx index d923467939..8be97cc86d 100644 --- a/src/renderer/bootstrap.tsx +++ b/src/renderer/bootstrap.tsx @@ -20,6 +20,7 @@ import { App } from "./components/app"; import { LensApp } from "./lens-app"; import { ThemeStore } from "./theme.store"; import { HelmRepoManager } from "../main/helm/helm-repo-manager"; +import { ExtensionInstallationStateStore } from "./components/+extensions/extension-install.store"; /** * If this is a development buid, wait a second to attach @@ -61,6 +62,7 @@ export async function bootstrap(App: AppComponent) { const themeStore = ThemeStore.createInstance(); const hotbarStore = HotbarStore.createInstance(); + ExtensionInstallationStateStore.bindIpcListeners(); HelmRepoManager.createInstance(); // initialize the manager // preload common stores diff --git a/src/renderer/components/+apps-helm-charts/helm-chart-details.tsx b/src/renderer/components/+apps-helm-charts/helm-chart-details.tsx index d31efc5438..e0dfb6c77e 100644 --- a/src/renderer/components/+apps-helm-charts/helm-chart-details.tsx +++ b/src/renderer/components/+apps-helm-charts/helm-chart-details.tsx @@ -1,14 +1,13 @@ import "./helm-chart-details.scss"; import React, { Component } from "react"; -import { HelmChart, helmChartsApi } from "../../api/endpoints/helm-charts.api"; +import { getChartDetails, HelmChart } from "../../api/endpoints/helm-charts.api"; import { observable, autorun } from "mobx"; import { observer } from "mobx-react"; import { Drawer, DrawerItem } from "../drawer"; import { autobind, stopPropagation } from "../../utils"; import { MarkdownViewer } from "../markdown-viewer"; import { Spinner } from "../spinner"; -import { CancelablePromise } from "../../utils/cancelableFetch"; import { Button } from "../button"; import { Select, SelectOption } from "../select"; import { createInstallChartTab } from "../dock/install-chart.store"; @@ -26,35 +25,37 @@ export class HelmChartDetails extends Component { @observable readme: string = null; @observable error: string = null; - private chartPromise: CancelablePromise<{ readme: string; versions: HelmChart[] }>; + private abortController?: AbortController; componentWillUnmount() { - this.chartPromise?.cancel(); + this.abortController?.abort(); } chartUpdater = autorun(() => { this.selectedChart = null; const { chart: { name, repo, version } } = this.props; - helmChartsApi.get(repo, name, version).then(result => { - this.readme = result.readme; - this.chartVersions = result.versions; - this.selectedChart = result.versions[0]; - }, - error => { - this.error = error; - }); + getChartDetails(repo, name, { version }) + .then(result => { + this.readme = result.readme; + this.chartVersions = result.versions; + this.selectedChart = result.versions[0]; + }) + .catch(error => { + this.error = error; + }); }); @autobind() - async onVersionChange({ value: version }: SelectOption) { + async onVersionChange({ value: version }: SelectOption) { this.selectedChart = this.chartVersions.find(chart => chart.version === version); this.readme = null; try { - this.chartPromise?.cancel(); + this.abortController?.abort(); + this.abortController = new AbortController(); const { chart: { name, repo } } = this.props; - const { readme } = await (this.chartPromise = helmChartsApi.get(repo, name, version)); + const { readme } = await getChartDetails(repo, name, { version, reqInit: { signal: this.abortController.signal }}); this.readme = readme; } catch (error) { diff --git a/src/renderer/components/+apps-helm-charts/helm-chart.store.ts b/src/renderer/components/+apps-helm-charts/helm-chart.store.ts index 25559a9711..a5663e00cb 100644 --- a/src/renderer/components/+apps-helm-charts/helm-chart.store.ts +++ b/src/renderer/components/+apps-helm-charts/helm-chart.store.ts @@ -1,7 +1,7 @@ import semver from "semver"; import { observable } from "mobx"; import { autobind } from "../../utils"; -import { HelmChart, helmChartsApi } from "../../api/endpoints/helm-charts.api"; +import { getChartDetails, HelmChart, listCharts } from "../../api/endpoints/helm-charts.api"; import { ItemStore } from "../../item.store"; import flatten from "lodash/flatten"; @@ -16,7 +16,7 @@ export class HelmChartStore extends ItemStore { async loadAll() { try { - const res = await this.loadItems(() => helmChartsApi.list()); + const res = await this.loadItems(() => listCharts()); this.failedLoading = false; @@ -48,13 +48,13 @@ export class HelmChartStore extends ItemStore { return versions; } - const loadVersions = (repo: string) => { - return helmChartsApi.get(repo, chartName).then(({ versions }) => { - return versions.map(chart => ({ - repo, - version: chart.getVersion() - })); - }); + const loadVersions = async (repo: string) => { + const { versions } = await getChartDetails(repo, chartName); + + return versions.map(chart => ({ + repo, + version: chart.getVersion() + })); }; if (!this.isLoaded) { diff --git a/src/renderer/components/+config-autoscalers/hpa.tsx b/src/renderer/components/+config-autoscalers/hpa.tsx index 2e2a78fc82..8bfae4ee12 100644 --- a/src/renderer/components/+config-autoscalers/hpa.tsx +++ b/src/renderer/components/+config-autoscalers/hpa.tsx @@ -47,7 +47,8 @@ export class HorizontalPodAutoscalers extends React.Component { [columnId.namespace]: (item: HorizontalPodAutoscaler) => item.getNs(), [columnId.minPods]: (item: HorizontalPodAutoscaler) => item.getMinPods(), [columnId.maxPods]: (item: HorizontalPodAutoscaler) => item.getMaxPods(), - [columnId.replicas]: (item: HorizontalPodAutoscaler) => item.getReplicas() + [columnId.replicas]: (item: HorizontalPodAutoscaler) => item.getReplicas(), + [columnId.age]: (item: HorizontalPodAutoscaler) => item.getTimeDiffFromNow(), }} searchFilters={[ (item: HorizontalPodAutoscaler) => item.getSearchFields() diff --git a/src/renderer/components/+extensions/__tests__/extensions.test.tsx b/src/renderer/components/+extensions/__tests__/extensions.test.tsx index 6e56c112ca..714b31e290 100644 --- a/src/renderer/components/+extensions/__tests__/extensions.test.tsx +++ b/src/renderer/components/+extensions/__tests__/extensions.test.tsx @@ -1,16 +1,18 @@ import "@testing-library/jest-dom/extend-expect"; -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { fireEvent, render, waitFor } from "@testing-library/react"; import fse from "fs-extra"; import React from "react"; import { UserStore } from "../../../../common/user-store"; import { ExtensionDiscovery } from "../../../../extensions/extension-discovery"; import { ExtensionLoader } from "../../../../extensions/extension-loader"; -import { ThemeStore } from "../../../theme.store"; import { ConfirmDialog } from "../../confirm-dialog"; -import { Notifications } from "../../notifications"; +import { ExtensionInstallationStateStore } from "../extension-install.store"; import { Extensions } from "../extensions"; +import mockFs from "mock-fs"; +jest.setTimeout(30000); jest.mock("fs-extra"); +jest.mock("../../notifications"); jest.mock("../../../../common/utils", () => ({ ...jest.requireActual("../../../../common/utils"), @@ -20,37 +22,30 @@ jest.mock("../../../../common/utils", () => ({ extractTar: jest.fn(() => Promise.resolve()) })); -jest.mock("../../notifications", () => ({ - ok: jest.fn(), - error: jest.fn(), - info: jest.fn() +jest.mock("electron", () => ({ + app: { + getVersion: () => "99.99.99", + getPath: () => "tmp", + getLocale: () => "en", + setLoginItemSettings: (): void => void 0, + } })); -jest.mock("electron", () => { - return { - app: { - getVersion: () => "99.99.99", - getPath: () => "tmp", - getLocale: () => "en", - setLoginItemSettings: (): void => void 0, - } - }; -}); - describe("Extensions", () => { beforeEach(async () => { + mockFs({ + "tmp": {} + }); + + ExtensionInstallationStateStore.reset(); UserStore.resetInstance(); - ThemeStore.resetInstance(); await UserStore.createInstance().load(); - await ThemeStore.createInstance().init(); - ExtensionLoader.resetInstance(); ExtensionDiscovery.resetInstance(); - Extensions.installStates.clear(); - ExtensionDiscovery.createInstance().uninstallExtension = jest.fn(() => Promise.resolve()); + ExtensionLoader.resetInstance(); ExtensionLoader.createInstance().addExtension({ id: "extensionId", manifest: { @@ -64,49 +59,38 @@ describe("Extensions", () => { }); }); - it("disables uninstall and disable buttons while uninstalling", async () => { - ExtensionDiscovery.getInstance().isLoaded = true; - render(<>); - - expect(screen.getByText("Disable").closest("button")).not.toBeDisabled(); - expect(screen.getByText("Uninstall").closest("button")).not.toBeDisabled(); - - fireEvent.click(screen.getByText("Uninstall")); - - // Approve confirm dialog - fireEvent.click(screen.getByText("Yes")); - - expect(ExtensionDiscovery.getInstance().uninstallExtension).toHaveBeenCalled(); - expect(screen.getByText("Disable").closest("button")).toBeDisabled(); - expect(screen.getByText("Uninstall").closest("button")).toBeDisabled(); + afterEach(() => { + mockFs.restore(); }); - it("displays error notification on uninstall error", () => { + it("disables uninstall and disable buttons while uninstalling", async () => { ExtensionDiscovery.getInstance().isLoaded = true; - (ExtensionDiscovery.getInstance().uninstallExtension as any).mockImplementationOnce(() => - Promise.reject() - ); - render(<>); - expect(screen.getByText("Disable").closest("button")).not.toBeDisabled(); - expect(screen.getByText("Uninstall").closest("button")).not.toBeDisabled(); + const res = render(<>); - fireEvent.click(screen.getByText("Uninstall")); + expect(res.getByText("Disable").closest("button")).not.toBeDisabled(); + expect(res.getByText("Uninstall").closest("button")).not.toBeDisabled(); + + fireEvent.click(res.getByText("Uninstall")); // Approve confirm dialog - fireEvent.click(screen.getByText("Yes")); + fireEvent.click(res.getByText("Yes")); - waitFor(() => { - expect(screen.getByText("Disable").closest("button")).not.toBeDisabled(); - expect(screen.getByText("Uninstall").closest("button")).not.toBeDisabled(); - expect(Notifications.error).toHaveBeenCalledTimes(1); + await waitFor(() => { + expect(ExtensionDiscovery.getInstance().uninstallExtension).toHaveBeenCalled(); + expect(res.getByText("Disable").closest("button")).toBeDisabled(); + expect(res.getByText("Uninstall").closest("button")).toBeDisabled(); + }, { + timeout: 30000, }); }); - it("disables install button while installing", () => { - render(); + it("disables install button while installing", async () => { + const res = render(); - fireEvent.change(screen.getByPlaceholderText("Path or URL to an extension package", { + (fse.unlink as jest.MockedFunction).mockReturnValue(Promise.resolve() as any); + + fireEvent.change(res.getByPlaceholderText("Path or URL to an extension package", { exact: false }), { target: { @@ -114,13 +98,8 @@ describe("Extensions", () => { } }); - fireEvent.click(screen.getByText("Install")); - - waitFor(() => { - expect(screen.getByText("Install").closest("button")).toBeDisabled(); - expect(fse.move).toHaveBeenCalledWith(""); - expect(Notifications.error).not.toHaveBeenCalled(); - }); + fireEvent.click(res.getByText("Install")); + expect(res.getByText("Install").closest("button")).toBeDisabled(); }); it("displays spinner while extensions are loading", () => { @@ -128,8 +107,11 @@ describe("Extensions", () => { const { container } = render(); expect(container.querySelector(".Spinner")).toBeInTheDocument(); + }); + it("does not display the spinner while extensions are not loading", async () => { ExtensionDiscovery.getInstance().isLoaded = true; + const { container } = render(); waitFor(() => expect(container.querySelector(".Spinner")).not.toBeInTheDocument() diff --git a/src/renderer/components/+extensions/extension-install.store.ts b/src/renderer/components/+extensions/extension-install.store.ts new file mode 100644 index 0000000000..787dc2b364 --- /dev/null +++ b/src/renderer/components/+extensions/extension-install.store.ts @@ -0,0 +1,218 @@ +import { action, computed, observable } from "mobx"; +import logger from "../../../main/logger"; +import { disposer, ExtendableDisposer } from "../../utils"; +import * as uuid from "uuid"; +import { broadcastMessage } from "../../../common/ipc"; +import { ipcRenderer } from "electron"; + +export enum ExtensionInstallationState { + INSTALLING = "installing", + UNINSTALLING = "uninstalling", + IDLE = "idle", +} + +const Prefix = "[ExtensionInstallationStore]"; + +export class ExtensionInstallationStateStore { + private static InstallingFromMainChannel = "extension-installation-state-store:install"; + private static ClearInstallingFromMainChannel = "extension-installation-state-store:clear-install"; + private static PreInstallIds = observable.set(); + private static UninstallingExtensions = observable.set(); + private static InstallingExtensions = observable.set(); + + static bindIpcListeners() { + ipcRenderer + .on(ExtensionInstallationStateStore.InstallingFromMainChannel, (event, extId) => { + ExtensionInstallationStateStore.setInstalling(extId); + }) + .on(ExtensionInstallationStateStore.ClearInstallingFromMainChannel, (event, extId) => { + ExtensionInstallationStateStore.clearInstalling(extId); + }); + } + + @action static reset() { + logger.warn(`${Prefix}: resetting, may throw errors`); + ExtensionInstallationStateStore.InstallingExtensions.clear(); + ExtensionInstallationStateStore.UninstallingExtensions.clear(); + ExtensionInstallationStateStore.PreInstallIds.clear(); + } + + /** + * Strictly transitions an extension from not installing to installing + * @param extId the ID of the extension + * @throws if state is not IDLE + */ + @action static setInstalling(extId: string): void { + logger.debug(`${Prefix}: trying to set ${extId} as installing`); + + const curState = ExtensionInstallationStateStore.getInstallationState(extId); + + if (curState !== ExtensionInstallationState.IDLE) { + throw new Error(`${Prefix}: cannot set ${extId} as installing. Is currently ${curState}.`); + } + + ExtensionInstallationStateStore.InstallingExtensions.add(extId); + } + + /** + * Broadcasts that an extension is being installed by the main process + * @param extId the ID of the extension + */ + static setInstallingFromMain(extId: string): void { + broadcastMessage(ExtensionInstallationStateStore.InstallingFromMainChannel, extId); + } + + /** + * Broadcasts that an extension is no longer being installed by the main process + * @param extId the ID of the extension + */ + static clearInstallingFromMain(extId: string): void { + broadcastMessage(ExtensionInstallationStateStore.ClearInstallingFromMainChannel, extId); + } + + /** + * Marks the start of a pre-install phase of an extension installation. The + * part of the installation before the tarball has been unpacked and the ID + * determined. + * @returns a disposer which should be called to mark the end of the install phase + */ + @action static startPreInstall(): ExtendableDisposer { + const preInstallStepId = uuid.v4(); + + logger.debug(`${Prefix}: starting a new preinstall phase: ${preInstallStepId}`); + ExtensionInstallationStateStore.PreInstallIds.add(preInstallStepId); + + return disposer(() => { + ExtensionInstallationStateStore.PreInstallIds.delete(preInstallStepId); + logger.debug(`${Prefix}: ending a preinstall phase: ${preInstallStepId}`); + }); + } + + /** + * Strictly transitions an extension from not uninstalling to uninstalling + * @param extId the ID of the extension + * @throws if state is not IDLE + */ + @action static setUninstalling(extId: string): void { + logger.debug(`${Prefix}: trying to set ${extId} as uninstalling`); + + const curState = ExtensionInstallationStateStore.getInstallationState(extId); + + if (curState !== ExtensionInstallationState.IDLE) { + throw new Error(`${Prefix}: cannot set ${extId} as uninstalling. Is currently ${curState}.`); + } + + ExtensionInstallationStateStore.UninstallingExtensions.add(extId); + } + + /** + * Strictly clears the INSTALLING state of an extension + * @param extId The ID of the extension + * @throws if state is not INSTALLING + */ + @action static clearInstalling(extId: string): void { + logger.debug(`${Prefix}: trying to clear ${extId} as installing`); + + const curState = ExtensionInstallationStateStore.getInstallationState(extId); + + switch (curState) { + case ExtensionInstallationState.INSTALLING: + return void ExtensionInstallationStateStore.InstallingExtensions.delete(extId); + default: + throw new Error(`${Prefix}: cannot clear INSTALLING state for ${extId}, it is currently ${curState}`); + } + } + + /** + * Strictly clears the UNINSTALLING state of an extension + * @param extId The ID of the extension + * @throws if state is not UNINSTALLING + */ + @action static clearUninstalling(extId: string): void { + logger.debug(`${Prefix}: trying to clear ${extId} as uninstalling`); + + const curState = ExtensionInstallationStateStore.getInstallationState(extId); + + switch (curState) { + case ExtensionInstallationState.UNINSTALLING: + return void ExtensionInstallationStateStore.UninstallingExtensions.delete(extId); + default: + throw new Error(`${Prefix}: cannot clear UNINSTALLING state for ${extId}, it is currently ${curState}`); + } + } + + /** + * Returns the current state of the extension. IDLE is default value. + * @param extId The ID of the extension + */ + static getInstallationState(extId: string): ExtensionInstallationState { + if (ExtensionInstallationStateStore.InstallingExtensions.has(extId)) { + return ExtensionInstallationState.INSTALLING; + } + + if (ExtensionInstallationStateStore.UninstallingExtensions.has(extId)) { + return ExtensionInstallationState.UNINSTALLING; + } + + return ExtensionInstallationState.IDLE; + } + + /** + * Returns true if the extension is currently INSTALLING + * @param extId The ID of the extension + */ + static isExtensionInstalling(extId: string): boolean { + return ExtensionInstallationStateStore.getInstallationState(extId) === ExtensionInstallationState.INSTALLING; + } + + /** + * Returns true if the extension is currently UNINSTALLING + * @param extId The ID of the extension + */ + static isExtensionUninstalling(extId: string): boolean { + return ExtensionInstallationStateStore.getInstallationState(extId) === ExtensionInstallationState.UNINSTALLING; + } + + /** + * Returns true if the extension is currently IDLE + * @param extId The ID of the extension + */ + static isExtensionIdle(extId: string): boolean { + return ExtensionInstallationStateStore.getInstallationState(extId) === ExtensionInstallationState.IDLE; + } + + /** + * The current number of extensions installing + */ + @computed static get installing(): number { + return ExtensionInstallationStateStore.InstallingExtensions.size; + } + + /** + * If there is at least one extension currently installing + */ + @computed static get anyInstalling(): boolean { + return ExtensionInstallationStateStore.installing > 0; + } + + /** + * The current number of extensions preinstalling + */ + @computed static get preinstalling(): number { + return ExtensionInstallationStateStore.PreInstallIds.size; + } + + /** + * If there is at least one extension currently downloading + */ + @computed static get anyPreinstalling(): boolean { + return ExtensionInstallationStateStore.preinstalling > 0; + } + + /** + * If there is at least one installing or preinstalling step taking place + */ + @computed static get anyPreInstallingOrInstalling(): boolean { + return ExtensionInstallationStateStore.anyInstalling || ExtensionInstallationStateStore.anyPreinstalling; + } +} diff --git a/src/renderer/components/+extensions/extensions.tsx b/src/renderer/components/+extensions/extensions.tsx index b2202a6050..968f16c63e 100644 --- a/src/renderer/components/+extensions/extensions.tsx +++ b/src/renderer/components/+extensions/extensions.tsx @@ -1,15 +1,16 @@ +import "./extensions.scss"; import { remote, shell } from "electron"; import fse from "fs-extra"; -import { computed, observable, reaction } from "mobx"; +import { computed, observable, reaction, when } from "mobx"; import { disposeOnUnmount, observer } from "mobx-react"; import os from "os"; import path from "path"; import React from "react"; -import { downloadFile, extractTar, listTarEntries, readFileFromTar } from "../../../common/utils"; +import { autobind, disposer, Disposer, downloadFile, downloadJson, ExtendableDisposer, extractTar, listTarEntries, noop, readFileFromTar } from "../../../common/utils"; import { docsUrl } from "../../../common/vars"; import { ExtensionDiscovery, InstalledExtension, manifestFilename } from "../../../extensions/extension-discovery"; import { ExtensionLoader } from "../../../extensions/extension-loader"; -import { extensionDisplayName, LensExtensionManifest, sanitizeExtensionName } from "../../../extensions/lens-extension"; +import { extensionDisplayName, LensExtensionId, LensExtensionManifest, sanitizeExtensionName } from "../../../extensions/lens-extension"; import logger from "../../../main/logger"; import { prevDefault } from "../../utils"; import { Button } from "../button"; @@ -21,103 +22,447 @@ import { SubTitle } from "../layout/sub-title"; import { Notifications } from "../notifications"; import { Spinner } from "../spinner/spinner"; import { TooltipPosition } from "../tooltip"; -import "./extensions.scss"; +import { ExtensionInstallationState, ExtensionInstallationStateStore } from "./extension-install.store"; +import URLParse from "url-parse"; +import { SemVer } from "semver"; +import _ from "lodash"; + +function getMessageFromError(error: any): string { + if (!error || typeof error !== "object") { + return "an error has occured"; + } + + if (error.message) { + return String(error.message); + } + + if (error.err) { + return String(error.err); + } + + const rawMessage = String(error); + + if (rawMessage === String({})) { + return "an error has occured"; + } + + return rawMessage; +} + +interface ExtensionInfo { + name: string; + version?: string; + requireConfirmation?: boolean; +} interface InstallRequest { fileName: string; - filePath?: string; - data?: Buffer; + dataP: Promise; } -interface InstallRequestPreloaded extends InstallRequest { +interface InstallRequestValidated { + fileName: string; data: Buffer; -} - -interface InstallRequestValidated extends InstallRequestPreloaded { + id: LensExtensionId; manifest: LensExtensionManifest; tempFile: string; // temp system path to packed extension for unpacking } -interface ExtensionState { - displayName: string; - // Possible states the extension can be - state: "installing" | "uninstalling"; +async function uninstallExtension(extensionId: LensExtensionId): Promise { + const loader = ExtensionLoader.getInstance(); + const { manifest } = loader.getExtension(extensionId); + const displayName = extensionDisplayName(manifest.name, manifest.version); + + try { + logger.debug(`[EXTENSIONS]: trying to uninstall ${extensionId}`); + ExtensionInstallationStateStore.setUninstalling(extensionId); + + await ExtensionDiscovery.getInstance().uninstallExtension(extensionId); + + // wait for the ExtensionLoader to actually uninstall the extension + await when(() => !loader.userExtensions.has(extensionId)); + + Notifications.ok( +

Extension {displayName} successfully uninstalled!

+ ); + + return true; + } catch (error) { + const message = getMessageFromError(error); + + logger.info(`[EXTENSION-UNINSTALL]: uninstalling ${displayName} has failed: ${error}`, { error }); + Notifications.error(

Uninstalling extension {displayName} has failed: {message}

); + + return false; + } finally { + // Remove uninstall state on uninstall failure + ExtensionInstallationStateStore.clearUninstalling(extensionId); + } +} + +async function confirmUninstallExtension(extension: InstalledExtension): Promise { + const displayName = extensionDisplayName(extension.manifest.name, extension.manifest.version); + const confirmed = await ConfirmDialog.confirm({ + message:

Are you sure you want to uninstall extension {displayName}?

, + labelOk: "Yes", + labelCancel: "No", + }); + + if (confirmed) { + await uninstallExtension(extension.id); + } +} + +function getExtensionDestFolder(name: string) { + return path.join(ExtensionDiscovery.getInstance().localFolderPath, sanitizeExtensionName(name)); +} + +function getExtensionPackageTemp(fileName = "") { + return path.join(os.tmpdir(), "lens-extensions", fileName); +} + +async function readFileNotify(filePath: string, showError = true): Promise { + try { + return await fse.readFile(filePath); + } catch (error) { + if (showError) { + const message = getMessageFromError(error); + + logger.info(`[EXTENSION-INSTALL]: preloading ${filePath} has failed: ${message}`, { error }); + Notifications.error(`Error while reading "${filePath}": ${message}`); + } + } + + return null; +} + +async function validatePackage(filePath: string): Promise { + const tarFiles = await listTarEntries(filePath); + + // tarball from npm contains single root folder "package/*" + const firstFile = tarFiles[0]; + + if(!firstFile) { + throw new Error(`invalid extension bundle, ${manifestFilename} not found`); + } + + const rootFolder = path.normalize(firstFile).split(path.sep)[0]; + const packedInRootFolder = tarFiles.every(entry => entry.startsWith(rootFolder)); + const manifestLocation = packedInRootFolder ? path.join(rootFolder, manifestFilename) : manifestFilename; + + if(!tarFiles.includes(manifestLocation)) { + throw new Error(`invalid extension bundle, ${manifestFilename} not found`); + } + + const manifest = await readFileFromTar({ + tarPath: filePath, + filePath: manifestLocation, + parseJson: true, + }); + + if (!manifest.main && !manifest.renderer) { + throw new Error(`${manifestFilename} must specify "main" and/or "renderer" fields`); + } + + return manifest; +} + +async function createTempFilesAndValidate({ fileName, dataP }: InstallRequest, disposer: ExtendableDisposer): Promise { + // copy files to temp + await fse.ensureDir(getExtensionPackageTemp()); + + // validate packages + const tempFile = getExtensionPackageTemp(fileName); + + disposer.push(() => fse.unlink(tempFile)); + + try { + const data = await dataP; + + if (!data) { + return; + } + + await fse.writeFile(tempFile, data); + const manifest = await validatePackage(tempFile); + const id = path.join(ExtensionDiscovery.getInstance().nodeModulesPath, manifest.name, "package.json"); + + return { + fileName, + data, + manifest, + tempFile, + id, + }; + } catch (error) { + const message = getMessageFromError(error); + + logger.info(`[EXTENSION-INSTALLATION]: installing ${fileName} has failed: ${message}`, { error }); + Notifications.error( +
+

Installing {fileName} has failed, skipping.

+

Reason: {message}

+
+ ); + } + + return null; +} + +async function unpackExtension(request: InstallRequestValidated, disposeDownloading?: Disposer) { + const { id, fileName, tempFile, manifest: { name, version } } = request; + + ExtensionInstallationStateStore.setInstalling(id); + disposeDownloading?.(); + + const displayName = extensionDisplayName(name, version); + const extensionFolder = getExtensionDestFolder(name); + const unpackingTempFolder = path.join(path.dirname(tempFile), `${path.basename(tempFile)}-unpacked`); + + logger.info(`Unpacking extension ${displayName}`, { fileName, tempFile }); + + try { + // extract to temp folder first + await fse.remove(unpackingTempFolder).catch(noop); + await fse.ensureDir(unpackingTempFolder); + await extractTar(tempFile, { cwd: unpackingTempFolder }); + + // move contents to extensions folder + const unpackedFiles = await fse.readdir(unpackingTempFolder); + let unpackedRootFolder = unpackingTempFolder; + + if (unpackedFiles.length === 1) { + // check if %extension.tgz was packed with single top folder, + // e.g. "npm pack %ext_name" downloads file with "package" root folder within tarball + unpackedRootFolder = path.join(unpackingTempFolder, unpackedFiles[0]); + } + + await fse.ensureDir(extensionFolder); + await fse.move(unpackedRootFolder, extensionFolder, { overwrite: true }); + + // wait for the loader has actually install it + await when(() => ExtensionLoader.getInstance().userExtensions.has(id)); + + // Enable installed extensions by default. + ExtensionLoader.getInstance().userExtensions.get(id).isEnabled = true; + + Notifications.ok( +

Extension {displayName} successfully installed!

+ ); + } catch (error) { + const message = getMessageFromError(error); + + logger.info(`[EXTENSION-INSTALLATION]: installing ${request.fileName} has failed: ${message}`, { error }); + Notifications.error(

Installing extension {displayName} has failed: {message}

); + } finally { + // Remove install state once finished + ExtensionInstallationStateStore.clearInstalling(id); + + // clean up + fse.remove(unpackingTempFolder).catch(noop); + fse.unlink(tempFile).catch(noop); + } +} + +export async function attemptInstallByInfo({ name, version, requireConfirmation = false }: ExtensionInfo) { + const disposer = ExtensionInstallationStateStore.startPreInstall(); + const registryUrl = new URLParse("https://registry.npmjs.com").set("pathname", name).toString(); + const { promise } = downloadJson({ url: registryUrl }); + const json = await promise.catch(console.error); + + if (!json || json.error || typeof json.versions !== "object" || !json.versions) { + const message = json?.error ? `: ${json.error}` : ""; + + Notifications.error(`Failed to get registry information for that extension${message}`); + + return disposer(); + } + + if (version) { + if (!json.versions[version]) { + Notifications.error(

The {name} extension does not have a v{version}.

); + + return disposer(); + } + } else { + const versions = Object.keys(json.versions) + .map(version => new SemVer(version, { loose: true, includePrerelease: true })) + // ignore pre-releases for auto picking the version + .filter(version => version.prerelease.length === 0); + + version = _.reduce(versions, (prev, curr) => ( + prev.compareMain(curr) === -1 + ? curr + : prev + )).format(); + } + + if (requireConfirmation) { + const proceed = await ConfirmDialog.confirm({ + message:

Are you sure you want to install {name}@{version}?

, + labelCancel: "Cancel", + labelOk: "Install", + }); + + if (!proceed) { + return disposer(); + } + } + + const url = json.versions[version].dist.tarball; + const fileName = path.basename(url); + const { promise: dataP } = downloadFile({ url, timeout: 10 * 60 * 1000 }); + + return attemptInstall({ fileName, dataP }, disposer); +} + +async function attemptInstall(request: InstallRequest, d?: ExtendableDisposer): Promise { + const dispose = disposer(ExtensionInstallationStateStore.startPreInstall(), d); + const validatedRequest = await createTempFilesAndValidate(request, dispose); + + if (!validatedRequest) { + return dispose(); + } + + const { name, version, description } = validatedRequest.manifest; + const curState = ExtensionInstallationStateStore.getInstallationState(validatedRequest.id); + + if (curState !== ExtensionInstallationState.IDLE) { + dispose(); + + return Notifications.error( +
+ Extension Install Collision: +

The {name} extension is currently {curState.toLowerCase()}.

+

Will not proceed with this current install request.

+
+ ); + } + + const extensionFolder = getExtensionDestFolder(name); + const folderExists = await fse.pathExists(extensionFolder); + + if (!folderExists) { + // install extension if not yet exists + await unpackExtension(validatedRequest, dispose); + } else { + const { manifest: { version: oldVersion } } = ExtensionLoader.getInstance().getExtension(validatedRequest.id); + + // otherwise confirmation required (re-install / update) + const removeNotification = Notifications.info( +
+
+

Install extension {name}@{version}?

+

Description: {description}

+
shell.openPath(extensionFolder)}> + Warning: {name}@{oldVersion} will be removed before installation. +
+
+
, + { + onClose: dispose, + } + ); + } +} + +async function attemptInstalls(filePaths: string[]): Promise { + const promises: Promise[] = []; + + for (const filePath of filePaths) { + promises.push(attemptInstall({ + fileName: path.basename(filePath), + dataP: readFileNotify(filePath), + })); + } + + await Promise.allSettled(promises); +} + +async function installOnDrop(files: File[]) { + logger.info("Install from D&D"); + await attemptInstalls(files.map(({ path }) => path)); +} + +async function installFromInput(input: string) { + let disposer: ExtendableDisposer | undefined = undefined; + + try { + // fixme: improve error messages for non-tar-file URLs + if (InputValidators.isUrl.validate(input)) { + // install via url + disposer = ExtensionInstallationStateStore.startPreInstall(); + const { promise } = downloadFile({ url: input, timeout: 10 * 60 * 1000 }); + const fileName = path.basename(input); + + await attemptInstall({ fileName, dataP: promise }, disposer); + } else if (InputValidators.isPath.validate(input)) { + // install from system path + const fileName = path.basename(input); + + await attemptInstall({ fileName, dataP: readFileNotify(input) }); + } else if (InputValidators.isExtensionNameInstall.validate(input)) { + const [{ groups: { name, version }}] = [...input.matchAll(InputValidators.isExtensionNameInstallRegex)]; + + await attemptInstallByInfo({ name, version }); + } + } catch (error) { + const message = getMessageFromError(error); + + logger.info(`[EXTENSION-INSTALL]: installation has failed: ${message}`, { error, installPath: input }); + Notifications.error(

Installation has failed: {message}

); + } finally { + disposer?.(); + } +} + +const supportedFormats = ["tar", "tgz"]; + +async function installFromSelectFileDialog() { + const { dialog, BrowserWindow, app } = remote; + const { canceled, filePaths } = await dialog.showOpenDialog(BrowserWindow.getFocusedWindow(), { + defaultPath: app.getPath("downloads"), + properties: ["openFile", "multiSelections"], + message: `Select extensions to install (formats: ${supportedFormats.join(", ")}), `, + buttonLabel: "Use configuration", + filters: [ + { name: "tarball", extensions: supportedFormats } + ] + }); + + if (!canceled) { + await attemptInstalls(filePaths); + } } @observer export class Extensions extends React.Component { - private static supportedFormats = ["tar", "tgz"]; + private static installInputValidators = [ + InputValidators.isUrl, + InputValidators.isPath, + InputValidators.isExtensionNameInstall, + ]; - private static installPathValidator: InputValidator = { - message: "Invalid URL or absolute path", - validate(value: string) { - return InputValidators.isUrl.validate(value) || InputValidators.isPath.validate(value); - } + private static installInputValidator: InputValidator = { + message: "Invalid URL, absolute path, or extension name", + validate: (value: string) => ( + Extensions.installInputValidators.some(({ validate }) => validate(value)) + ), }; - static installStates = observable.map(); - @observable search = ""; @observable installPath = ""; - // True if the preliminary install steps have started, but unpackExtension has not started yet - @observable startingInstall = false; - - /** - * Extensions that were removed from extensions but are still in "uninstalling" state - */ - @computed get removedUninstalling() { - return Array.from(Extensions.installStates.entries()) - .filter(([id, extension]) => - extension.state === "uninstalling" - && !this.extensions.find(extension => extension.id === id) - ) - .map(([id, extension]) => ({ ...extension, id })); - } - - /** - * Extensions that were added to extensions but are still in "installing" state - */ - @computed get addedInstalling() { - return Array.from(Extensions.installStates.entries()) - .filter(([id, extension]) => - extension.state === "installing" - && this.extensions.find(extension => extension.id === id) - ) - .map(([id, extension]) => ({ ...extension, id })); - } - - componentDidMount() { - disposeOnUnmount(this, - reaction(() => this.extensions, () => { - this.removedUninstalling.forEach(({ id, displayName }) => { - Notifications.ok( -

Extension {displayName} successfully uninstalled!

- ); - Extensions.installStates.delete(id); - }); - - this.addedInstalling.forEach(({ id, displayName }) => { - const extension = this.extensions.find(extension => extension.id === id); - - if (!extension) { - throw new Error("Extension not found"); - } - - Notifications.ok( -

Extension {displayName} successfully installed!

- ); - Extensions.installStates.delete(id); - this.installPath = ""; - - // Enable installed extensions by default. - extension.isEnabled = true; - }); - }) - ); - } - - @computed get extensions() { + @computed get searchedForExtensions() { const searchText = this.search.toLowerCase(); return Array.from(ExtensionLoader.getInstance().userExtensions.values()) @@ -127,368 +472,104 @@ export class Extensions extends React.Component { )); } - get extensionsPath() { - return ExtensionDiscovery.getInstance().localFolderPath; - } - - getExtensionPackageTemp(fileName = "") { - return path.join(os.tmpdir(), "lens-extensions", fileName); - } - - getExtensionDestFolder(name: string) { - return path.join(this.extensionsPath, sanitizeExtensionName(name)); - } - - installFromSelectFileDialog = async () => { - const { dialog, BrowserWindow, app } = remote; - const { canceled, filePaths } = await dialog.showOpenDialog(BrowserWindow.getFocusedWindow(), { - defaultPath: app.getPath("downloads"), - properties: ["openFile", "multiSelections"], - message: `Select extensions to install (formats: ${Extensions.supportedFormats.join(", ")}), `, - buttonLabel: `Use configuration`, - filters: [ - { name: "tarball", extensions: Extensions.supportedFormats } - ] - }); - - if (!canceled && filePaths.length) { - this.requestInstall( - filePaths.map(filePath => ({ - fileName: path.basename(filePath), - filePath, - })) - ); - } - }; - - installFromUrlOrPath = async () => { - const { installPath } = this; - - if (!installPath) return; - - this.startingInstall = true; - const fileName = path.basename(installPath); - - try { - // install via url - // fixme: improve error messages for non-tar-file URLs - if (InputValidators.isUrl.validate(installPath)) { - const { promise: filePromise } = downloadFile({ url: installPath, timeout: 60000 /*1m*/ }); - const data = await filePromise; - - await this.requestInstall({ fileName, data }); - } - // otherwise installing from system path - else if (InputValidators.isPath.validate(installPath)) { - await this.requestInstall({ fileName, filePath: installPath }); - } - } catch (error) { - this.startingInstall = false; - Notifications.error( -

Installation has failed: {String(error)}

- ); - } - }; - - installOnDrop = (files: File[]) => { - logger.info("Install from D&D"); - - return this.requestInstall( - files.map(file => ({ - fileName: path.basename(file.path), - filePath: file.path, - })) - ); - }; - - async preloadExtensions(requests: InstallRequest[], { showError = true } = {}) { - const preloadedRequests = requests.filter(request => request.data); - - await Promise.all( - requests - .filter(request => !request.data && request.filePath) - .map(async request => { - try { - const data = await fse.readFile(request.filePath); - - request.data = data; - preloadedRequests.push(request); - - return request; - } catch(error) { - if (showError) { - Notifications.error(`Error while reading "${request.filePath}": ${String(error)}`); - } - } - }) - ); - - return preloadedRequests as InstallRequestPreloaded[]; - } - - async validatePackage(filePath: string): Promise { - const tarFiles = await listTarEntries(filePath); - - // tarball from npm contains single root folder "package/*" - const firstFile = tarFiles[0]; - - if (!firstFile) { - throw new Error(`invalid extension bundle, ${manifestFilename} not found`); - } - - const rootFolder = path.normalize(firstFile).split(path.sep)[0]; - const packedInRootFolder = tarFiles.every(entry => entry.startsWith(rootFolder)); - const manifestLocation = packedInRootFolder ? path.join(rootFolder, manifestFilename) : manifestFilename; - - if (!tarFiles.includes(manifestLocation)) { - throw new Error(`invalid extension bundle, ${manifestFilename} not found`); - } - - const manifest = await readFileFromTar({ - tarPath: filePath, - filePath: manifestLocation, - parseJson: true, - }); - - if (!manifest.lens && !manifest.renderer) { - throw new Error(`${manifestFilename} must specify "main" and/or "renderer" fields`); - } - - return manifest; - } - - async createTempFilesAndValidate(requests: InstallRequestPreloaded[], { showErrors = true } = {}) { - const validatedRequests: InstallRequestValidated[] = []; - - // copy files to temp - await fse.ensureDir(this.getExtensionPackageTemp()); - - for (const request of requests) { - const tempFile = this.getExtensionPackageTemp(request.fileName); - - await fse.writeFile(tempFile, request.data); - } - - // validate packages - await Promise.all( - requests.map(async req => { - const tempFile = this.getExtensionPackageTemp(req.fileName); + componentDidMount() { + // TODO: change this after upgrading to mobx6 as that versions' reactions have this functionality + let prevSize = ExtensionLoader.getInstance().userExtensions.size; + disposeOnUnmount(this, [ + reaction(() => ExtensionLoader.getInstance().userExtensions.size, curSize => { try { - const manifest = await this.validatePackage(tempFile); - - validatedRequests.push({ - ...req, - manifest, - tempFile, - }); - } catch (error) { - fse.unlink(tempFile).catch(() => null); // remove invalid temp package - - if (showErrors) { - Notifications.error( -
-

Installing {req.fileName} has failed, skipping.

-

Reason: {String(error)}

-
- ); + if (curSize > prevSize) { + when(() => !ExtensionInstallationStateStore.anyInstalling) + .then(() => this.installPath = ""); } + } finally { + prevSize = curSize; } }) + ]); + } + + renderNoExtensionsHelpText() { + if (this.search) { + return

No search results found

; + } + + return ( +

+ There are no installed extensions. + See list of available extensions. +

); - - return validatedRequests; } - async requestInstall(init: InstallRequest | InstallRequest[]) { - const requests = Array.isArray(init) ? init : [init]; - const preloadedRequests = await this.preloadExtensions(requests); - const validatedRequests = await this.createTempFilesAndValidate(preloadedRequests); - - // If there are no requests for installing, reset startingInstall state - if (validatedRequests.length === 0) { - this.startingInstall = false; - } - - for (const install of validatedRequests) { - const { name, version, description } = install.manifest; - const extensionFolder = this.getExtensionDestFolder(name); - const folderExists = await fse.pathExists(extensionFolder); - - if (!folderExists) { - // auto-install extension if not yet exists - this.unpackExtension(install); - } else { - // If we show the confirmation dialog, we stop the install spinner until user clicks ok - // and the install continues - this.startingInstall = false; - - // otherwise confirmation required (re-install / update) - const removeNotification = Notifications.info( -
-
-

Install extension {name}@{version}?

-

Description: {description}

-
shell.openPath(extensionFolder)}> - Warning: {extensionFolder} will be removed before installation. -
-
-
- ); - } - } + renderNoExtensions() { + return ( +
+ +
+ {this.renderNoExtensionsHelpText()} +
+
+ ); } - async unpackExtension({ fileName, tempFile, manifest: { name, version } }: InstallRequestValidated) { - const displayName = extensionDisplayName(name, version); - const extensionId = path.join(ExtensionDiscovery.getInstance().nodeModulesPath, name, "package.json"); + @autobind() + renderExtension(extension: InstalledExtension) { + const { id, isEnabled, manifest } = extension; + const { name, description, version } = manifest; + const isUninstalling = ExtensionInstallationStateStore.isExtensionUninstalling(id); - logger.info(`Unpacking extension ${displayName}`, { fileName, tempFile }); - - Extensions.installStates.set(extensionId, { - state: "installing", - displayName - }); - this.startingInstall = false; - - const extensionFolder = this.getExtensionDestFolder(name); - const unpackingTempFolder = path.join(path.dirname(tempFile), `${path.basename(tempFile)}-unpacked`); - - logger.info(`Unpacking extension ${displayName}`, { fileName, tempFile }); - - try { - // extract to temp folder first - await fse.remove(unpackingTempFolder).catch(Function); - await fse.ensureDir(unpackingTempFolder); - await extractTar(tempFile, { cwd: unpackingTempFolder }); - - // move contents to extensions folder - const unpackedFiles = await fse.readdir(unpackingTempFolder); - let unpackedRootFolder = unpackingTempFolder; - - if (unpackedFiles.length === 1) { - // check if %extension.tgz was packed with single top folder, - // e.g. "npm pack %ext_name" downloads file with "package" root folder within tarball - unpackedRootFolder = path.join(unpackingTempFolder, unpackedFiles[0]); - } - - await fse.ensureDir(extensionFolder); - await fse.move(unpackedRootFolder, extensionFolder, { overwrite: true }); - } catch (error) { - Notifications.error( -

Installing extension {displayName} has failed: {error}

- ); - - // Remove install state on install failure - if (Extensions.installStates.get(extensionId)?.state === "installing") { - Extensions.installStates.delete(extensionId); - } - } finally { - // clean up - fse.remove(unpackingTempFolder).catch(Function); - fse.unlink(tempFile).catch(Function); - } - } - - confirmUninstallExtension = (extension: InstalledExtension) => { - const displayName = extensionDisplayName(extension.manifest.name, extension.manifest.version); - - ConfirmDialog.open({ - message:

Are you sure you want to uninstall extension {displayName}?

, - labelOk: "Yes", - labelCancel: "No", - ok: () => this.uninstallExtension(extension) - }); - }; - - async uninstallExtension(extension: InstalledExtension) { - const displayName = extensionDisplayName(extension.manifest.name, extension.manifest.version); - - try { - Extensions.installStates.set(extension.id, { - state: "uninstalling", - displayName - }); - - await ExtensionDiscovery.getInstance().uninstallExtension(extension); - } catch (error) { - Notifications.error( -

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

- ); - - // Remove uninstall state on uninstall failure - if (Extensions.installStates.get(extension.id)?.state === "uninstalling") { - Extensions.installStates.delete(extension.id); - } - } + return ( +
+
+
{name}
+
{version}
+

{description}

+
+
+ { + isEnabled + ? + : + } + +
+
+ ); } renderExtensions() { - const { extensions, search } = this; - - if (!extensions.length) { - return ( -
- -
- { - search - ?

No search results found

- :

There are no installed extensions. See list of available extensions.

- } -
-
- ); + if (!ExtensionDiscovery.getInstance().isLoaded) { + return
; } - return extensions.map(extension => { - const { id, isEnabled, manifest } = extension; - const { name, description, version } = manifest; - const isUninstalling = Extensions.installStates.get(id)?.state === "uninstalling"; + const { searchedForExtensions } = this; - return ( -
-
-
{name}
-
{version}
-

{description}

-
-
- {!isEnabled && ( - - )} - {isEnabled && ( - - )} - -
-
- ); - }); - } + if (!searchedForExtensions.length) { + return this.renderNoExtensions(); + } - /** - * True if at least one extension is in installing state - */ - @computed get isInstalling() { - return [...Extensions.installStates.values()].some(extension => extension.state === "installing"); + return ( + <> + {...searchedForExtensions.map(this.renderExtension)} + + ); } render() { const { installPath } = this; return ( - +

Lens Extensions

@@ -502,19 +583,19 @@ export class Extensions extends React.Component { this.installPath = value} - onSubmit={this.installFromUrlOrPath} + onSubmit={() => installFromInput(this.installPath)} iconLeft="link" iconRight={ } @@ -523,9 +604,9 @@ export class Extensions extends React.Component {
diff --git a/src/renderer/components/button/button.tsx b/src/renderer/components/button/button.tsx index 8bcb37bad4..da1d88d13f 100644 --- a/src/renderer/components/button/button.tsx +++ b/src/renderer/components/button/button.tsx @@ -1,5 +1,5 @@ import "./button.scss"; -import React, { ButtonHTMLAttributes, ReactNode } from "react"; +import React, { ButtonHTMLAttributes } from "react"; import { cssNames } from "../../utils"; import { TooltipDecoratorProps, withTooltip } from "../tooltip"; @@ -26,29 +26,22 @@ export class Button extends React.PureComponent { render() { const { - className, waiting, label, primary, accent, plain, hidden, active, big, - round, outlined, tooltip, light, children, ...props + waiting, label, primary, accent, plain, hidden, active, big, + round, outlined, tooltip, light, children, ...btnProps } = this.props; - const btnProps: Partial = props; if (hidden) return null; - btnProps.className = cssNames("Button", className, { + btnProps.className = cssNames("Button", btnProps.className, { waiting, primary, accent, plain, active, big, round, outlined, light, }); - const btnContent: ReactNode = ( - <> - {label} - {children} - - ); - // render as link if (this.props.href) { return ( this.link = e}> - {btnContent} + {label} + {children} ); } @@ -56,7 +49,8 @@ export class Button extends React.PureComponent { // render as button return ( ); } diff --git a/src/renderer/components/confirm-dialog/confirm-dialog.tsx b/src/renderer/components/confirm-dialog/confirm-dialog.tsx index 721ee36e45..0c29b40c02 100644 --- a/src/renderer/components/confirm-dialog/confirm-dialog.tsx +++ b/src/renderer/components/confirm-dialog/confirm-dialog.tsx @@ -11,14 +11,18 @@ import { Icon } from "../icon"; export interface ConfirmDialogProps extends Partial { } -export interface ConfirmDialogParams { - ok?: () => void; +export interface ConfirmDialogParams extends ConfirmDialogBooleanParams { + ok?: () => any | Promise; + cancel?: () => any | Promise; +} + +export interface ConfirmDialogBooleanParams { labelOk?: ReactNode; labelCancel?: ReactNode; - message?: ReactNode; + message: ReactNode; icon?: ReactNode; - okButtonProps?: Partial - cancelButtonProps?: Partial + okButtonProps?: Partial; + cancelButtonProps?: Partial; } @observer @@ -33,19 +37,26 @@ export class ConfirmDialog extends React.Component { ConfirmDialog.params = params; } - static close() { - ConfirmDialog.isOpen = false; + static confirm(params: ConfirmDialogBooleanParams): Promise { + return new Promise(resolve => { + ConfirmDialog.open({ + ok: () => resolve(true), + cancel: () => resolve(false), + ...params, + }); + }); } - public defaultParams: ConfirmDialogParams = { + static defaultParams: Partial = { ok: noop, + cancel: noop, labelOk: "Ok", labelCancel: "Cancel", icon: , }; get params(): ConfirmDialogParams { - return Object.assign({}, this.defaultParams, ConfirmDialog.params); + return Object.assign({}, ConfirmDialog.defaultParams, ConfirmDialog.params); } ok = async () => { @@ -54,16 +65,21 @@ export class ConfirmDialog extends React.Component { await Promise.resolve(this.params.ok()).catch(noop); } finally { this.isSaving = false; + ConfirmDialog.isOpen = false; } - this.close(); }; onClose = () => { this.isSaving = false; }; - close = () => { - ConfirmDialog.close(); + close = async () => { + try { + await Promise.resolve(this.params.cancel()).catch(noop); + } finally { + this.isSaving = false; + ConfirmDialog.isOpen = false; + } }; render() { diff --git a/src/renderer/components/dock/install-chart.store.ts b/src/renderer/components/dock/install-chart.store.ts index 7a11deb65a..3e06a2de43 100644 --- a/src/renderer/components/dock/install-chart.store.ts +++ b/src/renderer/components/dock/install-chart.store.ts @@ -1,7 +1,7 @@ import { action, autorun } from "mobx"; import { dockStore, IDockTab, TabId, TabKind } from "./dock.store"; import { DockTabStore } from "./dock-tab.store"; -import { HelmChart, helmChartsApi } from "../../api/endpoints/helm-charts.api"; +import { getChartDetails, getChartValues, HelmChart } from "../../api/endpoints/helm-charts.api"; import { IReleaseUpdateDetails } from "../../api/endpoints/helm-releases.api"; import { Notifications } from "../notifications"; @@ -54,7 +54,7 @@ export class InstallChartStore extends DockTabStore { const { repo, name, version } = this.getData(tabId); this.versions.clearData(tabId); // reset - const charts = await helmChartsApi.get(repo, name, version); + const charts = await getChartDetails(repo, name, { version }); const versions = charts.versions.map(chartVersion => chartVersion.version); this.versions.setData(tabId, versions); @@ -64,7 +64,7 @@ export class InstallChartStore extends DockTabStore { async loadValues(tabId: TabId, attempt = 0): Promise { const data = this.getData(tabId); const { repo, name, version } = data; - const values = await helmChartsApi.getValues(repo, name, version); + const values = await getChartValues(repo, name, version); if (values) { this.setData(tabId, { ...data, values }); diff --git a/src/renderer/components/dock/log-controls.tsx b/src/renderer/components/dock/log-controls.tsx index 8400aef584..3fc8d9eb14 100644 --- a/src/renderer/components/dock/log-controls.tsx +++ b/src/renderer/components/dock/log-controls.tsx @@ -11,7 +11,7 @@ import { Icon } from "../icon"; import { LogTabData } from "./log-tab.store"; interface Props { - tabData: LogTabData + tabData?: LogTabData logs: string[] save: (data: Partial) => void reload: () => void @@ -19,6 +19,11 @@ interface Props { export const LogControls = observer((props: Props) => { const { tabData, save, reload, logs } = props; + + if (!tabData) { + return null; + } + const { showTimestamps, previous } = tabData; const since = logs.length ? logStore.getTimestamps(logs[0]) : null; const pod = new Pod(tabData.selectedPod); diff --git a/src/renderer/components/dock/logs.tsx b/src/renderer/components/dock/logs.tsx index 0aa31f95fb..07f65c6e63 100644 --- a/src/renderer/components/dock/logs.tsx +++ b/src/renderer/components/dock/logs.tsx @@ -26,23 +26,14 @@ export class Logs extends React.Component { componentDidMount() { disposeOnUnmount(this, - reaction(() => this.props.tab.id, this.reload, { fireImmediately: true }) + reaction(() => this.props.tab.id, this.reload, { fireImmediately: true }), ); } - get tabData() { - return logTabStore.getData(this.tabId); - } - get tabId() { return this.props.tab.id; } - @autobind() - save(data: Partial) { - logTabStore.setData(this.tabId, { ...this.tabData, ...data }); - } - load = async () => { this.isLoading = true; await logStore.load(this.tabId); @@ -82,15 +73,19 @@ export class Logs extends React.Component { }, 100); } - renderResourceSelector() { + renderResourceSelector(data?: LogTabData) { + if (!data) { + return null; + } + const logs = logStore.logs; - const searchLogs = this.tabData.showTimestamps ? logs : logStore.logsWithoutTimestamps; + const searchLogs = data.showTimestamps ? logs : logStore.logsWithoutTimestamps; const controls = (
logTabStore.setData(this.tabId, { ...data, ...newData })} reload={this.reload} /> { render() { const logs = logStore.logs; + const data = logTabStore.getData(this.tabId); + + if (!data) { + this.reload(); + } return (
- {this.renderResourceSelector()} + {this.renderResourceSelector(data)} { /> logTabStore.setData(this.tabId, { ...data, ...newData })} reload={this.reload} />
diff --git a/src/renderer/components/input/input.tsx b/src/renderer/components/input/input.tsx index ce0594c0e6..ad3b77c8e8 100644 --- a/src/renderer/components/input/input.tsx +++ b/src/renderer/components/input/input.tsx @@ -315,6 +315,7 @@ export class Input extends React.Component { rows: multiLine ? (rows || 1) : null, ref: this.bindRef, spellCheck: "false", + disabled, }); const showErrors = errors.length > 0 && !valid && dirty; const errorsInfo = ( diff --git a/src/renderer/components/input/input_validators.ts b/src/renderer/components/input/input_validators.ts index ae5fd6d1e1..c96d63a4c5 100644 --- a/src/renderer/components/input/input_validators.ts +++ b/src/renderer/components/input/input_validators.ts @@ -47,6 +47,14 @@ export const isUrl: InputValidator = { }, }; +export const isExtensionNameInstallRegex = /^(?(@[-\w]+\/)?[-\w]+)(@(?\d\.\d\.\d(-\w+)?))?$/gi; + +export const isExtensionNameInstall: InputValidator = { + condition: ({ type }) => type === "text", + message: () => "Not an extension name with optional version", + validate: value => value.match(isExtensionNameInstallRegex) !== null, +}; + export const isPath: InputValidator = { condition: ({ type }) => type === "text", message: () => `This field must be a valid path`, diff --git a/src/renderer/components/kube-object-status-icon/kube-object-status-icon.tsx b/src/renderer/components/kube-object-status-icon/kube-object-status-icon.tsx index 386255d1eb..a9813d02db 100644 --- a/src/renderer/components/kube-object-status-icon/kube-object-status-icon.tsx +++ b/src/renderer/components/kube-object-status-icon/kube-object-status-icon.tsx @@ -2,105 +2,106 @@ import "./kube-object-status-icon.scss"; import React from "react"; import { Icon } from "../icon"; -import { KubeObject } from "../../api/kube-object"; import { cssNames, formatDuration } from "../../utils"; -import { KubeObjectStatusRegistration, kubeObjectStatusRegistry } from "../../../extensions/registries/kube-object-status-registry"; -import { KubeObjectStatus, KubeObjectStatusLevel } from "../../..//extensions/renderer-api/k8s-api"; -import { computed } from "mobx"; +import { KubeObject, KubeObjectStatus, KubeObjectStatusLevel } from "../../..//extensions/renderer-api/k8s-api"; +import { kubeObjectStatusRegistry } from "../../../extensions/registries"; + +function statusClassName(level: number): string { + switch (level) { + case KubeObjectStatusLevel.INFO: + return "info"; + case KubeObjectStatusLevel.WARNING: + return "warning"; + case KubeObjectStatusLevel.CRITICAL: + return "error"; + } +} + +function statusTitle(level: KubeObjectStatusLevel): string { + switch (level) { + case KubeObjectStatusLevel.INFO: + return "Info"; + case KubeObjectStatusLevel.WARNING: + return "Warning"; + case KubeObjectStatusLevel.CRITICAL: + return "Critical"; + } +} + +function getAge(timestamp: string) { + return timestamp + ? formatDuration(Date.now() - new Date(timestamp).getTime(), true) + : ""; +} + +interface SplitStatusesByLevel { + maxLevel: string, + criticals: KubeObjectStatus[]; + warnings: KubeObjectStatus[]; + infos: KubeObjectStatus[]; +} + +/** + * This fuction returns the class level for corresponding to the highest status level + * and the statuses split by their levels. + * @param src a list of status items + */ +function splitByLevel(src: KubeObjectStatus[]): SplitStatusesByLevel { + const parts = new Map(Object.values(KubeObjectStatusLevel).map(v => [v, []])); + + src.forEach(status => parts.get(status.level).push(status)); + + const criticals = parts.get(KubeObjectStatusLevel.CRITICAL); + const warnings = parts.get(KubeObjectStatusLevel.WARNING); + const infos = parts.get(KubeObjectStatusLevel.INFO); + const maxLevel = statusClassName(criticals[0]?.level ?? warnings[0]?.level ?? infos[0].level); + + return { maxLevel, criticals, warnings, infos }; +} interface Props { object: KubeObject; } export class KubeObjectStatusIcon extends React.Component { - @computed get objectStatuses() { - const { object } = this.props; - const registrations = kubeObjectStatusRegistry.getItemsForKind(object.kind, object.apiVersion); - - return registrations.map((item: KubeObjectStatusRegistration) => { return item.resolve(object); }).filter((item: KubeObjectStatus) => !!item); - } - - statusClassName(level: number): string { - switch (level) { - case KubeObjectStatusLevel.INFO: - return "info"; - case KubeObjectStatusLevel.WARNING: - return "warning"; - case KubeObjectStatusLevel.CRITICAL: - return "error"; - default: - return ""; - } - } - - statusTitle(level: number): string { - switch (level) { - case KubeObjectStatusLevel.INFO: - return "Info"; - case KubeObjectStatusLevel.WARNING: - return "Warning"; - case KubeObjectStatusLevel.CRITICAL: - return "Critical"; - default: - return ""; - } - } - - getAge(timestamp: string) { - if (!timestamp) return ""; - const diff = Date.now() - new Date(timestamp).getTime(); - - return formatDuration(diff, true); - } - renderStatuses(statuses: KubeObjectStatus[], level: number) { const filteredStatuses = statuses.filter((item) => item.level == level); return filteredStatuses.length > 0 && ( -
+
- {this.statusTitle(level)} + {statusTitle(level)} - { filteredStatuses.map((status, index) =>{ - return ( + { + filteredStatuses.map((status, index) => (
- - {status.text} · { this.getAge(status.timestamp) } + - {status.text} · {getAge(status.timestamp)}
- ); - })} + )) + }
); } render() { - const { objectStatuses} = this; + const statuses = kubeObjectStatusRegistry.getItemsForObject(this.props.object); - if (!objectStatuses.length) return null; + if (statuses.length === 0) { + return null; + } - const sortedStatuses = objectStatuses.sort((a: KubeObjectStatus, b: KubeObjectStatus) => { - if (a.level < b.level ) { - return 1; - } - - if (a.level > b.level ) { - return -1; - } - - return 0; - }); - - const level = this.statusClassName(sortedStatuses[0].level); + const { maxLevel, criticals, warnings, infos } = splitByLevel(statuses); return ( - {this.renderStatuses(sortedStatuses, KubeObjectStatusLevel.CRITICAL)} - {this.renderStatuses(sortedStatuses, KubeObjectStatusLevel.WARNING)} - {this.renderStatuses(sortedStatuses, KubeObjectStatusLevel.INFO)} + {this.renderStatuses(criticals, KubeObjectStatusLevel.CRITICAL)} + {this.renderStatuses(warnings, KubeObjectStatusLevel.WARNING)} + {this.renderStatuses(infos, KubeObjectStatusLevel.INFO)}
) }} diff --git a/src/renderer/components/kube-object/kube-object-meta.tsx b/src/renderer/components/kube-object/kube-object-meta.tsx index 7db45990c5..1b37c5c457 100644 --- a/src/renderer/components/kube-object/kube-object-meta.tsx +++ b/src/renderer/components/kube-object/kube-object-meta.tsx @@ -24,13 +24,11 @@ export class KubeObjectMeta extends React.Component { } render() { - const object = this.props.object; + const { object } = this.props; const { - getName, getNs, getLabels, getResourceVersion, selfLink, - getAnnotations, getFinalizers, getId, getAge, - metadata: { creationTimestamp }, + getNs, getLabels, getResourceVersion, selfLink, getAnnotations, + getFinalizers, getId, getAge, getName, metadata: { creationTimestamp }, } = object; - const ownerRefs = object.getOwnerRefs(); return ( @@ -39,7 +37,8 @@ export class KubeObjectMeta extends React.Component { {getAge(true, false)} ago ({})
diff --git a/src/renderer/components/spinner/spinner.scss b/src/renderer/components/spinner/spinner.scss index b8843b542d..75c3839152 100644 --- a/src/renderer/components/spinner/spinner.scss +++ b/src/renderer/components/spinner/spinner.scss @@ -34,12 +34,6 @@ margin-top: calc(var(--spinner-size) / -2); } - &.centerHorizontal { - position: absolute; - left: 50%; - margin-left: calc(var(--spinner-size) / -2); - } - @keyframes rotate { 0% { transform: rotate(0deg); diff --git a/src/renderer/components/spinner/spinner.tsx b/src/renderer/components/spinner/spinner.tsx index 9708221252..32c764b80a 100644 --- a/src/renderer/components/spinner/spinner.tsx +++ b/src/renderer/components/spinner/spinner.tsx @@ -6,7 +6,6 @@ import { cssNames } from "../../utils"; export interface SpinnerProps extends React.HTMLProps { singleColor?: boolean; center?: boolean; - centerHorizontal?: boolean; } export class Spinner extends React.Component { @@ -16,8 +15,8 @@ export class Spinner extends React.Component { }; render() { - const { center, singleColor, centerHorizontal, className, ...props } = this.props; - const classNames = cssNames("Spinner", className, { singleColor, center, centerHorizontal }); + const { center, singleColor, className, ...props } = this.props; + const classNames = cssNames("Spinner", className, { singleColor, center }); return
; } diff --git a/src/renderer/protocol-handler/app-handlers.ts b/src/renderer/protocol-handler/app-handlers.ts index abc1607303..eeae6ccffc 100644 --- a/src/renderer/protocol-handler/app-handlers.ts +++ b/src/renderer/protocol-handler/app-handlers.ts @@ -1,6 +1,6 @@ import { addClusterURL } from "../components/+add-cluster"; -import { extensionsURL } from "../components/+extensions"; import { catalogURL } from "../components/+catalog"; +import { attemptInstallByInfo, extensionsURL } from "../components/+extensions"; import { preferencesURL } from "../components/+preferences"; import { clusterViewURL } from "../components/cluster-manager/cluster-view.route"; import { LensProtocolRouterRenderer } from "./router"; @@ -8,6 +8,7 @@ import { navigate } from "../navigation/helpers"; import { entitySettingsURL } from "../components/+entity-settings"; import { catalogEntityRegistry } from "../api/catalog-entity-registry"; import { ClusterStore } from "../../common/cluster-store"; +import { EXTENSION_NAME_MATCH, EXTENSION_PUBLISHER_MATCH, LensProtocolRouter } from "../../common/protocol-handler"; export function bindProtocolAddRouteHandlers() { LensProtocolRouterRenderer @@ -33,9 +34,6 @@ export function bindProtocolAddRouteHandlers() { console.log("[APP-HANDLER]: catalog entity with given ID does not exist", { entityId }); } }) - .addInternalHandler("/extensions", () => { - navigate(extensionsURL()); - }) // Handlers below are deprecated and only kept for backward compact purposes .addInternalHandler("/cluster/:clusterId", ({ pathname: { clusterId } }) => { const cluster = ClusterStore.getInstance().getById(clusterId); @@ -54,5 +52,18 @@ export function bindProtocolAddRouteHandlers() { } else { console.log("[APP-HANDLER]: cluster with given ID does not exist", { clusterId }); } + }) + .addInternalHandler("/extensions", () => { + navigate(extensionsURL()); + }) + .addInternalHandler(`/extensions/install${LensProtocolRouter.ExtensionUrlSchema}`, ({ pathname, search: { version } }) => { + const name = [ + pathname[EXTENSION_PUBLISHER_MATCH], + pathname[EXTENSION_NAME_MATCH], + ].filter(Boolean) + .join("/"); + + navigate(extensionsURL()); + attemptInstallByInfo({ name, version, requireConfirmation: true }); }); } diff --git a/src/renderer/utils/cancelableFetch.ts b/src/renderer/utils/cancelableFetch.ts deleted file mode 100644 index a4a197fe0d..0000000000 --- a/src/renderer/utils/cancelableFetch.ts +++ /dev/null @@ -1,36 +0,0 @@ -// Allow to cancel request for window.fetch() - -export interface CancelablePromise extends Promise { - then(onfulfilled?: ((value: T) => TResult1 | PromiseLike) | undefined | null, onrejected?: ((reason: any) => TResult2 | PromiseLike) | undefined | null): CancelablePromise; - catch(onrejected?: ((reason: any) => TResult | PromiseLike) | undefined | null): CancelablePromise; - finally(onfinally?: (() => void) | undefined | null): CancelablePromise; - cancel(): void; -} - -interface WrappingFunction { - (result: Promise): CancelablePromise; - (result: T): T; -} - -export function cancelableFetch(reqInfo: RequestInfo, reqInit: RequestInit = {}) { - const abortController = new AbortController(); - const signal = abortController.signal; - const cancel = abortController.abort.bind(abortController); - const wrapResult: WrappingFunction = function (result: any) { - if (result instanceof Promise) { - const promise: CancelablePromise = result as any; - - promise.then = function (onfulfilled, onrejected) { - const data = Object.getPrototypeOf(this).then.call(this, onfulfilled, onrejected); - - return wrapResult(data); - }; - promise.cancel = cancel; - } - - return result; - }; - const req = fetch(reqInfo, { ...reqInit, signal }); - - return wrapResult(req); -}