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

Cherry-pick from 4.2.3 (#2628)

* Fix: logs data disapearing causing crashes (#2566)

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* Refactor helm-chart.api and improve kube validation and error handling (#2265)

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* Fix: HPA's not sortable by age (#2565)

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* Conditionally render status icon for kube meta (#2298)

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* Fix custom resource loading spinner appears above extensions' cluster menus (#2344)

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* Lens should point to the release docs (#2268)

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* Refactor the Extensions settings page (#2221)

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* try and get jest to not core dump

Signed-off-by: Sebastian Malton <sebastian@malton.name>
This commit is contained in:
Sebastian Malton 2021-04-27 01:11:50 -04:00 committed by GitHub
parent 7fde8125eb
commit 1f854d0a0f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
42 changed files with 1777 additions and 1067 deletions

View File

@ -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": {

View File

@ -124,8 +124,8 @@ export abstract class BaseStore<T = any> 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<T = any> 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.

View File

@ -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<ClusterStoreModel> {
@observable clusters = observable.map<ClusterId, Cluster>();
private static stateRequestChannel = "cluster:states";
protected disposer = disposer();
constructor() {
super({
@ -143,7 +145,7 @@ export class ClusterStore extends BaseStore<ClusterStoreModel> {
cluster.setState(clusterState.state);
}
});
} else {
} else if (ipcMain) {
handleRequest(ClusterStore.stateRequestChannel, (): clusterStateSync[] => {
const states: clusterStateSync[] = [];
@ -160,13 +162,16 @@ export class ClusterStore extends BaseStore<ClusterStoreModel> {
}
protected pushStateToViewsAutomatically() {
if (!ipcRenderer) {
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<ClusterStoreModel> {
unregisterIpcListener() {
super.unregisterIpcListener();
unsubscribeAllFromBroadcast("cluster:state");
this.disposer();
}
pushState() {
@ -288,7 +293,7 @@ export class ClusterStore extends BaseStore<ClusterStoreModel> {
// remove only custom kubeconfigs (pasted as text)
if (cluster.kubeConfigPath == ClusterStore.getCustomKubeConfigPath(clusterId)) {
unlink(cluster.kubeConfigPath).catch(() => null);
await unlink(cluster.kubeConfigPath).catch(noop);
}
}
}

View File

@ -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);
}
}

View File

@ -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}`;
/**
*

View File

@ -0,0 +1,20 @@
export type Disposer = () => void;
interface Extendable<T> {
push(...vals: T[]): void;
}
export type ExtendableDisposer = Disposer & Extendable<Disposer>;
export function disposer(...args: Disposer[]): ExtendableDisposer {
const res = () => {
args.forEach(dispose => dispose?.());
args.length = 0;
};
res.push = (...vals: Disposer[]) => {
args.push(...vals);
};
return res;
}

View File

@ -6,13 +6,13 @@ export interface DownloadFileOptions {
timeout?: number;
}
export interface DownloadFileTicket {
export interface DownloadFileTicket<T> {
url: string;
promise: Promise<Buffer>;
promise: Promise<T>;
cancel(): void;
}
export function downloadFile({ url, timeout, gzip = true }: DownloadFileOptions): DownloadFileTicket {
export function downloadFile({ url, timeout, gzip = true }: DownloadFileOptions): DownloadFileTicket<Buffer> {
const fileChunks: Buffer[] = [];
const req = request(url, { gzip, timeout });
const promise: Promise<Buffer> = new Promise((resolve, reject) => {
@ -35,3 +35,12 @@ export function downloadFile({ url, timeout, gzip = true }: DownloadFileOptions)
}
};
}
export function downloadJson(args: DownloadFileOptions): DownloadFileTicket<any> {
const { promise, ...rest } = downloadFile(args);
return {
promise: promise.then(res => JSON.parse(res.toString())),
...rest
};
}

View File

@ -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 };

View File

@ -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<V extends object, K extends PropertyKey>(val: V, key: K): val is (V & { [key in K]: unknown }) {
export function hasOwnProperty<S extends object, K extends PropertyKey>(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<V extends object, K extends PropertyKey>(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<S extends object, K extends PropertyKey>(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<S extends object, K extends PropertyKey, V>(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<S extends object, K extends PropertyKey, V>(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<T, V>` 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<T extends PropertyKey, V>(val: unknown, isKey: (key: unknown) => key is T, isValue: (value: unknown) => value is V): val is Record<T, V> {
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<T>(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<FnArgs extends any[], T>(fn: (arg1: unknown, ...args: FnArgs) => arg1 is T, ...boundArgs: FnArgs): (arg1: unknown) => arg1 is T {
return (arg1: unknown): arg1 is T => fn(arg1, ...boundArgs);
}

View File

@ -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}`;

View File

@ -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<typeof watch>;
describe("ExtensionDiscovery", () => {
@ -24,10 +29,20 @@ describe("ExtensionDiscovery", () => {
ExtensionsStore.createInstance();
});
it("emits add for added extension", async done => {
globalThis.__non_webpack_require__.mockImplementation(() => ({
describe("with mockFs", () => {
beforeEach(() => {
mockFs({
[`${os.homedir()}/.k8slens/extensions/my-extension/package.json`]: JSON.stringify({
name: "my-extension"
}));
}),
});
});
afterEach(() => {
mockFs.restore();
});
it("emits add for added extension", async (done) => {
let addHandler: (filePath: string) => void;
const mockWatchInstance: any = {
@ -43,6 +58,7 @@ describe("ExtensionDiscovery", () => {
mockedWatch.mockImplementationOnce(() =>
(mockWatchInstance) as any
);
const extensionDiscovery = ExtensionDiscovery.createInstance();
// Need to force isLoaded to be true so that the file watching is started
@ -50,21 +66,22 @@ describe("ExtensionDiscovery", () => {
await extensionDiscovery.watchExtensions();
extensionDiscovery.events.on("add", (extension: InstalledExtension) => {
extensionDiscovery.events.on("add", extension => {
expect(extension).toEqual({
absolutePath: expect.any(String),
id: normalize("node_modules/my-extension/package.json"),
id: path.normalize("node_modules/my-extension/package.json"),
isBundled: false,
isEnabled: false,
manifest: {
name: "my-extension",
},
manifestPath: normalize("node_modules/my-extension/package.json"),
manifestPath: path.normalize("node_modules/my-extension/package.json"),
});
done();
});
addHandler(join(extensionDiscovery.localFolderPath, "/my-extension/package.json"));
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();

View File

@ -1,15 +1,17 @@
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 {
@ -25,7 +27,7 @@ export interface InstalledExtension {
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 "<extensionDir>/package.json" add
.on("add", this.handleWatchFileAdd)
// Extension remove is detected by watching <extensionDir>" unlink
.on("unlinkDir", this.handleWatchUnlinkDir);
// Extension remove is detected by watching "<extensionDir>" unlink
.on("unlinkDir", this.handleWatchUnlinkEvent)
// Extension remove is detected by watching "<extensionSymLink>" 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<void> => {
// 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 (expectedPath !== extensionFolderName) {
return;
}
if (path.relative(this.localFolderPath, filePath) === extensionFolderName) {
const extension = Array.from(this.extensions.values()).find((extension) => extension.absolutePath === filePath);
if (extension) {
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
// 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`);
}
}
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<Map<LensExtensionId, InstalledExtension>> {
@ -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/<username>/Library/Application Support/LensDev/extensions
await fs.remove(this.inTreeTargetPath);
await fse.remove(this.inTreeTargetPath);
// Create folder e.g. /Users/<username>/Library/Application Support/LensDev/extensions
await fs.ensureDir(this.inTreeTargetPath);
await fse.ensureDir(this.inTreeTargetPath);
// Copy static/extensions to e.g. /Users/<username>/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/<username>/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<InstalledExtension | null> {
let manifestJson: LensExtensionManifest;
protected async getByManifest(manifestPath: string, { isBundled = false } = {}): Promise<InstalledExtension | null> {
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<InstalledExtension[]> {
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)) {

View File

@ -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 });
}
}

View File

@ -9,9 +9,17 @@ export interface KubeObjectStatusRegistration {
export class KubeObjectStatusRegistry extends BaseRegistry<KubeObjectStatusRegistration> {
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);
}
}

View File

@ -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)]),
],
});

View File

@ -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<KubeJsonApiData> (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);
});
});
});

View File

@ -16,39 +16,51 @@ const endpoint = compile(`/v2/charts/:repo?/:name?`) as (params?: {
name?: string;
}) => string;
export const helmChartsApi = {
list() {
return apiBase
.get<HelmChartList>(endpoint())
.then(data => {
/**
* Get a list of all helm charts from all saved helm repos
*/
export async function listCharts(): Promise<HelmChart[]> {
const data = await apiBase.get<HelmChartList>(endpoint());
return Object
.values(data)
.reduce((allCharts, repoCharts) => allCharts.concat(Object.values(repoCharts)), [])
.map(([chart]) => HelmChart.create(chart));
});
},
}
get(repo: string, name: string, readmeVersion?: string) {
export interface GetChartDetailsOptions {
version?: string;
reqInit?: RequestInit;
}
/**
* 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<IHelmChartDetails> {
const path = endpoint({ repo, name });
return apiBase
.get<IHelmChartDetails>(`${path}?${stringify({ version: readmeVersion })}`)
.then(data => {
const { readme, ...data } = await apiBase.get<IHelmChartDetails>(`${path}?${stringify({ version })}`, undefined, reqInit);
const versions = data.versions.map(HelmChart.create);
const readme = data.readme;
return {
readme,
versions,
};
});
},
}
getValues(repo: string, name: string, version: string) {
return apiBase
.get<string>(`/v2/charts/${repo}/${name}/values?${stringify({ version })}`);
}
};
/**
* 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<string> {
return apiBase.get<string>(`/v2/charts/${repo}/${name}/values?${stringify({ version })}`);
}
@autobind()
export class HelmChart {

View File

@ -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<D = JsonApiData, P extends JsonApiParams = JsonApiParams> {
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<D = JsonApiData, P extends JsonApiParams = JsonApiParams> {
return this.request<T>(path, params, { ...reqInit, method: "delete" });
}
protected request<D>(path: string, params?: P, init: RequestInit = {}) {
protected async request<D>(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,15 +117,15 @@ export class JsonApi<D = JsonApiData, P extends JsonApiParams = JsonApiParams> {
reqInit,
};
return cancelableFetch(reqUrl, reqInit).then(res => {
const res = await fetch(reqUrl, reqInit);
return this.parseResponse<D>(res, infoLog);
});
}
protected parseResponse<D>(res: Response, log: JsonApiLog): Promise<D> {
protected async parseResponse<D>(res: Response, log: JsonApiLog): Promise<D> {
const { status } = res;
return res.text().then(text => {
const text = await res.text();
let data;
try {
@ -141,26 +139,31 @@ export class JsonApi<D = JsonApiData, P extends JsonApiParams = JsonApiParams> {
this.writeLog({ ...log, data });
return data;
} else if (log.method === "GET" && res.status === 403) {
this.writeLog({ ...log, data });
} else {
}
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];
}

View File

@ -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<T extends KubeObject> {
/**
@ -34,6 +35,11 @@ export interface IKubeApiOptions<T extends KubeObject> {
checkPreferredVersion?: boolean;
}
export interface KubeApiListOptions {
namespace?: string;
reqInit?: RequestInit;
}
export interface IKubeApiQueryParams {
watch?: boolean | number;
resourceVersion?: string;
@ -245,7 +251,7 @@ export class KubeApi<T extends KubeObject = any> {
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<T extends KubeObject = any> {
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,36 +303,60 @@ export class KubeApi<T extends KubeObject = any> {
});
}
// 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<T[]> {
async list({ namespace = "", reqInit }: KubeApiListOptions = {}, query?: IKubeApiQueryParams): Promise<T[] | null> {
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;
}
async get({ name = "", namespace = "default" } = {}, query?: IKubeApiQueryParams): Promise<T> {
await this.checkPreferredVersion();
return this.request
.get(this.getUrl({ namespace, name }), { query })
.then(this.parseResponse);
if (!parsed) {
return null;
}
async create({ name = "", namespace = "default" } = {}, data?: Partial<T>): Promise<T> {
throw new Error(`GET multiple request to ${url} returned not an array: ${JSON.stringify(parsed)}`);
}
async get({ name = "", namespace = "default" } = {}, query?: IKubeApiQueryParams): Promise<T | null> {
await this.checkPreferredVersion();
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<T>): Promise<T | null> {
await this.checkPreferredVersion();
const apiUrl = this.getUrl({ namespace });
return this.request
.post(apiUrl, {
const res = await this.request.post(apiUrl, {
data: merge({
kind: this.kind,
apiVersion: this.apiVersionWithGroup,
@ -343,17 +365,28 @@ export class KubeApi<T extends KubeObject = any> {
namespace
}
}, data)
})
.then(this.parseResponse);
});
const parsed = this.parseResponse(res);
if (Array.isArray(parsed)) {
throw new Error(`POST request to ${apiUrl} returned an array: ${JSON.stringify(parsed)}`);
}
async update({ name = "", namespace = "default" } = {}, data?: Partial<T>): Promise<T> {
return parsed;
}
async update({ name = "", namespace = "default" } = {}, data?: Partial<T>): Promise<T | null> {
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,28 +405,23 @@ export class KubeApi<T extends KubeObject = any> {
}
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;
responsePromise
.then(response => {
if (!response.ok) {
return callback(null, response);
}
const nodeStream = new ReadableWebToNodeStream(response.body);
["end", "close", "error"].forEach((eventName) => {
@ -402,48 +430,35 @@ export class KubeApi<T extends KubeObject = any> {
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) => {
byline(nodeStream).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;
return callback(null, new KubeStatus(event.object as any));
}
this.modifyWatchEvent(event);
if (callback) {
callback(event, null);
}
} catch (ignore) {
// ignore parse errors
}
});
}, (error) => {
})
.catch(error => {
if (error instanceof DOMException) return; // AbortController rejects, we can ignore it
callback?.(null, error);
}).catch((error) => {
callback?.(null, error);
callback(null, error);
});
const disposer = () => {
abortController.abort();
};
return disposer;
return abort;
}
protected modifyWatchEvent(event: IKubeWatchEvent) {

View File

@ -1,19 +1,18 @@
import { JsonApi, JsonApiData, JsonApiError } from "./json-api";
export interface KubeJsonApiListMetadata {
resourceVersion: string;
selfLink?: string;
}
export interface KubeJsonApiDataList<T = KubeJsonApiData> {
kind: string;
apiVersion: string;
items: T[];
metadata: {
resourceVersion: string;
selfLink: string;
};
metadata: KubeJsonApiListMetadata;
}
export interface KubeJsonApiData extends JsonApiData {
kind: string;
apiVersion: string;
metadata: {
export interface KubeJsonApiMetadata {
uid: string;
name: string;
namespace?: string;
@ -28,7 +27,12 @@ export interface KubeJsonApiData extends JsonApiData {
annotations?: {
[annotation: string]: string;
};
};
}
export interface KubeJsonApiData extends JsonApiData {
kind: string;
apiVersion: string;
metadata: KubeJsonApiMetadata;
}
export interface KubeJsonApiError extends JsonApiError {

View File

@ -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<T extends KubeObject = any> = (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<KubeJsonApiData> {
return (
isObject(object)
&& hasOptionalProperty(object, "kind", isString)
&& hasOptionalProperty(object, "apiVersion", isString)
&& hasOptionalProperty(object, "metadata", KubeObject.isKubeJsonApiMetadata)
);
}
static isJsonApiDataList<T>(object: unknown, verifyItem:(val: unknown) => val is T): object is KubeJsonApiDataList<T> {
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}`);

View File

@ -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

View File

@ -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<Props> {
@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 => {
getChartDetails(repo, name, { version })
.then(result => {
this.readme = result.readme;
this.chartVersions = result.versions;
this.selectedChart = result.versions[0];
},
error => {
})
.catch(error => {
this.error = error;
});
});
@autobind()
async onVersionChange({ value: version }: SelectOption) {
async onVersionChange({ value: version }: SelectOption<string>) {
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) {

View File

@ -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<HelmChart> {
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<HelmChart> {
return versions;
}
const loadVersions = (repo: string) => {
return helmChartsApi.get(repo, chartName).then(({ versions }) => {
const loadVersions = async (repo: string) => {
const { versions } = await getChartDetails(repo, chartName);
return versions.map(chart => ({
repo,
version: chart.getVersion()
}));
});
};
if (!this.isLoaded) {

View File

@ -47,7 +47,8 @@ export class HorizontalPodAutoscalers extends React.Component<Props> {
[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()

View File

@ -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", () => {
return {
jest.mock("electron", () => ({
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", () => {
});
});
afterEach(() => {
mockFs.restore();
});
it("disables uninstall and disable buttons while uninstalling", async () => {
ExtensionDiscovery.getInstance().isLoaded = true;
render(<><Extensions /><ConfirmDialog/></>);
expect(screen.getByText("Disable").closest("button")).not.toBeDisabled();
expect(screen.getByText("Uninstall").closest("button")).not.toBeDisabled();
const res = render(<><Extensions /><ConfirmDialog /></>);
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"));
await waitFor(() => {
expect(ExtensionDiscovery.getInstance().uninstallExtension).toHaveBeenCalled();
expect(screen.getByText("Disable").closest("button")).toBeDisabled();
expect(screen.getByText("Uninstall").closest("button")).toBeDisabled();
});
it("displays error notification on uninstall error", () => {
ExtensionDiscovery.getInstance().isLoaded = true;
(ExtensionDiscovery.getInstance().uninstallExtension as any).mockImplementationOnce(() =>
Promise.reject()
);
render(<><Extensions /><ConfirmDialog/></>);
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"));
waitFor(() => {
expect(screen.getByText("Disable").closest("button")).not.toBeDisabled();
expect(screen.getByText("Uninstall").closest("button")).not.toBeDisabled();
expect(Notifications.error).toHaveBeenCalledTimes(1);
expect(res.getByText("Disable").closest("button")).toBeDisabled();
expect(res.getByText("Uninstall").closest("button")).toBeDisabled();
}, {
timeout: 30000,
});
});
it("disables install button while installing", () => {
render(<Extensions />);
it("disables install button while installing", async () => {
const res = render(<Extensions />);
fireEvent.change(screen.getByPlaceholderText("Path or URL to an extension package", {
(fse.unlink as jest.MockedFunction<typeof fse.unlink>).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(<Extensions />);
expect(container.querySelector(".Spinner")).toBeInTheDocument();
});
it("does not display the spinner while extensions are not loading", async () => {
ExtensionDiscovery.getInstance().isLoaded = true;
const { container } = render(<Extensions />);
waitFor(() =>
expect(container.querySelector(".Spinner")).not.toBeInTheDocument()

View File

@ -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<string>();
private static UninstallingExtensions = observable.set<string>();
private static InstallingExtensions = observable.set<string>();
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;
}
}

View File

@ -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,218 +22,127 @@ 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<Buffer | null>;
}
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<boolean> {
const loader = ExtensionLoader.getInstance();
const { manifest } = loader.getExtension(extensionId);
const displayName = extensionDisplayName(manifest.name, manifest.version);
@observer
export class Extensions extends React.Component {
private static supportedFormats = ["tar", "tgz"];
try {
logger.debug(`[EXTENSIONS]: trying to uninstall ${extensionId}`);
ExtensionInstallationStateStore.setUninstalling(extensionId);
private static installPathValidator: InputValidator = {
message: "Invalid URL or absolute path",
validate(value: string) {
return InputValidators.isUrl.validate(value) || InputValidators.isPath.validate(value);
}
};
await ExtensionDiscovery.getInstance().uninstallExtension(extensionId);
static installStates = observable.map<string, ExtensionState>();
// wait for the ExtensionLoader to actually uninstall the extension
await when(() => !loader.userExtensions.has(extensionId));
@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(
<p>Extension <b>{displayName}</b> successfully uninstalled!</p>
);
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(
<p>Extension <b>{displayName}</b> successfully installed!</p>
);
Extensions.installStates.delete(id);
this.installPath = "";
// Enable installed extensions by default.
extension.isEnabled = true;
});
})
);
}
@computed get extensions() {
const searchText = this.search.toLowerCase();
return Array.from(ExtensionLoader.getInstance().userExtensions.values())
.filter(({ manifest: { name, description }}) => (
name.toLowerCase().includes(searchText)
|| description?.toLowerCase().includes(searchText)
));
}
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 });
}
return true;
} catch (error) {
this.startingInstall = false;
Notifications.error(
<p>Installation has failed: <b>{String(error)}</b></p>
);
const message = getMessageFromError(error);
logger.info(`[EXTENSION-UNINSTALL]: uninstalling ${displayName} has failed: ${error}`, { error });
Notifications.error(<p>Uninstalling extension <b>{displayName}</b> has failed: <em>{message}</em></p>);
return false;
} finally {
// Remove uninstall state on uninstall failure
ExtensionInstallationStateStore.clearUninstalling(extensionId);
}
};
}
installOnDrop = (files: File[]) => {
logger.info("Install from D&D");
async function confirmUninstallExtension(extension: InstalledExtension): Promise<void> {
const displayName = extensionDisplayName(extension.manifest.name, extension.manifest.version);
const confirmed = await ConfirmDialog.confirm({
message: <p>Are you sure you want to uninstall extension <b>{displayName}</b>?</p>,
labelOk: "Yes",
labelCancel: "No",
});
return this.requestInstall(
files.map(file => ({
fileName: path.basename(file.path),
filePath: file.path,
}))
);
};
if (confirmed) {
await uninstallExtension(extension.id);
}
}
async preloadExtensions(requests: InstallRequest[], { showError = true } = {}) {
const preloadedRequests = requests.filter(request => request.data);
function getExtensionDestFolder(name: string) {
return path.join(ExtensionDiscovery.getInstance().localFolderPath, sanitizeExtensionName(name));
}
await Promise.all(
requests
.filter(request => !request.data && request.filePath)
.map(async request => {
function getExtensionPackageTemp(fileName = "") {
return path.join(os.tmpdir(), "lens-extensions", fileName);
}
async function readFileNotify(filePath: string, showError = true): Promise<Buffer | null> {
try {
const data = await fse.readFile(request.filePath);
request.data = data;
preloadedRequests.push(request);
return request;
} catch(error) {
return await fse.readFile(filePath);
} catch (error) {
if (showError) {
Notifications.error(`Error while reading "${request.filePath}": ${String(error)}`);
}
}
})
);
const message = getMessageFromError(error);
return preloadedRequests as InstallRequestPreloaded[];
logger.info(`[EXTENSION-INSTALL]: preloading ${filePath} has failed: ${message}`, { error });
Notifications.error(`Error while reading "${filePath}": ${message}`);
}
}
async validatePackage(filePath: string): Promise<LensExtensionManifest> {
return null;
}
async function validatePackage(filePath: string): Promise<LensExtensionManifest> {
const tarFiles = await listTarEntries(filePath);
// tarball from npm contains single root folder "package/*"
const firstFile = tarFiles[0];
if (!firstFile) {
if(!firstFile) {
throw new Error(`invalid extension bundle, ${manifestFilename} not found`);
}
@ -240,7 +150,7 @@ export class Extensions extends React.Component {
const packedInRootFolder = tarFiles.every(entry => entry.startsWith(rootFolder));
const manifestLocation = packedInRootFolder ? path.join(rootFolder, manifestFilename) : manifestFilename;
if (!tarFiles.includes(manifestLocation)) {
if(!tarFiles.includes(manifestLocation)) {
throw new Error(`invalid extension bundle, ${manifestFilename} not found`);
}
@ -250,119 +160,70 @@ export class Extensions extends React.Component {
parseJson: true,
});
if (!manifest.lens && !manifest.renderer) {
if (!manifest.main && !manifest.renderer) {
throw new Error(`${manifestFilename} must specify "main" and/or "renderer" fields`);
}
return manifest;
}
async createTempFilesAndValidate(requests: InstallRequestPreloaded[], { showErrors = true } = {}) {
const validatedRequests: InstallRequestValidated[] = [];
}
async function createTempFilesAndValidate({ fileName, dataP }: InstallRequest, disposer: ExtendableDisposer): Promise<InstallRequestValidated | null> {
// 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);
}
await fse.ensureDir(getExtensionPackageTemp());
// validate packages
await Promise.all(
requests.map(async req => {
const tempFile = this.getExtensionPackageTemp(req.fileName);
const tempFile = getExtensionPackageTemp(fileName);
disposer.push(() => fse.unlink(tempFile));
try {
const manifest = await this.validatePackage(tempFile);
const data = await dataP;
validatedRequests.push({
...req,
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) {
fse.unlink(tempFile).catch(() => null); // remove invalid temp package
const message = getMessageFromError(error);
if (showErrors) {
logger.info(`[EXTENSION-INSTALLATION]: installing ${fileName} has failed: ${message}`, { error });
Notifications.error(
<div className="flex column gaps">
<p>Installing <em>{req.fileName}</em> has failed, skipping.</p>
<p>Reason: <em>{String(error)}</em></p>
<p>Installing <em>{fileName}</em> has failed, skipping.</p>
<p>Reason: <em>{message}</em></p>
</div>
);
}
}
})
);
return validatedRequests;
}
return null;
}
async requestInstall(init: InstallRequest | InstallRequest[]) {
const requests = Array.isArray(init) ? init : [init];
const preloadedRequests = await this.preloadExtensions(requests);
const validatedRequests = await this.createTempFilesAndValidate(preloadedRequests);
async function unpackExtension(request: InstallRequestValidated, disposeDownloading?: Disposer) {
const { id, fileName, tempFile, manifest: { name, version } } = request;
// If there are no requests for installing, reset startingInstall state
if (validatedRequests.length === 0) {
this.startingInstall = false;
}
ExtensionInstallationStateStore.setInstalling(id);
disposeDownloading?.();
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(
<div className="InstallingExtensionNotification flex gaps align-center">
<div className="flex column gaps">
<p>Install extension <b>{name}@{version}</b>?</p>
<p>Description: <em>{description}</em></p>
<div className="remove-folder-warning" onClick={() => shell.openPath(extensionFolder)}>
<b>Warning:</b> <code>{extensionFolder}</code> will be removed before installation.
</div>
</div>
<Button autoFocus label="Install" onClick={() => {
removeNotification();
this.unpackExtension(install);
}}/>
</div>
);
}
}
}
async unpackExtension({ fileName, tempFile, manifest: { name, version } }: InstallRequestValidated) {
const displayName = extensionDisplayName(name, version);
const extensionId = path.join(ExtensionDiscovery.getInstance().nodeModulesPath, name, "package.json");
logger.info(`Unpacking extension ${displayName}`, { fileName, tempFile });
Extensions.installStates.set(extensionId, {
state: "installing",
displayName
});
this.startingInstall = false;
const extensionFolder = this.getExtensionDestFolder(name);
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(Function);
await fse.remove(unpackingTempFolder).catch(noop);
await fse.ensureDir(unpackingTempFolder);
await extractTar(tempFile, { cwd: unpackingTempFolder });
@ -378,77 +239,286 @@ export class Extensions extends React.Component {
await fse.ensureDir(extensionFolder);
await fse.move(unpackedRootFolder, extensionFolder, { overwrite: true });
} catch (error) {
Notifications.error(
<p>Installing extension <b>{displayName}</b> has failed: <em>{error}</em></p>
// 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(
<p>Extension <b>{displayName}</b> successfully installed!</p>
);
} catch (error) {
const message = getMessageFromError(error);
// Remove install state on install failure
if (Extensions.installStates.get(extensionId)?.state === "installing") {
Extensions.installStates.delete(extensionId);
}
logger.info(`[EXTENSION-INSTALLATION]: installing ${request.fileName} has failed: ${message}`, { error });
Notifications.error(<p>Installing extension <b>{displayName}</b> has failed: <em>{message}</em></p>);
} finally {
// Remove install state once finished
ExtensionInstallationStateStore.clearInstalling(id);
// clean up
fse.remove(unpackingTempFolder).catch(Function);
fse.unlink(tempFile).catch(Function);
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();
}
confirmUninstallExtension = (extension: InstalledExtension) => {
const displayName = extensionDisplayName(extension.manifest.name, extension.manifest.version);
if (version) {
if (!json.versions[version]) {
Notifications.error(<p>The <em>{name}</em> extension does not have a v{version}.</p>);
ConfirmDialog.open({
message: <p>Are you sure you want to uninstall extension <b>{displayName}</b>?</p>,
labelOk: "Yes",
labelCancel: "No",
ok: () => this.uninstallExtension(extension)
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: <p>Are you sure you want to install <b>{name}@{version}</b>?</p>,
labelCancel: "Cancel",
labelOk: "Install",
});
};
async uninstallExtension(extension: InstalledExtension) {
const displayName = extensionDisplayName(extension.manifest.name, extension.manifest.version);
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<void> {
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(
<div className="flex column gaps">
<b>Extension Install Collision:</b>
<p>The <em>{name}</em> extension is currently {curState.toLowerCase()}.</p>
<p>Will not proceed with this current install request.</p>
</div>
);
}
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(
<div className="InstallingExtensionNotification flex gaps align-center">
<div className="flex column gaps">
<p>Install extension <b>{name}@{version}</b>?</p>
<p>Description: <em>{description}</em></p>
<div className="remove-folder-warning" onClick={() => shell.openPath(extensionFolder)}>
<b>Warning:</b> {name}@{oldVersion} will be removed before installation.
</div>
</div>
<Button autoFocus label="Install" onClick={async () => {
removeNotification();
if (await uninstallExtension(validatedRequest.id)) {
await unpackExtension(validatedRequest, dispose);
} else {
dispose();
}
}} />
</div>,
{
onClose: dispose,
}
);
}
}
async function attemptInstalls(filePaths: string[]): Promise<void> {
const promises: Promise<void>[] = [];
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 {
Extensions.installStates.set(extension.id, {
state: "uninstalling",
displayName
// 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(<p>Installation has failed: <b>{message}</b></p>);
} 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 }
]
});
await ExtensionDiscovery.getInstance().uninstallExtension(extension);
} catch (error) {
Notifications.error(
<p>Uninstalling extension <b>{displayName}</b> has failed: <em>{error?.message ?? ""}</em></p>
if (!canceled) {
await attemptInstalls(filePaths);
}
}
@observer
export class Extensions extends React.Component {
private static installInputValidators = [
InputValidators.isUrl,
InputValidators.isPath,
InputValidators.isExtensionNameInstall,
];
private static installInputValidator: InputValidator = {
message: "Invalid URL, absolute path, or extension name",
validate: (value: string) => (
Extensions.installInputValidators.some(({ validate }) => validate(value))
),
};
@observable search = "";
@observable installPath = "";
@computed get searchedForExtensions() {
const searchText = this.search.toLowerCase();
return Array.from(ExtensionLoader.getInstance().userExtensions.values())
.filter(({ manifest: { name, description }}) => (
name.toLowerCase().includes(searchText)
|| description?.toLowerCase().includes(searchText)
));
}
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 {
if (curSize > prevSize) {
when(() => !ExtensionInstallationStateStore.anyInstalling)
.then(() => this.installPath = "");
}
} finally {
prevSize = curSize;
}
})
]);
}
renderNoExtensionsHelpText() {
if (this.search) {
return <p>No search results found</p>;
}
return (
<p>
There are no installed extensions.
See list of <a href="https://github.com/lensapp/lens-extensions/blob/main/README.md" target="_blank" rel="noreferrer">available extensions</a>.
</p>
);
// Remove uninstall state on uninstall failure
if (Extensions.installStates.get(extension.id)?.state === "uninstalling") {
Extensions.installStates.delete(extension.id);
}
}
}
renderExtensions() {
const { extensions, search } = this;
if (!extensions.length) {
renderNoExtensions() {
return (
<div className="no-extensions flex box gaps justify-center">
<Icon material="info"/>
<Icon material="info" />
<div>
{
search
? <p>No search results found</p>
: <p>There are no installed extensions. See list of <a href="https://github.com/lensapp/lens-extensions/blob/main/README.md" target="_blank" rel="noreferrer">available extensions</a>.</p>
}
{this.renderNoExtensionsHelpText()}
</div>
</div>
);
}
return extensions.map(extension => {
@autobind()
renderExtension(extension: InstalledExtension) {
const { id, isEnabled, manifest } = extension;
const { name, description, version } = manifest;
const isUninstalling = Extensions.installStates.get(id)?.state === "uninstalling";
const isUninstalling = ExtensionInstallationStateStore.isExtensionUninstalling(id);
return (
<div key={id} className="extension flex gaps align-center">
@ -458,37 +528,48 @@ export class Extensions extends React.Component {
<p>{description}</p>
</div>
<div className="actions">
{!isEnabled && (
<Button plain active disabled={isUninstalling} onClick={() => {
extension.isEnabled = true;
}}>Enable</Button>
)}
{isEnabled && (
<Button accent disabled={isUninstalling} onClick={() => {
extension.isEnabled = false;
}}>Disable</Button>
)}
<Button plain active disabled={isUninstalling} waiting={isUninstalling} onClick={() => {
this.confirmUninstallExtension(extension);
}}>Uninstall</Button>
{
isEnabled
? <Button accent disabled={isUninstalling} onClick={() => extension.isEnabled = false}>Disable</Button>
: <Button plain active disabled={isUninstalling} onClick={() => extension.isEnabled = true}>Enable</Button>
}
<Button
plain
active
disabled={isUninstalling}
waiting={isUninstalling}
onClick={() => confirmUninstallExtension(extension)}
>
Uninstall
</Button>
</div>
</div>
);
});
}
/**
* True if at least one extension is in installing state
*/
@computed get isInstalling() {
return [...Extensions.installStates.values()].some(extension => extension.state === "installing");
renderExtensions() {
if (!ExtensionDiscovery.getInstance().isLoaded) {
return <div className="spinner-wrapper"><Spinner /></div>;
}
const { searchedForExtensions } = this;
if (!searchedForExtensions.length) {
return this.renderNoExtensions();
}
return (
<>
{...searchedForExtensions.map(this.renderExtension)}
</>
);
}
render() {
const { installPath } = this;
return (
<DropFileInput onDropFiles={this.installOnDrop}>
<DropFileInput onDropFiles={installOnDrop}>
<PageLayout showOnTop className="Extensions" contentGaps={false}>
<h2>Lens Extensions</h2>
<div>
@ -502,19 +583,19 @@ export class Extensions extends React.Component {
<Input
className="box grow"
theme="round-black"
disabled={this.isInstalling}
placeholder={`Path or URL to an extension package (${Extensions.supportedFormats.join(", ")})`}
disabled={ExtensionInstallationStateStore.anyPreInstallingOrInstalling}
placeholder={`Name or file path or URL to an extension package (${supportedFormats.join(", ")})`}
showErrorsAsTooltip={{ preferredPositions: TooltipPosition.BOTTOM }}
validators={installPath ? Extensions.installPathValidator : undefined}
validators={installPath ? Extensions.installInputValidator : undefined}
value={installPath}
onChange={value => this.installPath = value}
onSubmit={this.installFromUrlOrPath}
onSubmit={() => installFromInput(this.installPath)}
iconLeft="link"
iconRight={
<Icon
interactive
material="folder"
onClick={prevDefault(this.installFromSelectFileDialog)}
onClick={prevDefault(installFromSelectFileDialog)}
tooltip="Browse"
/>
}
@ -523,9 +604,9 @@ export class Extensions extends React.Component {
<Button
primary
label="Install"
disabled={this.isInstalling || !Extensions.installPathValidator.validate(installPath)}
waiting={this.isInstalling}
onClick={this.installFromUrlOrPath}
disabled={ExtensionInstallationStateStore.anyPreInstallingOrInstalling || !Extensions.installInputValidator.validate(installPath)}
waiting={ExtensionInstallationStateStore.anyPreInstallingOrInstalling}
onClick={() => installFromInput(this.installPath)}
/>
<small className="hint">
<b>Pro-Tip</b>: you can also drag-n-drop tarball-file to this area
@ -539,11 +620,7 @@ export class Extensions extends React.Component {
value={this.search}
onChange={(value) => this.search = value}
/>
{
ExtensionDiscovery.getInstance().isLoaded
? this.renderExtensions()
: <div className="spinner-wrapper"><Spinner/></div>
}
{this.renderExtensions()}
</div>
</PageLayout>
</DropFileInput>

View File

@ -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<ButtonProps, {}> {
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<ButtonProps> = 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 (
<a {...btnProps} ref={e => this.link = e}>
{btnContent}
{label}
{children}
</a>
);
}
@ -56,7 +49,8 @@ export class Button extends React.PureComponent<ButtonProps, {}> {
// render as button
return (
<button type="button" {...btnProps} ref={e => this.button = e}>
{btnContent}
{label}
{children}
</button>
);
}

View File

@ -11,14 +11,18 @@ import { Icon } from "../icon";
export interface ConfirmDialogProps extends Partial<DialogProps> {
}
export interface ConfirmDialogParams {
ok?: () => void;
export interface ConfirmDialogParams extends ConfirmDialogBooleanParams {
ok?: () => any | Promise<any>;
cancel?: () => any | Promise<any>;
}
export interface ConfirmDialogBooleanParams {
labelOk?: ReactNode;
labelCancel?: ReactNode;
message?: ReactNode;
message: ReactNode;
icon?: ReactNode;
okButtonProps?: Partial<ButtonProps>
cancelButtonProps?: Partial<ButtonProps>
okButtonProps?: Partial<ButtonProps>;
cancelButtonProps?: Partial<ButtonProps>;
}
@observer
@ -33,19 +37,26 @@ export class ConfirmDialog extends React.Component<ConfirmDialogProps> {
ConfirmDialog.params = params;
}
static close() {
ConfirmDialog.isOpen = false;
static confirm(params: ConfirmDialogBooleanParams): Promise<boolean> {
return new Promise(resolve => {
ConfirmDialog.open({
ok: () => resolve(true),
cancel: () => resolve(false),
...params,
});
});
}
public defaultParams: ConfirmDialogParams = {
static defaultParams: Partial<ConfirmDialogParams> = {
ok: noop,
cancel: noop,
labelOk: "Ok",
labelCancel: "Cancel",
icon: <Icon big material="warning"/>,
};
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<ConfirmDialogProps> {
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() {

View File

@ -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<IChartInstallData> {
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<IChartInstallData> {
async loadValues(tabId: TabId, attempt = 0): Promise<void> {
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 });

View File

@ -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<LogTabData>) => 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);

View File

@ -26,23 +26,14 @@ export class Logs extends React.Component<Props> {
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<LogTabData>) {
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<Props> {
}, 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 = (
<div className="flex gaps">
<LogResourceSelector
tabId={this.tabId}
tabData={this.tabData}
save={this.save}
tabData={data}
save={newData => logTabStore.setData(this.tabId, { ...data, ...newData })}
reload={this.reload}
/>
<LogSearch
@ -115,10 +110,15 @@ export class Logs extends React.Component<Props> {
render() {
const logs = logStore.logs;
const data = logTabStore.getData(this.tabId);
if (!data) {
this.reload();
}
return (
<div className="PodLogs flex column">
{this.renderResourceSelector()}
{this.renderResourceSelector(data)}
<LogList
logs={logs}
id={this.tabId}
@ -128,8 +128,8 @@ export class Logs extends React.Component<Props> {
/>
<LogControls
logs={logs}
tabData={this.tabData}
save={this.save}
tabData={data}
save={newData => logTabStore.setData(this.tabId, { ...data, ...newData })}
reload={this.reload}
/>
</div>

View File

@ -315,6 +315,7 @@ export class Input extends React.Component<InputProps, State> {
rows: multiLine ? (rows || 1) : null,
ref: this.bindRef,
spellCheck: "false",
disabled,
});
const showErrors = errors.length > 0 && !valid && dirty;
const errorsInfo = (

View File

@ -47,6 +47,14 @@ export const isUrl: InputValidator = {
},
};
export const isExtensionNameInstallRegex = /^(?<name>(@[-\w]+\/)?[-\w]+)(@(?<version>\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`,

View File

@ -2,25 +2,11 @@ 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";
interface Props {
object: KubeObject;
}
export class KubeObjectStatusIcon extends React.Component<Props> {
@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 {
function statusClassName(level: number): string {
switch (level) {
case KubeObjectStatusLevel.INFO:
return "info";
@ -28,12 +14,10 @@ export class KubeObjectStatusIcon extends React.Component<Props> {
return "warning";
case KubeObjectStatusLevel.CRITICAL:
return "error";
default:
return "";
}
}
}
statusTitle(level: number): string {
function statusTitle(level: KubeObjectStatusLevel): string {
switch (level) {
case KubeObjectStatusLevel.INFO:
return "Info";
@ -41,66 +25,83 @@ export class KubeObjectStatusIcon extends React.Component<Props> {
return "Warning";
case KubeObjectStatusLevel.CRITICAL:
return "Critical";
default:
return "";
}
}
}
getAge(timestamp: string) {
if (!timestamp) return "";
const diff = Date.now() - new Date(timestamp).getTime();
function getAge(timestamp: string) {
return timestamp
? formatDuration(Date.now() - new Date(timestamp).getTime(), true)
: "";
}
return formatDuration(diff, 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<Props> {
renderStatuses(statuses: KubeObjectStatus[], level: number) {
const filteredStatuses = statuses.filter((item) => item.level == level);
return filteredStatuses.length > 0 && (
<div className={cssNames("level", this.statusClassName(level))}>
<div className={cssNames("level", statusClassName(level))}>
<span className="title">
{this.statusTitle(level)}
{statusTitle(level)}
</span>
{ filteredStatuses.map((status, index) =>{
return (
{
filteredStatuses.map((status, index) => (
<div key={`kube-resource-status-${level}-${index}`} className={cssNames("status", "msg")}>
- {status.text} <span className="age"> · { this.getAge(status.timestamp) }</span>
- {status.text} <span className="age"> · {getAge(status.timestamp)}</span>
</div>
);
})}
))
}
</div>
);
}
render() {
const { objectStatuses} = this;
const statuses = kubeObjectStatusRegistry.getItemsForObject(this.props.object);
if (!objectStatuses.length) return null;
const sortedStatuses = objectStatuses.sort((a: KubeObjectStatus, b: KubeObjectStatus) => {
if (a.level < b.level ) {
return 1;
if (statuses.length === 0) {
return null;
}
if (a.level > b.level ) {
return -1;
}
return 0;
});
const level = this.statusClassName(sortedStatuses[0].level);
const { maxLevel, criticals, warnings, infos } = splitByLevel(statuses);
return (
<Icon
material={level}
className={cssNames("KubeObjectStatusIcon", level)}
material={maxLevel}
className={cssNames("KubeObjectStatusIcon", maxLevel)}
tooltip={{
children: (
<div className="KubeObjectStatusTooltip">
{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)}
</div>
)
}}

View File

@ -24,13 +24,11 @@ export class KubeObjectMeta extends React.Component<KubeObjectMetaProps> {
}
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<KubeObjectMetaProps> {
{getAge(true, false)} ago ({<LocaleDate date={creationTimestamp} />})
</DrawerItem>
<DrawerItem name="Name" hidden={this.isHidden("name")}>
{getName()} <KubeObjectStatusIcon key="icon" object={object} />
{getName()}
<KubeObjectStatusIcon key="icon" object={object} />
</DrawerItem>
<DrawerItem name="Namespace" hidden={this.isHidden("namespace") || !getNs()}>
{getNs()}
@ -68,7 +67,7 @@ export class KubeObjectMeta extends React.Component<KubeObjectMetaProps> {
labels={getFinalizers()}
hidden={this.isHidden("finalizers")}
/>
{ownerRefs && ownerRefs.length > 0 &&
{ownerRefs?.length > 0 &&
<DrawerItem name="Controlled By" hidden={this.isHidden("ownerReferences")}>
{
ownerRefs.map(ref => {

View File

@ -2,7 +2,6 @@ import "./sidebar.scss";
import type { TabLayoutRoute } from "./tab-layout";
import React from "react";
import { computed } from "mobx";
import { observer } from "mobx-react";
import { NavLink } from "react-router-dom";
import { cssNames } from "../../utils";
@ -45,9 +44,13 @@ export class Sidebar extends React.Component<Props> {
crdStore.reloadAll();
}
@computed get crdSubMenus(): React.ReactNode {
if (!crdStore.isLoaded && crdStore.isLoading) {
return <Spinner centerHorizontal/>;
renderCustomResources() {
if (crdStore.isLoading) {
return (
<div className="flex justify-center">
<Spinner />
</div>
);
}
return Object.entries(crdStore.groups).map(([group, crds]) => {
@ -268,7 +271,7 @@ export class Sidebar extends React.Component<Props> {
icon={<Icon material="extension"/>}
>
{this.renderTreeFromTabRoutes(CustomResources.tabRoutes)}
{this.crdSubMenus}
{this.renderCustomResources()}
</SidebarItem>
{this.renderRegisteredMenus()}
</div>

View File

@ -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);

View File

@ -6,7 +6,6 @@ import { cssNames } from "../../utils";
export interface SpinnerProps extends React.HTMLProps<any> {
singleColor?: boolean;
center?: boolean;
centerHorizontal?: boolean;
}
export class Spinner extends React.Component<SpinnerProps, {}> {
@ -16,8 +15,8 @@ export class Spinner extends React.Component<SpinnerProps, {}> {
};
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 <div {...props} className={classNames} />;
}

View File

@ -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 });
});
}

View File

@ -1,36 +0,0 @@
// Allow to cancel request for window.fetch()
export interface CancelablePromise<T> extends Promise<T> {
then<TResult1 = T, TResult2 = never>(onfulfilled?: ((value: T) => TResult1 | PromiseLike<TResult1>) | undefined | null, onrejected?: ((reason: any) => TResult2 | PromiseLike<TResult2>) | undefined | null): CancelablePromise<TResult1 | TResult2>;
catch<TResult = never>(onrejected?: ((reason: any) => TResult | PromiseLike<TResult>) | undefined | null): CancelablePromise<T | TResult>;
finally(onfinally?: (() => void) | undefined | null): CancelablePromise<T>;
cancel(): void;
}
interface WrappingFunction {
<T>(result: Promise<T>): CancelablePromise<T>;
<T>(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<any> = 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);
}