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:
parent
7fde8125eb
commit
1f854d0a0f
@ -43,13 +43,17 @@ jest.mock("electron", () => {
|
|||||||
},
|
},
|
||||||
ipcMain: {
|
ipcMain: {
|
||||||
handle: jest.fn(),
|
handle: jest.fn(),
|
||||||
on: jest.fn()
|
on: jest.fn(),
|
||||||
|
removeAllListeners: jest.fn(),
|
||||||
|
off: jest.fn(),
|
||||||
|
send: jest.fn(),
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("empty config", () => {
|
describe("empty config", () => {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
|
ClusterStore.getInstance(false)?.unregisterIpcListener();
|
||||||
ClusterStore.resetInstance();
|
ClusterStore.resetInstance();
|
||||||
const mockOpts = {
|
const mockOpts = {
|
||||||
"tmp": {
|
"tmp": {
|
||||||
|
|||||||
@ -124,8 +124,8 @@ export abstract class BaseStore<T = any> extends Singleton {
|
|||||||
}
|
}
|
||||||
|
|
||||||
unregisterIpcListener() {
|
unregisterIpcListener() {
|
||||||
ipcRenderer.removeAllListeners(this.syncMainChannel);
|
ipcRenderer?.removeAllListeners(this.syncMainChannel);
|
||||||
ipcRenderer.removeAllListeners(this.syncRendererChannel);
|
ipcRenderer?.removeAllListeners(this.syncRendererChannel);
|
||||||
}
|
}
|
||||||
|
|
||||||
disableSync() {
|
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
|
* 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
|
* It is recommended that a round trip is valid. Namely, calling
|
||||||
* `this.fromStore(this.toJSON())` shouldn't change the state.
|
* `this.fromStore(this.toJSON())` shouldn't change the state.
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import path from "path";
|
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 { unlink } from "fs-extra";
|
||||||
import { action, comparer, computed, observable, reaction, toJS } from "mobx";
|
import { action, comparer, computed, observable, reaction, toJS } from "mobx";
|
||||||
import { BaseStore } from "./base-store";
|
import { BaseStore } from "./base-store";
|
||||||
@ -12,6 +12,7 @@ import { saveToAppFiles } from "./utils/saveToAppFiles";
|
|||||||
import { KubeConfig } from "@kubernetes/client-node";
|
import { KubeConfig } from "@kubernetes/client-node";
|
||||||
import { handleRequest, requestMain, subscribeToBroadcast, unsubscribeAllFromBroadcast } from "./ipc";
|
import { handleRequest, requestMain, subscribeToBroadcast, unsubscribeAllFromBroadcast } from "./ipc";
|
||||||
import { ResourceType } from "../renderer/components/cluster-settings/components/cluster-metrics-setting";
|
import { ResourceType } from "../renderer/components/cluster-settings/components/cluster-metrics-setting";
|
||||||
|
import { disposer, noop } from "./utils";
|
||||||
|
|
||||||
export interface ClusterIconUpload {
|
export interface ClusterIconUpload {
|
||||||
clusterId: string;
|
clusterId: string;
|
||||||
@ -111,6 +112,7 @@ export class ClusterStore extends BaseStore<ClusterStoreModel> {
|
|||||||
@observable clusters = observable.map<ClusterId, Cluster>();
|
@observable clusters = observable.map<ClusterId, Cluster>();
|
||||||
|
|
||||||
private static stateRequestChannel = "cluster:states";
|
private static stateRequestChannel = "cluster:states";
|
||||||
|
protected disposer = disposer();
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super({
|
super({
|
||||||
@ -143,7 +145,7 @@ export class ClusterStore extends BaseStore<ClusterStoreModel> {
|
|||||||
cluster.setState(clusterState.state);
|
cluster.setState(clusterState.state);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} else {
|
} else if (ipcMain) {
|
||||||
handleRequest(ClusterStore.stateRequestChannel, (): clusterStateSync[] => {
|
handleRequest(ClusterStore.stateRequestChannel, (): clusterStateSync[] => {
|
||||||
const states: clusterStateSync[] = [];
|
const states: clusterStateSync[] = [];
|
||||||
|
|
||||||
@ -160,13 +162,16 @@ export class ClusterStore extends BaseStore<ClusterStoreModel> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected pushStateToViewsAutomatically() {
|
protected pushStateToViewsAutomatically() {
|
||||||
if (!ipcRenderer) {
|
if (ipcMain) {
|
||||||
reaction(() => this.enabledClustersList, () => {
|
this.disposer.push(
|
||||||
this.pushState();
|
reaction(() => this.enabledClustersList, () => {
|
||||||
});
|
this.pushState();
|
||||||
reaction(() => this.connectedClustersList, () => {
|
}),
|
||||||
this.pushState();
|
reaction(() => this.connectedClustersList, () => {
|
||||||
});
|
this.pushState();
|
||||||
|
}),
|
||||||
|
() => unsubscribeAllFromBroadcast("cluster:state"),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -180,7 +185,7 @@ export class ClusterStore extends BaseStore<ClusterStoreModel> {
|
|||||||
|
|
||||||
unregisterIpcListener() {
|
unregisterIpcListener() {
|
||||||
super.unregisterIpcListener();
|
super.unregisterIpcListener();
|
||||||
unsubscribeAllFromBroadcast("cluster:state");
|
this.disposer();
|
||||||
}
|
}
|
||||||
|
|
||||||
pushState() {
|
pushState() {
|
||||||
@ -288,7 +293,7 @@ export class ClusterStore extends BaseStore<ClusterStoreModel> {
|
|||||||
|
|
||||||
// remove only custom kubeconfigs (pasted as text)
|
// remove only custom kubeconfigs (pasted as text)
|
||||||
if (cluster.kubeConfigPath == ClusterStore.getCustomKubeConfigPath(clusterId)) {
|
if (cluster.kubeConfigPath == ClusterStore.getCustomKubeConfigPath(clusterId)) {
|
||||||
unlink(cluster.kubeConfigPath).catch(() => null);
|
await unlink(cluster.kubeConfigPath).catch(noop);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -28,7 +28,7 @@ export async function broadcastMessage(channel: string, ...args: any[]) {
|
|||||||
|
|
||||||
if (ipcRenderer) {
|
if (ipcRenderer) {
|
||||||
ipcRenderer.send(channel, ...args);
|
ipcRenderer.send(channel, ...args);
|
||||||
} else {
|
} else if (ipcMain) {
|
||||||
ipcMain.emit(channel, ...args);
|
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) {
|
export function subscribeToBroadcast(channel: string, listener: (...args: any[]) => any) {
|
||||||
if (ipcRenderer) {
|
if (ipcRenderer) {
|
||||||
ipcRenderer.on(channel, listener);
|
ipcRenderer.on(channel, listener);
|
||||||
} else {
|
} else if (ipcMain) {
|
||||||
ipcMain.on(channel, listener);
|
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) {
|
export function unsubscribeFromBroadcast(channel: string, listener: (...args: any[]) => any) {
|
||||||
if (ipcRenderer) {
|
if (ipcRenderer) {
|
||||||
ipcRenderer.off(channel, listener);
|
ipcRenderer.off(channel, listener);
|
||||||
} else {
|
} else if (ipcMain) {
|
||||||
ipcMain.off(channel, listener);
|
ipcMain.off(channel, listener);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -73,7 +73,7 @@ export function unsubscribeFromBroadcast(channel: string, listener: (...args: an
|
|||||||
export function unsubscribeAllFromBroadcast(channel: string) {
|
export function unsubscribeAllFromBroadcast(channel: string) {
|
||||||
if (ipcRenderer) {
|
if (ipcRenderer) {
|
||||||
ipcRenderer.removeAllListeners(channel);
|
ipcRenderer.removeAllListeners(channel);
|
||||||
} else {
|
} else if (ipcMain) {
|
||||||
ipcMain.removeAllListeners(channel);
|
ipcMain.removeAllListeners(channel);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -23,8 +23,8 @@ export const ProtocolHandlerExtension= `${ProtocolHandlerIpcPrefix}:extension`;
|
|||||||
* Though under the current (2021/01/18) implementation, these are never matched
|
* 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.
|
* against in the final matching so their names are less of a concern.
|
||||||
*/
|
*/
|
||||||
const EXTENSION_PUBLISHER_MATCH = "LENS_INTERNAL_EXTENSION_PUBLISHER_MATCH";
|
export const EXTENSION_PUBLISHER_MATCH = "LENS_INTERNAL_EXTENSION_PUBLISHER_MATCH";
|
||||||
const EXTENSION_NAME_MATCH = "LENS_INTERNAL_EXTENSION_NAME_MATCH";
|
export const EXTENSION_NAME_MATCH = "LENS_INTERNAL_EXTENSION_NAME_MATCH";
|
||||||
|
|
||||||
export abstract class LensProtocolRouter extends Singleton {
|
export abstract class LensProtocolRouter extends Singleton {
|
||||||
// Map between path schemas and the handlers
|
// Map between path schemas and the handlers
|
||||||
@ -32,7 +32,7 @@ export abstract class LensProtocolRouter extends Singleton {
|
|||||||
|
|
||||||
public static readonly LoggingPrefix = "[PROTOCOL ROUTER]";
|
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}`;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
|
|||||||
20
src/common/utils/disposer.ts
Normal file
20
src/common/utils/disposer.ts
Normal 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;
|
||||||
|
}
|
||||||
@ -6,13 +6,13 @@ export interface DownloadFileOptions {
|
|||||||
timeout?: number;
|
timeout?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DownloadFileTicket {
|
export interface DownloadFileTicket<T> {
|
||||||
url: string;
|
url: string;
|
||||||
promise: Promise<Buffer>;
|
promise: Promise<T>;
|
||||||
cancel(): void;
|
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 fileChunks: Buffer[] = [];
|
||||||
const req = request(url, { gzip, timeout });
|
const req = request(url, { gzip, timeout });
|
||||||
const promise: Promise<Buffer> = new Promise((resolve, reject) => {
|
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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@ -19,6 +19,8 @@ export * from "./downloadFile";
|
|||||||
export * from "./escapeRegExp";
|
export * from "./escapeRegExp";
|
||||||
export * from "./tar";
|
export * from "./tar";
|
||||||
export * from "./type-narrowing";
|
export * from "./type-narrowing";
|
||||||
|
export * from "./disposer";
|
||||||
|
|
||||||
import * as iter from "./iter";
|
import * as iter from "./iter";
|
||||||
|
|
||||||
export { iter };
|
export { iter };
|
||||||
|
|||||||
@ -1,13 +1,89 @@
|
|||||||
/**
|
/**
|
||||||
* Narrows `val` to include the property `key` (if true is returned)
|
* Narrows `val` to include the property `key` (if true is returned)
|
||||||
* @param val The object to be tested
|
* @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)`
|
// this call syntax is for when `val` was created by `Object.create(null)`
|
||||||
return Object.prototype.hasOwnProperty.call(val, key);
|
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));
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
// App's common configuration for any process (main, renderer, build pipeline, etc.)
|
// App's common configuration for any process (main, renderer, build pipeline, etc.)
|
||||||
import path from "path";
|
import path from "path";
|
||||||
|
import { SemVer } from "semver";
|
||||||
import packageInfo from "../../package.json";
|
import packageInfo from "../../package.json";
|
||||||
import { defineGlobal } from "./utils/defineGlobal";
|
import { defineGlobal } from "./utils/defineGlobal";
|
||||||
|
|
||||||
@ -44,5 +45,11 @@ export const apiKubePrefix = "/api-kube"; // k8s cluster apis
|
|||||||
// Links
|
// Links
|
||||||
export const issuesTrackerUrl = "https://github.com/lensapp/lens/issues";
|
export const issuesTrackerUrl = "https://github.com/lensapp/lens/issues";
|
||||||
export const slackUrl = "https://join.slack.com/t/k8slens/shared_invite/enQtOTc5NjAyNjYyOTk4LWU1NDQ0ZGFkOWJkNTRhYTc2YjVmZDdkM2FkNGM5MjhiYTRhMDU2NDQ1MzIyMDA4ZGZlNmExOTc0N2JmY2M3ZGI";
|
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/";
|
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}`;
|
||||||
|
|||||||
@ -1,10 +1,14 @@
|
|||||||
|
import mockFs from "mock-fs";
|
||||||
import { watch } from "chokidar";
|
import { watch } from "chokidar";
|
||||||
import { join, normalize } from "path";
|
|
||||||
import { ExtensionDiscovery, InstalledExtension } from "../extension-discovery";
|
|
||||||
import { ExtensionsStore } from "../extensions-store";
|
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("../../common/ipc");
|
||||||
jest.mock("fs-extra");
|
|
||||||
jest.mock("chokidar", () => ({
|
jest.mock("chokidar", () => ({
|
||||||
watch: jest.fn()
|
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>;
|
const mockedWatch = watch as jest.MockedFunction<typeof watch>;
|
||||||
|
|
||||||
describe("ExtensionDiscovery", () => {
|
describe("ExtensionDiscovery", () => {
|
||||||
@ -24,47 +29,59 @@ describe("ExtensionDiscovery", () => {
|
|||||||
ExtensionsStore.createInstance();
|
ExtensionsStore.createInstance();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("emits add for added extension", async done => {
|
describe("with mockFs", () => {
|
||||||
globalThis.__non_webpack_require__.mockImplementation(() => ({
|
beforeEach(() => {
|
||||||
name: "my-extension"
|
mockFs({
|
||||||
}));
|
[`${os.homedir()}/.k8slens/extensions/my-extension/package.json`]: JSON.stringify({
|
||||||
let addHandler: (filePath: string) => void;
|
name: "my-extension"
|
||||||
|
}),
|
||||||
const mockWatchInstance: any = {
|
|
||||||
on: jest.fn((event: string, handler: typeof addHandler) => {
|
|
||||||
if (event === "add") {
|
|
||||||
addHandler = handler;
|
|
||||||
}
|
|
||||||
|
|
||||||
return mockWatchInstance;
|
|
||||||
})
|
|
||||||
};
|
|
||||||
|
|
||||||
mockedWatch.mockImplementationOnce(() =>
|
|
||||||
(mockWatchInstance) as any
|
|
||||||
);
|
|
||||||
const extensionDiscovery = ExtensionDiscovery.createInstance();
|
|
||||||
|
|
||||||
// Need to force isLoaded to be true so that the file watching is started
|
|
||||||
extensionDiscovery.isLoaded = true;
|
|
||||||
|
|
||||||
await extensionDiscovery.watchExtensions();
|
|
||||||
|
|
||||||
extensionDiscovery.events.on("add", (extension: InstalledExtension) => {
|
|
||||||
expect(extension).toEqual({
|
|
||||||
absolutePath: expect.any(String),
|
|
||||||
id: normalize("node_modules/my-extension/package.json"),
|
|
||||||
isBundled: false,
|
|
||||||
isEnabled: false,
|
|
||||||
manifest: {
|
|
||||||
name: "my-extension",
|
|
||||||
},
|
|
||||||
manifestPath: normalize("node_modules/my-extension/package.json"),
|
|
||||||
});
|
});
|
||||||
done();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
addHandler(join(extensionDiscovery.localFolderPath, "/my-extension/package.json"));
|
afterEach(() => {
|
||||||
|
mockFs.restore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits add for added extension", async (done) => {
|
||||||
|
let addHandler: (filePath: string) => void;
|
||||||
|
|
||||||
|
const mockWatchInstance: any = {
|
||||||
|
on: jest.fn((event: string, handler: typeof addHandler) => {
|
||||||
|
if (event === "add") {
|
||||||
|
addHandler = handler;
|
||||||
|
}
|
||||||
|
|
||||||
|
return mockWatchInstance;
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
mockedWatch.mockImplementationOnce(() =>
|
||||||
|
(mockWatchInstance) as any
|
||||||
|
);
|
||||||
|
|
||||||
|
const extensionDiscovery = ExtensionDiscovery.createInstance();
|
||||||
|
|
||||||
|
// Need to force isLoaded to be true so that the file watching is started
|
||||||
|
extensionDiscovery.isLoaded = true;
|
||||||
|
|
||||||
|
await extensionDiscovery.watchExtensions();
|
||||||
|
|
||||||
|
extensionDiscovery.events.on("add", extension => {
|
||||||
|
expect(extension).toEqual({
|
||||||
|
absolutePath: expect.any(String),
|
||||||
|
id: path.normalize("node_modules/my-extension/package.json"),
|
||||||
|
isBundled: false,
|
||||||
|
isEnabled: false,
|
||||||
|
manifest: {
|
||||||
|
name: "my-extension",
|
||||||
|
},
|
||||||
|
manifestPath: path.normalize("node_modules/my-extension/package.json"),
|
||||||
|
});
|
||||||
|
done();
|
||||||
|
});
|
||||||
|
|
||||||
|
addHandler(path.join(extensionDiscovery.localFolderPath, "/my-extension/package.json"));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("doesn't emit add for added file under extension", async done => {
|
it("doesn't emit add for added file under extension", async done => {
|
||||||
@ -94,7 +111,7 @@ describe("ExtensionDiscovery", () => {
|
|||||||
|
|
||||||
extensionDiscovery.events.on("add", onAdd);
|
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(() => {
|
setTimeout(() => {
|
||||||
expect(onAdd).not.toHaveBeenCalled();
|
expect(onAdd).not.toHaveBeenCalled();
|
||||||
|
|||||||
@ -1,31 +1,33 @@
|
|||||||
import { watch } from "chokidar";
|
import { watch } from "chokidar";
|
||||||
import { ipcRenderer } from "electron";
|
import { ipcRenderer } from "electron";
|
||||||
import { EventEmitter } from "events";
|
import { EventEmitter } from "events";
|
||||||
import fs from "fs-extra";
|
import fse from "fs-extra";
|
||||||
import { observable, reaction, toJS, when } from "mobx";
|
import { observable, reaction, toJS, when } from "mobx";
|
||||||
import os from "os";
|
import os from "os";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import { broadcastMessage, handleRequest, requestMain, subscribeToBroadcast } from "../common/ipc";
|
import { broadcastMessage, handleRequest, requestMain, subscribeToBroadcast } from "../common/ipc";
|
||||||
import { Singleton } from "../common/utils";
|
import { Singleton } from "../common/utils";
|
||||||
import logger from "../main/logger";
|
import logger from "../main/logger";
|
||||||
|
import { ExtensionInstallationStateStore } from "../renderer/components/+extensions/extension-install.store";
|
||||||
import { extensionInstaller, PackageJson } from "./extension-installer";
|
import { extensionInstaller, PackageJson } from "./extension-installer";
|
||||||
import { ExtensionsStore } from "./extensions-store";
|
import { ExtensionsStore } from "./extensions-store";
|
||||||
|
import { ExtensionLoader } from "./extension-loader";
|
||||||
import type { LensExtensionId, LensExtensionManifest } from "./lens-extension";
|
import type { LensExtensionId, LensExtensionManifest } from "./lens-extension";
|
||||||
|
|
||||||
export interface InstalledExtension {
|
export interface InstalledExtension {
|
||||||
id: LensExtensionId;
|
id: LensExtensionId;
|
||||||
|
|
||||||
readonly manifest: LensExtensionManifest;
|
readonly manifest: LensExtensionManifest;
|
||||||
|
|
||||||
// Absolute path to the non-symlinked source folder,
|
// Absolute path to the non-symlinked source folder,
|
||||||
// e.g. "/Users/user/.k8slens/extensions/helloworld"
|
// e.g. "/Users/user/.k8slens/extensions/helloworld"
|
||||||
readonly absolutePath: string;
|
readonly absolutePath: string;
|
||||||
|
|
||||||
// Absolute to the symlinked package.json file
|
// Absolute to the symlinked package.json file
|
||||||
readonly manifestPath: string;
|
readonly manifestPath: string;
|
||||||
readonly isBundled: boolean; // defined in project root's package.json
|
readonly isBundled: boolean; // defined in project root's package.json
|
||||||
isEnabled: boolean;
|
isEnabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const logModule = "[EXTENSION-DISCOVERY]";
|
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)
|
* Returns true if the lstat is for a directory-like file (e.g. isDirectory or symbolic link)
|
||||||
* @param lstat the stats to compare
|
* @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.
|
* 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
|
// IPC channel to broadcast changes to extension-discovery from main
|
||||||
protected static readonly extensionDiscoveryChannel = "extension-discovery:main";
|
protected static readonly extensionDiscoveryChannel = "extension-discovery:main";
|
||||||
|
|
||||||
public events: EventEmitter;
|
public events = new EventEmitter();
|
||||||
|
|
||||||
constructor() {
|
|
||||||
super();
|
|
||||||
this.events = new EventEmitter();
|
|
||||||
}
|
|
||||||
|
|
||||||
get localFolderPath(): string {
|
get localFolderPath(): string {
|
||||||
return path.join(os.homedir(), ".k8slens", "extensions");
|
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
|
// Extension add is detected by watching "<extensionDir>/package.json" add
|
||||||
.on("add", this.handleWatchFileAdd)
|
.on("add", this.handleWatchFileAdd)
|
||||||
// Extension remove is detected by watching <extensionDir>" unlink
|
// Extension remove is detected by watching "<extensionDir>" unlink
|
||||||
.on("unlinkDir", this.handleWatchUnlinkDir);
|
.on("unlinkDir", this.handleWatchUnlinkEvent)
|
||||||
|
// Extension remove is detected by watching "<extensionSymLink>" unlink
|
||||||
|
.on("unlink", this.handleWatchUnlinkEvent);
|
||||||
}
|
}
|
||||||
|
|
||||||
handleWatchFileAdd = async (manifestPath: string) => {
|
handleWatchFileAdd = async (manifestPath: string) => {
|
||||||
@ -161,6 +160,7 @@ export class ExtensionDiscovery extends Singleton {
|
|||||||
|
|
||||||
if (path.basename(manifestPath) === manifestFilename && isUnderLocalFolderPath) {
|
if (path.basename(manifestPath) === manifestFilename && isUnderLocalFolderPath) {
|
||||||
try {
|
try {
|
||||||
|
ExtensionInstallationStateStore.setInstallingFromMain(manifestPath);
|
||||||
const absPath = path.dirname(manifestPath);
|
const absPath = path.dirname(manifestPath);
|
||||||
|
|
||||||
// this.loadExtensionFromPath updates this.packagesJson
|
// this.loadExtensionFromPath updates this.packagesJson
|
||||||
@ -168,7 +168,7 @@ export class ExtensionDiscovery extends Singleton {
|
|||||||
|
|
||||||
if (extension) {
|
if (extension) {
|
||||||
// Remove a broken symlink left by a previous installation if it exists.
|
// 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
|
// Install dependencies for the new extension
|
||||||
await this.installPackage(extension.absolutePath);
|
await this.installPackage(extension.absolutePath);
|
||||||
@ -178,40 +178,46 @@ export class ExtensionDiscovery extends Singleton {
|
|||||||
this.events.emit("add", extension);
|
this.events.emit("add", extension);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} 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
|
* Handle any unlink event, filtering out non-package.json links so the delete code
|
||||||
// this.packagesJson.dependencies value is the non-symlinked path to the extension folder
|
* only happens once per extension.
|
||||||
// LensExtensionId in extension-loader is the symlinked path to the extension folder manifest file
|
* @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
|
// Check that the removed path is directly under this.localFolderPath
|
||||||
// Note that the watcher can create unlink events for subdirectories of the extension
|
// Note that the watcher can create unlink events for subdirectories of the extension
|
||||||
const extensionFolderName = path.basename(filePath);
|
const extensionFolderName = path.basename(filePath);
|
||||||
|
const expectedPath = path.relative(this.localFolderPath, filePath);
|
||||||
|
|
||||||
if (path.relative(this.localFolderPath, filePath) === extensionFolderName) {
|
if (expectedPath !== extensionFolderName) {
|
||||||
const extension = Array.from(this.extensions.values()).find((extension) => extension.absolutePath === filePath);
|
return;
|
||||||
|
|
||||||
if (extension) {
|
|
||||||
const extensionName = extension.manifest.name;
|
|
||||||
|
|
||||||
// If the extension is deleted manually while the application is running, also remove the symlink
|
|
||||||
await this.removeSymlinkByPackageName(extensionName);
|
|
||||||
|
|
||||||
// The path to the manifest file is the lens extension id
|
|
||||||
// Note that we need to use the symlinked path
|
|
||||||
const lensExtensionId = extension.manifestPath;
|
|
||||||
|
|
||||||
this.extensions.delete(extension.id);
|
|
||||||
logger.info(`${logModule} removed extension ${extensionName}`);
|
|
||||||
this.events.emit("remove", lensExtensionId as LensExtensionId);
|
|
||||||
} else {
|
|
||||||
logger.warn(`${logModule} extension ${extensionFolderName} not found, can't remove`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const extension = Array.from(this.extensions.values()).find((extension) => extension.absolutePath === filePath);
|
||||||
|
|
||||||
|
if (!extension) {
|
||||||
|
return void logger.warn(`${logModule} extension ${extensionFolderName} not found, can't remove`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const extensionName = extension.manifest.name;
|
||||||
|
|
||||||
|
// If the extension is deleted manually while the application is running, also remove the symlink
|
||||||
|
await this.removeSymlinkByPackageName(extensionName);
|
||||||
|
|
||||||
|
// The path to the manifest file is the lens extension id
|
||||||
|
// Note: that we need to use the symlinked path
|
||||||
|
const lensExtensionId = extension.manifestPath;
|
||||||
|
|
||||||
|
this.extensions.delete(extension.id);
|
||||||
|
logger.info(`${logModule} removed extension ${extensionName}`);
|
||||||
|
this.events.emit("remove", lensExtensionId);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -221,31 +227,23 @@ export class ExtensionDiscovery extends Singleton {
|
|||||||
* @param name e.g. "@mirantis/lens-extension-cc"
|
* @param name e.g. "@mirantis/lens-extension-cc"
|
||||||
*/
|
*/
|
||||||
removeSymlinkByPackageName(name: string) {
|
removeSymlinkByPackageName(name: string) {
|
||||||
return fs.remove(this.getInstalledPath(name));
|
return fse.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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Uninstalls extension.
|
* Uninstalls extension.
|
||||||
* The application will detect the folder unlink and remove the extension from the UI automatically.
|
* 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}`);
|
logger.info(`${logModule} Uninstalling ${manifest.name}`);
|
||||||
|
|
||||||
await this.removeSymlinkByPackageName(manifest.name);
|
await this.removeSymlinkByPackageName(manifest.name);
|
||||||
|
|
||||||
// fs.remove does nothing if the path doesn't exist anymore
|
// 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>> {
|
async load(): Promise<Map<LensExtensionId, InstalledExtension>> {
|
||||||
@ -259,12 +257,11 @@ export class ExtensionDiscovery extends Singleton {
|
|||||||
logger.info(`${logModule} loading extensions from ${extensionInstaller.extensionPackagesRoot}`);
|
logger.info(`${logModule} loading extensions from ${extensionInstaller.extensionPackagesRoot}`);
|
||||||
|
|
||||||
// fs.remove won't throw if path is missing
|
// 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 {
|
try {
|
||||||
// Verify write access to static/extensions, which is needed for symlinking
|
// 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
|
// Set bundled folder path to static/extensions
|
||||||
this.bundledFolderPath = this.inTreeFolderPath;
|
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.
|
// 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
|
// 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
|
// 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
|
// 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
|
// Set bundled folder path to e.g. /Users/<username>/Library/Application Support/LensDev/extensions
|
||||||
this.bundledFolderPath = this.inTreeTargetPath;
|
this.bundledFolderPath = this.inTreeTargetPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
await fs.ensureDir(this.nodeModulesPath);
|
await fse.ensureDir(this.nodeModulesPath);
|
||||||
await fs.ensureDir(this.localFolderPath);
|
await fse.ensureDir(this.localFolderPath);
|
||||||
|
|
||||||
const extensions = await this.ensureExtensions();
|
const extensions = await this.ensureExtensions();
|
||||||
|
|
||||||
@ -315,30 +312,22 @@ export class ExtensionDiscovery extends Singleton {
|
|||||||
* Returns InstalledExtension from path to package.json file.
|
* Returns InstalledExtension from path to package.json file.
|
||||||
* Also updates this.packagesJson.
|
* Also updates this.packagesJson.
|
||||||
*/
|
*/
|
||||||
protected async getByManifest(manifestPath: string, { isBundled = false }: {
|
protected async getByManifest(manifestPath: string, { isBundled = false } = {}): Promise<InstalledExtension | null> {
|
||||||
isBundled?: boolean;
|
|
||||||
} = {}): Promise<InstalledExtension | null> {
|
|
||||||
let manifestJson: LensExtensionManifest;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// check manifest file for existence
|
const manifest = await fse.readJson(manifestPath);
|
||||||
fs.accessSync(manifestPath, fs.constants.F_OK);
|
const installedManifestPath = this.getInstalledManifestPath(manifest.name);
|
||||||
|
const isEnabled = isBundled || ExtensionsStore.getInstance().isEnabled(installedManifestPath);
|
||||||
manifestJson = __non_webpack_require__(manifestPath);
|
|
||||||
const installedManifestPath = this.getInstalledManifestPath(manifestJson.name);
|
|
||||||
|
|
||||||
const isEnabled = isBundled || ExtensionsStore.getInstance().isEnabled(installedManifestPath);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: installedManifestPath,
|
id: installedManifestPath,
|
||||||
absolutePath: path.dirname(manifestPath),
|
absolutePath: path.dirname(manifestPath),
|
||||||
manifestPath: installedManifestPath,
|
manifestPath: installedManifestPath,
|
||||||
manifest: manifestJson,
|
manifest,
|
||||||
isBundled,
|
isBundled,
|
||||||
isEnabled
|
isEnabled
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} 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;
|
return null;
|
||||||
}
|
}
|
||||||
@ -352,7 +341,7 @@ export class ExtensionDiscovery extends Singleton {
|
|||||||
const userExtensions = await this.loadFromFolder(this.localFolderPath, bundledExtensions.map((extension) => extension.manifest.name));
|
const userExtensions = await this.loadFromFolder(this.localFolderPath, bundledExtensions.map((extension) => extension.manifest.name));
|
||||||
|
|
||||||
for (const extension of userExtensions) {
|
for (const extension of userExtensions) {
|
||||||
if (await fs.pathExists(extension.manifestPath) === false) {
|
if (await fse.pathExists(extension.manifestPath) === false) {
|
||||||
await this.installPackage(extension.absolutePath);
|
await this.installPackage(extension.absolutePath);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -383,7 +372,7 @@ export class ExtensionDiscovery extends Singleton {
|
|||||||
async loadBundledExtensions() {
|
async loadBundledExtensions() {
|
||||||
const extensions: InstalledExtension[] = [];
|
const extensions: InstalledExtension[] = [];
|
||||||
const folderPath = this.bundledFolderPath;
|
const folderPath = this.bundledFolderPath;
|
||||||
const paths = await fs.readdir(folderPath);
|
const paths = await fse.readdir(folderPath);
|
||||||
|
|
||||||
for (const fileName of paths) {
|
for (const fileName of paths) {
|
||||||
const absPath = path.resolve(folderPath, fileName);
|
const absPath = path.resolve(folderPath, fileName);
|
||||||
@ -400,7 +389,7 @@ export class ExtensionDiscovery extends Singleton {
|
|||||||
|
|
||||||
async loadFromFolder(folderPath: string, bundledExtensions: string[]): Promise<InstalledExtension[]> {
|
async loadFromFolder(folderPath: string, bundledExtensions: string[]): Promise<InstalledExtension[]> {
|
||||||
const extensions: InstalledExtension[] = [];
|
const extensions: InstalledExtension[] = [];
|
||||||
const paths = await fs.readdir(folderPath);
|
const paths = await fse.readdir(folderPath);
|
||||||
|
|
||||||
for (const fileName of paths) {
|
for (const fileName of paths) {
|
||||||
// do not allow to override bundled extensions
|
// do not allow to override bundled extensions
|
||||||
@ -410,11 +399,11 @@ export class ExtensionDiscovery extends Singleton {
|
|||||||
|
|
||||||
const absPath = path.resolve(folderPath, fileName);
|
const absPath = path.resolve(folderPath, fileName);
|
||||||
|
|
||||||
if (!fs.existsSync(absPath)) {
|
if (!fse.existsSync(absPath)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const lstat = await fs.lstat(absPath);
|
const lstat = await fse.lstat(absPath);
|
||||||
|
|
||||||
// skip non-directories
|
// skip non-directories
|
||||||
if (!isDirectoryLike(lstat)) {
|
if (!isDirectoryLike(lstat)) {
|
||||||
|
|||||||
@ -13,8 +13,6 @@ import type { LensExtension, LensExtensionConstructor, LensExtensionId } from ".
|
|||||||
import type { LensMainExtension } from "./lens-main-extension";
|
import type { LensMainExtension } from "./lens-main-extension";
|
||||||
import type { LensRendererExtension } from "./lens-renderer-extension";
|
import type { LensRendererExtension } from "./lens-renderer-extension";
|
||||||
import * as registries from "./registries";
|
import * as registries from "./registries";
|
||||||
import fs from "fs";
|
|
||||||
|
|
||||||
|
|
||||||
export function extensionPackagesRoot() {
|
export function extensionPackagesRoot() {
|
||||||
return path.join((app || remote.app).getPath("userData"));
|
return path.join((app || remote.app).getPath("userData"));
|
||||||
@ -290,28 +288,20 @@ export class ExtensionLoader extends Singleton {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
protected requireExtension(extension: InstalledExtension): LensExtensionConstructor {
|
protected requireExtension(extension: InstalledExtension): LensExtensionConstructor | null {
|
||||||
let extEntrypoint = "";
|
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 {
|
try {
|
||||||
if (ipcRenderer && extension.manifest.renderer) {
|
return __non_webpack_require__(extAbsolutePath).default;
|
||||||
extEntrypoint = path.resolve(path.join(path.dirname(extension.manifestPath), extension.manifest.renderer));
|
} catch (error) {
|
||||||
} else if (!ipcRenderer && extension.manifest.main) {
|
logger.error(`${logModule}: can't load extension main at ${extAbsolutePath}: ${error}`, { extension, error });
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -9,9 +9,17 @@ export interface KubeObjectStatusRegistration {
|
|||||||
|
|
||||||
export class KubeObjectStatusRegistry extends BaseRegistry<KubeObjectStatusRegistration> {
|
export class KubeObjectStatusRegistry extends BaseRegistry<KubeObjectStatusRegistration> {
|
||||||
getItemsForKind(kind: string, apiVersion: string) {
|
getItemsForKind(kind: string, apiVersion: string) {
|
||||||
return this.getItems().filter((item) => {
|
return this.getItems()
|
||||||
return item.kind === kind && item.apiVersions.includes(apiVersion);
|
.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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { app, remote } from "electron";
|
import { app, remote } from "electron";
|
||||||
import winston from "winston";
|
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 logLevel = process.env.LOG_LEVEL ? process.env.LOG_LEVEL : isDebugging ? "debug" : "info";
|
||||||
const consoleOptions: winston.transports.ConsoleTransportOptions = {
|
const consoleOptions: winston.transports.ConsoleTransportOptions = {
|
||||||
@ -23,7 +23,7 @@ const logger = winston.createLogger({
|
|||||||
),
|
),
|
||||||
transports: [
|
transports: [
|
||||||
new winston.transports.Console(consoleOptions),
|
new winston.transports.Console(consoleOptions),
|
||||||
new winston.transports.File(fileOptions),
|
...(isTestEnv ? [] : [new winston.transports.File(fileOptions)]),
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
228
src/renderer/api/__tests__/kube-object.test.ts
Normal file
228
src/renderer/api/__tests__/kube-object.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -16,39 +16,51 @@ const endpoint = compile(`/v2/charts/:repo?/:name?`) as (params?: {
|
|||||||
name?: string;
|
name?: string;
|
||||||
}) => string;
|
}) => string;
|
||||||
|
|
||||||
export const helmChartsApi = {
|
/**
|
||||||
list() {
|
* Get a list of all helm charts from all saved helm repos
|
||||||
return apiBase
|
*/
|
||||||
.get<HelmChartList>(endpoint())
|
export async function listCharts(): Promise<HelmChart[]> {
|
||||||
.then(data => {
|
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) {
|
return Object
|
||||||
const path = endpoint({ repo, name });
|
.values(data)
|
||||||
|
.reduce((allCharts, repoCharts) => allCharts.concat(Object.values(repoCharts)), [])
|
||||||
|
.map(([chart]) => HelmChart.create(chart));
|
||||||
|
}
|
||||||
|
|
||||||
return apiBase
|
export interface GetChartDetailsOptions {
|
||||||
.get<IHelmChartDetails>(`${path}?${stringify({ version: readmeVersion })}`)
|
version?: string;
|
||||||
.then(data => {
|
reqInit?: RequestInit;
|
||||||
const versions = data.versions.map(HelmChart.create);
|
}
|
||||||
const readme = data.readme;
|
|
||||||
|
|
||||||
return {
|
/**
|
||||||
readme,
|
* Get the readme and all versions of a chart
|
||||||
versions,
|
* @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 });
|
||||||
|
|
||||||
getValues(repo: string, name: string, version: string) {
|
const { readme, ...data } = await apiBase.get<IHelmChartDetails>(`${path}?${stringify({ version })}`, undefined, reqInit);
|
||||||
return apiBase
|
const versions = data.versions.map(HelmChart.create);
|
||||||
.get<string>(`/v2/charts/${repo}/${name}/values?${stringify({ version })}`);
|
|
||||||
}
|
return {
|
||||||
};
|
readme,
|
||||||
|
versions,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get chart values related to a specific repos' version of a chart
|
||||||
|
* @param repo The repo to get from
|
||||||
|
* @param name The name of the chart to request the data of
|
||||||
|
* @param version The version to get the values from
|
||||||
|
*/
|
||||||
|
export async function getChartValues(repo: string, name: string, version: string): Promise<string> {
|
||||||
|
return apiBase.get<string>(`/v2/charts/${repo}/${name}/values?${stringify({ version })}`);
|
||||||
|
}
|
||||||
|
|
||||||
@autobind()
|
@autobind()
|
||||||
export class HelmChart {
|
export class HelmChart {
|
||||||
|
|||||||
@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
import { stringify } from "querystring";
|
import { stringify } from "querystring";
|
||||||
import { EventEmitter } from "../../common/event-emitter";
|
import { EventEmitter } from "../../common/event-emitter";
|
||||||
import { cancelableFetch } from "../utils/cancelableFetch";
|
|
||||||
import { randomBytes } from "crypto";
|
import { randomBytes } from "crypto";
|
||||||
|
|
||||||
export interface JsonApiData {
|
export interface JsonApiData {
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -72,13 +72,11 @@ export class JsonApi<D = JsonApiData, P extends JsonApiParams = JsonApiParams> {
|
|||||||
reqUrl += (reqUrl.includes("?") ? "&" : "?") + queryString;
|
reqUrl += (reqUrl.includes("?") ? "&" : "?") + queryString;
|
||||||
}
|
}
|
||||||
|
|
||||||
const infoLog: JsonApiLog = {
|
this.writeLog({
|
||||||
method: reqInit.method.toUpperCase(),
|
method: reqInit.method.toUpperCase(),
|
||||||
reqUrl: reqPath,
|
reqUrl: reqPath,
|
||||||
reqInit,
|
reqInit,
|
||||||
};
|
});
|
||||||
|
|
||||||
this.writeLog({ ...infoLog });
|
|
||||||
|
|
||||||
return fetch(reqUrl, reqInit);
|
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" });
|
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;
|
let reqUrl = this.config.apiBase + path;
|
||||||
const reqInit: RequestInit = { ...this.reqInit, ...init };
|
const reqInit: RequestInit = { ...this.reqInit, ...init };
|
||||||
const { data, query } = params || {} as P;
|
const { data, query } = params || {} as P;
|
||||||
@ -119,48 +117,53 @@ export class JsonApi<D = JsonApiData, P extends JsonApiParams = JsonApiParams> {
|
|||||||
reqInit,
|
reqInit,
|
||||||
};
|
};
|
||||||
|
|
||||||
return cancelableFetch(reqUrl, reqInit).then(res => {
|
const res = await fetch(reqUrl, reqInit);
|
||||||
return this.parseResponse<D>(res, infoLog);
|
|
||||||
});
|
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;
|
const { status } = res;
|
||||||
|
|
||||||
return res.text().then(text => {
|
const text = await res.text();
|
||||||
let data;
|
let data;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
data = text ? JSON.parse(text) : ""; // DELETE-requests might not have response-body
|
data = text ? JSON.parse(text) : ""; // DELETE-requests might not have response-body
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
data = text;
|
data = text;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status >= 200 && status < 300) {
|
if (status >= 200 && status < 300) {
|
||||||
this.onData.emit(data, res);
|
this.onData.emit(data, res);
|
||||||
this.writeLog({ ...log, data });
|
this.writeLog({ ...log, data });
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
} else if (log.method === "GET" && res.status === 403) {
|
}
|
||||||
this.writeLog({ ...log, data });
|
|
||||||
} else {
|
|
||||||
const error = new JsonApiErrorParsed(data, this.parseError(data, res));
|
|
||||||
|
|
||||||
this.onError.emit(error, res);
|
if (log.method === "GET" && res.status === 403) {
|
||||||
this.writeLog({ ...log, error });
|
this.writeLog({ ...log, error: data });
|
||||||
throw error;
|
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[] {
|
protected parseError(error: JsonApiError | string, res: Response): string[] {
|
||||||
if (typeof error === "string") {
|
if (typeof error === "string") {
|
||||||
return [error];
|
return [error];
|
||||||
}
|
}
|
||||||
else if (Array.isArray(error.errors)) {
|
|
||||||
|
if (Array.isArray(error.errors)) {
|
||||||
return error.errors.map(error => error.title);
|
return error.errors.map(error => error.title);
|
||||||
}
|
}
|
||||||
else if (error.message) {
|
|
||||||
|
if (error.message) {
|
||||||
return [error.message];
|
return [error.message];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -7,11 +7,12 @@ import logger from "../../main/logger";
|
|||||||
import { apiManager } from "./api-manager";
|
import { apiManager } from "./api-manager";
|
||||||
import { apiKube } from "./index";
|
import { apiKube } from "./index";
|
||||||
import { createKubeApiURL, parseKubeApi } from "./kube-api-parse";
|
import { createKubeApiURL, parseKubeApi } from "./kube-api-parse";
|
||||||
import { KubeJsonApi, KubeJsonApiData, KubeJsonApiDataList } from "./kube-json-api";
|
|
||||||
import { IKubeObjectConstructor, KubeObject, KubeStatus } from "./kube-object";
|
import { IKubeObjectConstructor, KubeObject, KubeStatus } from "./kube-object";
|
||||||
import byline from "byline";
|
import byline from "byline";
|
||||||
import { IKubeWatchEvent } from "./kube-watch-api";
|
import { IKubeWatchEvent } from "./kube-watch-api";
|
||||||
import { ReadableWebToNodeStream } from "../utils/readableStream";
|
import { ReadableWebToNodeStream } from "../utils/readableStream";
|
||||||
|
import { KubeJsonApi, KubeJsonApiData } from "./kube-json-api";
|
||||||
|
import { noop } from "../utils";
|
||||||
|
|
||||||
export interface IKubeApiOptions<T extends KubeObject> {
|
export interface IKubeApiOptions<T extends KubeObject> {
|
||||||
/**
|
/**
|
||||||
@ -34,6 +35,11 @@ export interface IKubeApiOptions<T extends KubeObject> {
|
|||||||
checkPreferredVersion?: boolean;
|
checkPreferredVersion?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface KubeApiListOptions {
|
||||||
|
namespace?: string;
|
||||||
|
reqInit?: RequestInit;
|
||||||
|
}
|
||||||
|
|
||||||
export interface IKubeApiQueryParams {
|
export interface IKubeApiQueryParams {
|
||||||
watch?: boolean | number;
|
watch?: boolean | number;
|
||||||
resourceVersion?: string;
|
resourceVersion?: string;
|
||||||
@ -245,7 +251,7 @@ export class KubeApi<T extends KubeObject = any> {
|
|||||||
return this.resourceVersions.get(namespace);
|
return this.resourceVersions.get(namespace);
|
||||||
}
|
}
|
||||||
|
|
||||||
async refreshResourceVersion(params?: { namespace: string }) {
|
async refreshResourceVersion(params?: KubeApiListOptions) {
|
||||||
return this.list(params, { limit: 1 });
|
return this.list(params, { limit: 1 });
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -273,20 +279,12 @@ export class KubeApi<T extends KubeObject = any> {
|
|||||||
return query;
|
return query;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected parseResponse(data: KubeJsonApiData | KubeJsonApiData[] | KubeJsonApiDataList, namespace?: string): any {
|
protected parseResponse(data: unknown, namespace?: string): T | T[] | null {
|
||||||
if (!data) return;
|
if (!data) return;
|
||||||
const KubeObjectConstructor = this.objectConstructor;
|
const KubeObjectConstructor = this.objectConstructor;
|
||||||
|
|
||||||
if (KubeObject.isJsonApiData(data)) {
|
// process items list response, check before single item since there is overlap
|
||||||
const object = new KubeObjectConstructor(data);
|
if (KubeObject.isJsonApiDataList(data, KubeObject.isPartialJsonApiData)) {
|
||||||
|
|
||||||
ensureObjectSelfLink(this, object);
|
|
||||||
|
|
||||||
return object;
|
|
||||||
}
|
|
||||||
|
|
||||||
// process items list response
|
|
||||||
if (KubeObject.isJsonApiDataList(data)) {
|
|
||||||
const { apiVersion, items, metadata } = data;
|
const { apiVersion, items, metadata } = data;
|
||||||
|
|
||||||
this.setResourceVersion(namespace, metadata.resourceVersion);
|
this.setResourceVersion(namespace, metadata.resourceVersion);
|
||||||
@ -305,55 +303,90 @@ 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.
|
// custom apis might return array for list response, e.g. users, groups, etc.
|
||||||
if (Array.isArray(data)) {
|
if (Array.isArray(data)) {
|
||||||
return data.map(data => new KubeObjectConstructor(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();
|
await this.checkPreferredVersion();
|
||||||
|
|
||||||
return this.request
|
const url = this.getUrl({ namespace });
|
||||||
.get(this.getUrl({ namespace }), { query })
|
const res = await this.request.get(url, { query }, reqInit);
|
||||||
.then(data => this.parseResponse(data, namespace));
|
const parsed = this.parseResponse(res, namespace);
|
||||||
|
|
||||||
|
if (Array.isArray(parsed)) {
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!parsed) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`GET multiple request to ${url} returned not an array: ${JSON.stringify(parsed)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async get({ name = "", namespace = "default" } = {}, query?: IKubeApiQueryParams): Promise<T> {
|
async get({ name = "", namespace = "default" } = {}, query?: IKubeApiQueryParams): Promise<T | null> {
|
||||||
await this.checkPreferredVersion();
|
await this.checkPreferredVersion();
|
||||||
|
|
||||||
return this.request
|
const url = this.getUrl({ namespace, name });
|
||||||
.get(this.getUrl({ namespace, name }), { query })
|
const res = await this.request.get(url, { query });
|
||||||
.then(this.parseResponse);
|
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> {
|
async create({ name = "", namespace = "default" } = {}, data?: Partial<T>): Promise<T | null> {
|
||||||
await this.checkPreferredVersion();
|
await this.checkPreferredVersion();
|
||||||
|
|
||||||
const apiUrl = this.getUrl({ namespace });
|
const apiUrl = this.getUrl({ namespace });
|
||||||
|
const res = await this.request.post(apiUrl, {
|
||||||
|
data: merge({
|
||||||
|
kind: this.kind,
|
||||||
|
apiVersion: this.apiVersionWithGroup,
|
||||||
|
metadata: {
|
||||||
|
name,
|
||||||
|
namespace
|
||||||
|
}
|
||||||
|
}, data)
|
||||||
|
});
|
||||||
|
const parsed = this.parseResponse(res);
|
||||||
|
|
||||||
return this.request
|
if (Array.isArray(parsed)) {
|
||||||
.post(apiUrl, {
|
throw new Error(`POST request to ${apiUrl} returned an array: ${JSON.stringify(parsed)}`);
|
||||||
data: merge({
|
}
|
||||||
kind: this.kind,
|
|
||||||
apiVersion: this.apiVersionWithGroup,
|
return parsed;
|
||||||
metadata: {
|
|
||||||
name,
|
|
||||||
namespace
|
|
||||||
}
|
|
||||||
}, data)
|
|
||||||
})
|
|
||||||
.then(this.parseResponse);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async update({ name = "", namespace = "default" } = {}, data?: Partial<T>): Promise<T> {
|
async update({ name = "", namespace = "default" } = {}, data?: Partial<T>): Promise<T | null> {
|
||||||
await this.checkPreferredVersion();
|
await this.checkPreferredVersion();
|
||||||
const apiUrl = this.getUrl({ namespace, name });
|
const apiUrl = this.getUrl({ namespace, name });
|
||||||
|
|
||||||
return this.request
|
const res = await this.request.put(apiUrl, { data });
|
||||||
.put(apiUrl, { data })
|
const parsed = this.parseResponse(res);
|
||||||
.then(this.parseResponse);
|
|
||||||
|
if (Array.isArray(parsed)) {
|
||||||
|
throw new Error(`PUT request to ${apiUrl} returned an array: ${JSON.stringify(parsed)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed;
|
||||||
}
|
}
|
||||||
|
|
||||||
async delete({ name = "", namespace = "default" }) {
|
async delete({ name = "", namespace = "default" }) {
|
||||||
@ -372,78 +405,60 @@ export class KubeApi<T extends KubeObject = any> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
watch(opts: KubeApiWatchOptions = { namespace: "" }): () => void {
|
watch(opts: KubeApiWatchOptions = { namespace: "" }): () => void {
|
||||||
if (!opts.abortController) {
|
|
||||||
opts.abortController = new AbortController();
|
|
||||||
}
|
|
||||||
let errorReceived = false;
|
let errorReceived = false;
|
||||||
let timedRetry: NodeJS.Timeout;
|
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);
|
clearTimeout(timedRetry);
|
||||||
});
|
});
|
||||||
|
|
||||||
const watchUrl = this.getWatchUrl(namespace);
|
const watchUrl = this.getWatchUrl(namespace);
|
||||||
const responsePromise = this.request.getResponse(watchUrl, null, {
|
const responsePromise = this.request.getResponse(watchUrl, null, { signal });
|
||||||
signal: abortController.signal
|
|
||||||
});
|
|
||||||
|
|
||||||
responsePromise.then((response) => {
|
responsePromise
|
||||||
if (!response.ok && !abortController.signal.aborted) {
|
.then(response => {
|
||||||
callback?.(null, response);
|
if (!response.ok) {
|
||||||
|
return callback(null, response);
|
||||||
return;
|
|
||||||
}
|
|
||||||
const nodeStream = new ReadableWebToNodeStream(response.body);
|
|
||||||
|
|
||||||
["end", "close", "error"].forEach((eventName) => {
|
|
||||||
nodeStream.on(eventName, () => {
|
|
||||||
if (errorReceived) return; // kubernetes errors should be handled in a callback
|
|
||||||
|
|
||||||
clearTimeout(timedRetry);
|
|
||||||
timedRetry = setTimeout(() => { // we did not get any kubernetes errors so let's retry
|
|
||||||
if (abortController.signal.aborted) return;
|
|
||||||
|
|
||||||
this.watch({...opts, namespace, callback});
|
|
||||||
}, 1000);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const stream = byline(nodeStream);
|
|
||||||
|
|
||||||
stream.on("data", (line) => {
|
|
||||||
try {
|
|
||||||
const event: IKubeWatchEvent = JSON.parse(line);
|
|
||||||
|
|
||||||
if (event.type === "ERROR" && event.object.kind === "Status") {
|
|
||||||
errorReceived = true;
|
|
||||||
callback(null, new KubeStatus(event.object as any));
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.modifyWatchEvent(event);
|
|
||||||
|
|
||||||
if (callback) {
|
|
||||||
callback(event, null);
|
|
||||||
}
|
|
||||||
} catch (ignore) {
|
|
||||||
// ignore parse errors
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const nodeStream = new ReadableWebToNodeStream(response.body);
|
||||||
|
|
||||||
|
["end", "close", "error"].forEach((eventName) => {
|
||||||
|
nodeStream.on(eventName, () => {
|
||||||
|
if (errorReceived) return; // kubernetes errors should be handled in a callback
|
||||||
|
|
||||||
|
clearTimeout(timedRetry);
|
||||||
|
timedRetry = setTimeout(() => { // we did not get any kubernetes errors so let's retry
|
||||||
|
this.watch({...opts, namespace, callback});
|
||||||
|
}, 1000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
byline(nodeStream).on("data", (line) => {
|
||||||
|
try {
|
||||||
|
const event: IKubeWatchEvent = JSON.parse(line);
|
||||||
|
|
||||||
|
if (event.type === "ERROR" && event.object.kind === "Status") {
|
||||||
|
errorReceived = true;
|
||||||
|
|
||||||
|
return callback(null, new KubeStatus(event.object as any));
|
||||||
|
}
|
||||||
|
|
||||||
|
this.modifyWatchEvent(event);
|
||||||
|
callback(event, null);
|
||||||
|
} catch (ignore) {
|
||||||
|
// ignore parse errors
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
if (error instanceof DOMException) return; // AbortController rejects, we can ignore it
|
||||||
|
|
||||||
|
callback(null, error);
|
||||||
});
|
});
|
||||||
}, (error) => {
|
|
||||||
if (error instanceof DOMException) return; // AbortController rejects, we can ignore it
|
|
||||||
|
|
||||||
callback?.(null, error);
|
return abort;
|
||||||
}).catch((error) => {
|
|
||||||
callback?.(null, error);
|
|
||||||
});
|
|
||||||
|
|
||||||
const disposer = () => {
|
|
||||||
abortController.abort();
|
|
||||||
};
|
|
||||||
|
|
||||||
return disposer;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected modifyWatchEvent(event: IKubeWatchEvent) {
|
protected modifyWatchEvent(event: IKubeWatchEvent) {
|
||||||
|
|||||||
@ -1,34 +1,38 @@
|
|||||||
import { JsonApi, JsonApiData, JsonApiError } from "./json-api";
|
import { JsonApi, JsonApiData, JsonApiError } from "./json-api";
|
||||||
|
|
||||||
|
export interface KubeJsonApiListMetadata {
|
||||||
|
resourceVersion: string;
|
||||||
|
selfLink?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface KubeJsonApiDataList<T = KubeJsonApiData> {
|
export interface KubeJsonApiDataList<T = KubeJsonApiData> {
|
||||||
kind: string;
|
kind: string;
|
||||||
apiVersion: string;
|
apiVersion: string;
|
||||||
items: T[];
|
items: T[];
|
||||||
metadata: {
|
metadata: KubeJsonApiListMetadata;
|
||||||
resourceVersion: string;
|
}
|
||||||
selfLink: string;
|
|
||||||
|
export interface KubeJsonApiMetadata {
|
||||||
|
uid: string;
|
||||||
|
name: string;
|
||||||
|
namespace?: string;
|
||||||
|
creationTimestamp?: string;
|
||||||
|
resourceVersion: string;
|
||||||
|
continue?: string;
|
||||||
|
finalizers?: string[];
|
||||||
|
selfLink?: string;
|
||||||
|
labels?: {
|
||||||
|
[label: string]: string;
|
||||||
|
};
|
||||||
|
annotations?: {
|
||||||
|
[annotation: string]: string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface KubeJsonApiData extends JsonApiData {
|
export interface KubeJsonApiData extends JsonApiData {
|
||||||
kind: string;
|
kind: string;
|
||||||
apiVersion: string;
|
apiVersion: string;
|
||||||
metadata: {
|
metadata: KubeJsonApiMetadata;
|
||||||
uid: string;
|
|
||||||
name: string;
|
|
||||||
namespace?: string;
|
|
||||||
creationTimestamp?: string;
|
|
||||||
resourceVersion: string;
|
|
||||||
continue?: string;
|
|
||||||
finalizers?: string[];
|
|
||||||
selfLink?: string;
|
|
||||||
labels?: {
|
|
||||||
[label: string]: string;
|
|
||||||
};
|
|
||||||
annotations?: {
|
|
||||||
[annotation: string]: string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface KubeJsonApiError extends JsonApiError {
|
export interface KubeJsonApiError extends JsonApiError {
|
||||||
|
|||||||
@ -1,12 +1,13 @@
|
|||||||
// Base class for all kubernetes objects
|
// Base class for all kubernetes objects
|
||||||
|
|
||||||
import moment from "moment";
|
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 { autobind, formatDuration } from "../utils";
|
||||||
import { ItemObject } from "../item.store";
|
import { ItemObject } from "../item.store";
|
||||||
import { apiKube } from "./index";
|
import { apiKube } from "./index";
|
||||||
import { JsonApiParams } from "./json-api";
|
import { JsonApiParams } from "./json-api";
|
||||||
import { resourceApplierApi } from "./endpoints/resource-applier.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) & {
|
export type IKubeObjectConstructor<T extends KubeObject = any> = (new (data: KubeJsonApiData | any) => T) & {
|
||||||
kind?: string;
|
kind?: string;
|
||||||
@ -78,15 +79,59 @@ export class KubeObject implements ItemObject {
|
|||||||
return !item.metadata.name.startsWith("system:");
|
return !item.metadata.name.startsWith("system:");
|
||||||
}
|
}
|
||||||
|
|
||||||
static isJsonApiData(object: any): object is KubeJsonApiData {
|
static isJsonApiData(object: unknown): object is KubeJsonApiData {
|
||||||
return !object.items && object.metadata;
|
return (
|
||||||
|
isObject(object)
|
||||||
|
&& hasTypedProperty(object, "kind", isString)
|
||||||
|
&& hasTypedProperty(object, "apiVersion", isString)
|
||||||
|
&& hasTypedProperty(object, "metadata", KubeObject.isKubeJsonApiMetadata)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
static isJsonApiDataList(object: any): object is KubeJsonApiDataList {
|
static isKubeJsonApiListMetadata(object: unknown): object is KubeJsonApiListMetadata {
|
||||||
return object.items && object.metadata;
|
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 [];
|
if (!labels) return [];
|
||||||
|
|
||||||
return Object.entries(labels).map(([name, value]) => `${name}=${value}`);
|
return Object.entries(labels).map(([name, value]) => `${name}=${value}`);
|
||||||
|
|||||||
@ -20,6 +20,7 @@ import { App } from "./components/app";
|
|||||||
import { LensApp } from "./lens-app";
|
import { LensApp } from "./lens-app";
|
||||||
import { ThemeStore } from "./theme.store";
|
import { ThemeStore } from "./theme.store";
|
||||||
import { HelmRepoManager } from "../main/helm/helm-repo-manager";
|
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
|
* 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 themeStore = ThemeStore.createInstance();
|
||||||
const hotbarStore = HotbarStore.createInstance();
|
const hotbarStore = HotbarStore.createInstance();
|
||||||
|
|
||||||
|
ExtensionInstallationStateStore.bindIpcListeners();
|
||||||
HelmRepoManager.createInstance(); // initialize the manager
|
HelmRepoManager.createInstance(); // initialize the manager
|
||||||
|
|
||||||
// preload common stores
|
// preload common stores
|
||||||
|
|||||||
@ -1,14 +1,13 @@
|
|||||||
import "./helm-chart-details.scss";
|
import "./helm-chart-details.scss";
|
||||||
|
|
||||||
import React, { Component } from "react";
|
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 { observable, autorun } from "mobx";
|
||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
import { Drawer, DrawerItem } from "../drawer";
|
import { Drawer, DrawerItem } from "../drawer";
|
||||||
import { autobind, stopPropagation } from "../../utils";
|
import { autobind, stopPropagation } from "../../utils";
|
||||||
import { MarkdownViewer } from "../markdown-viewer";
|
import { MarkdownViewer } from "../markdown-viewer";
|
||||||
import { Spinner } from "../spinner";
|
import { Spinner } from "../spinner";
|
||||||
import { CancelablePromise } from "../../utils/cancelableFetch";
|
|
||||||
import { Button } from "../button";
|
import { Button } from "../button";
|
||||||
import { Select, SelectOption } from "../select";
|
import { Select, SelectOption } from "../select";
|
||||||
import { createInstallChartTab } from "../dock/install-chart.store";
|
import { createInstallChartTab } from "../dock/install-chart.store";
|
||||||
@ -26,35 +25,37 @@ export class HelmChartDetails extends Component<Props> {
|
|||||||
@observable readme: string = null;
|
@observable readme: string = null;
|
||||||
@observable error: string = null;
|
@observable error: string = null;
|
||||||
|
|
||||||
private chartPromise: CancelablePromise<{ readme: string; versions: HelmChart[] }>;
|
private abortController?: AbortController;
|
||||||
|
|
||||||
componentWillUnmount() {
|
componentWillUnmount() {
|
||||||
this.chartPromise?.cancel();
|
this.abortController?.abort();
|
||||||
}
|
}
|
||||||
|
|
||||||
chartUpdater = autorun(() => {
|
chartUpdater = autorun(() => {
|
||||||
this.selectedChart = null;
|
this.selectedChart = null;
|
||||||
const { chart: { name, repo, version } } = this.props;
|
const { chart: { name, repo, version } } = this.props;
|
||||||
|
|
||||||
helmChartsApi.get(repo, name, version).then(result => {
|
getChartDetails(repo, name, { version })
|
||||||
this.readme = result.readme;
|
.then(result => {
|
||||||
this.chartVersions = result.versions;
|
this.readme = result.readme;
|
||||||
this.selectedChart = result.versions[0];
|
this.chartVersions = result.versions;
|
||||||
},
|
this.selectedChart = result.versions[0];
|
||||||
error => {
|
})
|
||||||
this.error = error;
|
.catch(error => {
|
||||||
});
|
this.error = error;
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@autobind()
|
@autobind()
|
||||||
async onVersionChange({ value: version }: SelectOption) {
|
async onVersionChange({ value: version }: SelectOption<string>) {
|
||||||
this.selectedChart = this.chartVersions.find(chart => chart.version === version);
|
this.selectedChart = this.chartVersions.find(chart => chart.version === version);
|
||||||
this.readme = null;
|
this.readme = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
this.chartPromise?.cancel();
|
this.abortController?.abort();
|
||||||
|
this.abortController = new AbortController();
|
||||||
const { chart: { name, repo } } = this.props;
|
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;
|
this.readme = readme;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import semver from "semver";
|
import semver from "semver";
|
||||||
import { observable } from "mobx";
|
import { observable } from "mobx";
|
||||||
import { autobind } from "../../utils";
|
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 { ItemStore } from "../../item.store";
|
||||||
import flatten from "lodash/flatten";
|
import flatten from "lodash/flatten";
|
||||||
|
|
||||||
@ -16,7 +16,7 @@ export class HelmChartStore extends ItemStore<HelmChart> {
|
|||||||
|
|
||||||
async loadAll() {
|
async loadAll() {
|
||||||
try {
|
try {
|
||||||
const res = await this.loadItems(() => helmChartsApi.list());
|
const res = await this.loadItems(() => listCharts());
|
||||||
|
|
||||||
this.failedLoading = false;
|
this.failedLoading = false;
|
||||||
|
|
||||||
@ -48,13 +48,13 @@ export class HelmChartStore extends ItemStore<HelmChart> {
|
|||||||
return versions;
|
return versions;
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadVersions = (repo: string) => {
|
const loadVersions = async (repo: string) => {
|
||||||
return helmChartsApi.get(repo, chartName).then(({ versions }) => {
|
const { versions } = await getChartDetails(repo, chartName);
|
||||||
return versions.map(chart => ({
|
|
||||||
repo,
|
return versions.map(chart => ({
|
||||||
version: chart.getVersion()
|
repo,
|
||||||
}));
|
version: chart.getVersion()
|
||||||
});
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!this.isLoaded) {
|
if (!this.isLoaded) {
|
||||||
|
|||||||
@ -47,7 +47,8 @@ export class HorizontalPodAutoscalers extends React.Component<Props> {
|
|||||||
[columnId.namespace]: (item: HorizontalPodAutoscaler) => item.getNs(),
|
[columnId.namespace]: (item: HorizontalPodAutoscaler) => item.getNs(),
|
||||||
[columnId.minPods]: (item: HorizontalPodAutoscaler) => item.getMinPods(),
|
[columnId.minPods]: (item: HorizontalPodAutoscaler) => item.getMinPods(),
|
||||||
[columnId.maxPods]: (item: HorizontalPodAutoscaler) => item.getMaxPods(),
|
[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={[
|
searchFilters={[
|
||||||
(item: HorizontalPodAutoscaler) => item.getSearchFields()
|
(item: HorizontalPodAutoscaler) => item.getSearchFields()
|
||||||
|
|||||||
@ -1,16 +1,18 @@
|
|||||||
import "@testing-library/jest-dom/extend-expect";
|
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 fse from "fs-extra";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { UserStore } from "../../../../common/user-store";
|
import { UserStore } from "../../../../common/user-store";
|
||||||
import { ExtensionDiscovery } from "../../../../extensions/extension-discovery";
|
import { ExtensionDiscovery } from "../../../../extensions/extension-discovery";
|
||||||
import { ExtensionLoader } from "../../../../extensions/extension-loader";
|
import { ExtensionLoader } from "../../../../extensions/extension-loader";
|
||||||
import { ThemeStore } from "../../../theme.store";
|
|
||||||
import { ConfirmDialog } from "../../confirm-dialog";
|
import { ConfirmDialog } from "../../confirm-dialog";
|
||||||
import { Notifications } from "../../notifications";
|
import { ExtensionInstallationStateStore } from "../extension-install.store";
|
||||||
import { Extensions } from "../extensions";
|
import { Extensions } from "../extensions";
|
||||||
|
import mockFs from "mock-fs";
|
||||||
|
|
||||||
|
jest.setTimeout(30000);
|
||||||
jest.mock("fs-extra");
|
jest.mock("fs-extra");
|
||||||
|
jest.mock("../../notifications");
|
||||||
|
|
||||||
jest.mock("../../../../common/utils", () => ({
|
jest.mock("../../../../common/utils", () => ({
|
||||||
...jest.requireActual("../../../../common/utils"),
|
...jest.requireActual("../../../../common/utils"),
|
||||||
@ -20,37 +22,30 @@ jest.mock("../../../../common/utils", () => ({
|
|||||||
extractTar: jest.fn(() => Promise.resolve())
|
extractTar: jest.fn(() => Promise.resolve())
|
||||||
}));
|
}));
|
||||||
|
|
||||||
jest.mock("../../notifications", () => ({
|
jest.mock("electron", () => ({
|
||||||
ok: jest.fn(),
|
app: {
|
||||||
error: jest.fn(),
|
getVersion: () => "99.99.99",
|
||||||
info: jest.fn()
|
getPath: () => "tmp",
|
||||||
|
getLocale: () => "en",
|
||||||
|
setLoginItemSettings: (): void => void 0,
|
||||||
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
jest.mock("electron", () => {
|
|
||||||
return {
|
|
||||||
app: {
|
|
||||||
getVersion: () => "99.99.99",
|
|
||||||
getPath: () => "tmp",
|
|
||||||
getLocale: () => "en",
|
|
||||||
setLoginItemSettings: (): void => void 0,
|
|
||||||
}
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Extensions", () => {
|
describe("Extensions", () => {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
|
mockFs({
|
||||||
|
"tmp": {}
|
||||||
|
});
|
||||||
|
|
||||||
|
ExtensionInstallationStateStore.reset();
|
||||||
UserStore.resetInstance();
|
UserStore.resetInstance();
|
||||||
ThemeStore.resetInstance();
|
|
||||||
|
|
||||||
await UserStore.createInstance().load();
|
await UserStore.createInstance().load();
|
||||||
await ThemeStore.createInstance().init();
|
|
||||||
|
|
||||||
ExtensionLoader.resetInstance();
|
|
||||||
ExtensionDiscovery.resetInstance();
|
ExtensionDiscovery.resetInstance();
|
||||||
Extensions.installStates.clear();
|
|
||||||
|
|
||||||
ExtensionDiscovery.createInstance().uninstallExtension = jest.fn(() => Promise.resolve());
|
ExtensionDiscovery.createInstance().uninstallExtension = jest.fn(() => Promise.resolve());
|
||||||
|
|
||||||
|
ExtensionLoader.resetInstance();
|
||||||
ExtensionLoader.createInstance().addExtension({
|
ExtensionLoader.createInstance().addExtension({
|
||||||
id: "extensionId",
|
id: "extensionId",
|
||||||
manifest: {
|
manifest: {
|
||||||
@ -64,49 +59,38 @@ describe("Extensions", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("disables uninstall and disable buttons while uninstalling", async () => {
|
afterEach(() => {
|
||||||
ExtensionDiscovery.getInstance().isLoaded = true;
|
mockFs.restore();
|
||||||
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"));
|
|
||||||
|
|
||||||
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", () => {
|
it("disables uninstall and disable buttons while uninstalling", async () => {
|
||||||
ExtensionDiscovery.getInstance().isLoaded = true;
|
ExtensionDiscovery.getInstance().isLoaded = true;
|
||||||
(ExtensionDiscovery.getInstance().uninstallExtension as any).mockImplementationOnce(() =>
|
|
||||||
Promise.reject()
|
|
||||||
);
|
|
||||||
render(<><Extensions /><ConfirmDialog/></>);
|
|
||||||
|
|
||||||
expect(screen.getByText("Disable").closest("button")).not.toBeDisabled();
|
const res = render(<><Extensions /><ConfirmDialog /></>);
|
||||||
expect(screen.getByText("Uninstall").closest("button")).not.toBeDisabled();
|
|
||||||
|
|
||||||
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
|
// Approve confirm dialog
|
||||||
fireEvent.click(screen.getByText("Yes"));
|
fireEvent.click(res.getByText("Yes"));
|
||||||
|
|
||||||
waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText("Disable").closest("button")).not.toBeDisabled();
|
expect(ExtensionDiscovery.getInstance().uninstallExtension).toHaveBeenCalled();
|
||||||
expect(screen.getByText("Uninstall").closest("button")).not.toBeDisabled();
|
expect(res.getByText("Disable").closest("button")).toBeDisabled();
|
||||||
expect(Notifications.error).toHaveBeenCalledTimes(1);
|
expect(res.getByText("Uninstall").closest("button")).toBeDisabled();
|
||||||
|
}, {
|
||||||
|
timeout: 30000,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("disables install button while installing", () => {
|
it("disables install button while installing", async () => {
|
||||||
render(<Extensions />);
|
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
|
exact: false
|
||||||
}), {
|
}), {
|
||||||
target: {
|
target: {
|
||||||
@ -114,13 +98,8 @@ describe("Extensions", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
fireEvent.click(screen.getByText("Install"));
|
fireEvent.click(res.getByText("Install"));
|
||||||
|
expect(res.getByText("Install").closest("button")).toBeDisabled();
|
||||||
waitFor(() => {
|
|
||||||
expect(screen.getByText("Install").closest("button")).toBeDisabled();
|
|
||||||
expect(fse.move).toHaveBeenCalledWith("");
|
|
||||||
expect(Notifications.error).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("displays spinner while extensions are loading", () => {
|
it("displays spinner while extensions are loading", () => {
|
||||||
@ -128,8 +107,11 @@ describe("Extensions", () => {
|
|||||||
const { container } = render(<Extensions />);
|
const { container } = render(<Extensions />);
|
||||||
|
|
||||||
expect(container.querySelector(".Spinner")).toBeInTheDocument();
|
expect(container.querySelector(".Spinner")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not display the spinner while extensions are not loading", async () => {
|
||||||
ExtensionDiscovery.getInstance().isLoaded = true;
|
ExtensionDiscovery.getInstance().isLoaded = true;
|
||||||
|
const { container } = render(<Extensions />);
|
||||||
|
|
||||||
waitFor(() =>
|
waitFor(() =>
|
||||||
expect(container.querySelector(".Spinner")).not.toBeInTheDocument()
|
expect(container.querySelector(".Spinner")).not.toBeInTheDocument()
|
||||||
|
|||||||
218
src/renderer/components/+extensions/extension-install.store.ts
Normal file
218
src/renderer/components/+extensions/extension-install.store.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@ -1,5 +1,5 @@
|
|||||||
import "./button.scss";
|
import "./button.scss";
|
||||||
import React, { ButtonHTMLAttributes, ReactNode } from "react";
|
import React, { ButtonHTMLAttributes } from "react";
|
||||||
import { cssNames } from "../../utils";
|
import { cssNames } from "../../utils";
|
||||||
import { TooltipDecoratorProps, withTooltip } from "../tooltip";
|
import { TooltipDecoratorProps, withTooltip } from "../tooltip";
|
||||||
|
|
||||||
@ -26,29 +26,22 @@ export class Button extends React.PureComponent<ButtonProps, {}> {
|
|||||||
|
|
||||||
render() {
|
render() {
|
||||||
const {
|
const {
|
||||||
className, waiting, label, primary, accent, plain, hidden, active, big,
|
waiting, label, primary, accent, plain, hidden, active, big,
|
||||||
round, outlined, tooltip, light, children, ...props
|
round, outlined, tooltip, light, children, ...btnProps
|
||||||
} = this.props;
|
} = this.props;
|
||||||
const btnProps: Partial<ButtonProps> = props;
|
|
||||||
|
|
||||||
if (hidden) return null;
|
if (hidden) return null;
|
||||||
|
|
||||||
btnProps.className = cssNames("Button", className, {
|
btnProps.className = cssNames("Button", btnProps.className, {
|
||||||
waiting, primary, accent, plain, active, big, round, outlined, light,
|
waiting, primary, accent, plain, active, big, round, outlined, light,
|
||||||
});
|
});
|
||||||
|
|
||||||
const btnContent: ReactNode = (
|
|
||||||
<>
|
|
||||||
{label}
|
|
||||||
{children}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
// render as link
|
// render as link
|
||||||
if (this.props.href) {
|
if (this.props.href) {
|
||||||
return (
|
return (
|
||||||
<a {...btnProps} ref={e => this.link = e}>
|
<a {...btnProps} ref={e => this.link = e}>
|
||||||
{btnContent}
|
{label}
|
||||||
|
{children}
|
||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -56,7 +49,8 @@ export class Button extends React.PureComponent<ButtonProps, {}> {
|
|||||||
// render as button
|
// render as button
|
||||||
return (
|
return (
|
||||||
<button type="button" {...btnProps} ref={e => this.button = e}>
|
<button type="button" {...btnProps} ref={e => this.button = e}>
|
||||||
{btnContent}
|
{label}
|
||||||
|
{children}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,14 +11,18 @@ import { Icon } from "../icon";
|
|||||||
export interface ConfirmDialogProps extends Partial<DialogProps> {
|
export interface ConfirmDialogProps extends Partial<DialogProps> {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ConfirmDialogParams {
|
export interface ConfirmDialogParams extends ConfirmDialogBooleanParams {
|
||||||
ok?: () => void;
|
ok?: () => any | Promise<any>;
|
||||||
|
cancel?: () => any | Promise<any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConfirmDialogBooleanParams {
|
||||||
labelOk?: ReactNode;
|
labelOk?: ReactNode;
|
||||||
labelCancel?: ReactNode;
|
labelCancel?: ReactNode;
|
||||||
message?: ReactNode;
|
message: ReactNode;
|
||||||
icon?: ReactNode;
|
icon?: ReactNode;
|
||||||
okButtonProps?: Partial<ButtonProps>
|
okButtonProps?: Partial<ButtonProps>;
|
||||||
cancelButtonProps?: Partial<ButtonProps>
|
cancelButtonProps?: Partial<ButtonProps>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@observer
|
@observer
|
||||||
@ -33,19 +37,26 @@ export class ConfirmDialog extends React.Component<ConfirmDialogProps> {
|
|||||||
ConfirmDialog.params = params;
|
ConfirmDialog.params = params;
|
||||||
}
|
}
|
||||||
|
|
||||||
static close() {
|
static confirm(params: ConfirmDialogBooleanParams): Promise<boolean> {
|
||||||
ConfirmDialog.isOpen = false;
|
return new Promise(resolve => {
|
||||||
|
ConfirmDialog.open({
|
||||||
|
ok: () => resolve(true),
|
||||||
|
cancel: () => resolve(false),
|
||||||
|
...params,
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public defaultParams: ConfirmDialogParams = {
|
static defaultParams: Partial<ConfirmDialogParams> = {
|
||||||
ok: noop,
|
ok: noop,
|
||||||
|
cancel: noop,
|
||||||
labelOk: "Ok",
|
labelOk: "Ok",
|
||||||
labelCancel: "Cancel",
|
labelCancel: "Cancel",
|
||||||
icon: <Icon big material="warning"/>,
|
icon: <Icon big material="warning"/>,
|
||||||
};
|
};
|
||||||
|
|
||||||
get params(): ConfirmDialogParams {
|
get params(): ConfirmDialogParams {
|
||||||
return Object.assign({}, this.defaultParams, ConfirmDialog.params);
|
return Object.assign({}, ConfirmDialog.defaultParams, ConfirmDialog.params);
|
||||||
}
|
}
|
||||||
|
|
||||||
ok = async () => {
|
ok = async () => {
|
||||||
@ -54,16 +65,21 @@ export class ConfirmDialog extends React.Component<ConfirmDialogProps> {
|
|||||||
await Promise.resolve(this.params.ok()).catch(noop);
|
await Promise.resolve(this.params.ok()).catch(noop);
|
||||||
} finally {
|
} finally {
|
||||||
this.isSaving = false;
|
this.isSaving = false;
|
||||||
|
ConfirmDialog.isOpen = false;
|
||||||
}
|
}
|
||||||
this.close();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
onClose = () => {
|
onClose = () => {
|
||||||
this.isSaving = false;
|
this.isSaving = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
close = () => {
|
close = async () => {
|
||||||
ConfirmDialog.close();
|
try {
|
||||||
|
await Promise.resolve(this.params.cancel()).catch(noop);
|
||||||
|
} finally {
|
||||||
|
this.isSaving = false;
|
||||||
|
ConfirmDialog.isOpen = false;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { action, autorun } from "mobx";
|
import { action, autorun } from "mobx";
|
||||||
import { dockStore, IDockTab, TabId, TabKind } from "./dock.store";
|
import { dockStore, IDockTab, TabId, TabKind } from "./dock.store";
|
||||||
import { DockTabStore } from "./dock-tab.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 { IReleaseUpdateDetails } from "../../api/endpoints/helm-releases.api";
|
||||||
import { Notifications } from "../notifications";
|
import { Notifications } from "../notifications";
|
||||||
|
|
||||||
@ -54,7 +54,7 @@ export class InstallChartStore extends DockTabStore<IChartInstallData> {
|
|||||||
const { repo, name, version } = this.getData(tabId);
|
const { repo, name, version } = this.getData(tabId);
|
||||||
|
|
||||||
this.versions.clearData(tabId); // reset
|
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);
|
const versions = charts.versions.map(chartVersion => chartVersion.version);
|
||||||
|
|
||||||
this.versions.setData(tabId, versions);
|
this.versions.setData(tabId, versions);
|
||||||
@ -64,7 +64,7 @@ export class InstallChartStore extends DockTabStore<IChartInstallData> {
|
|||||||
async loadValues(tabId: TabId, attempt = 0): Promise<void> {
|
async loadValues(tabId: TabId, attempt = 0): Promise<void> {
|
||||||
const data = this.getData(tabId);
|
const data = this.getData(tabId);
|
||||||
const { repo, name, version } = data;
|
const { repo, name, version } = data;
|
||||||
const values = await helmChartsApi.getValues(repo, name, version);
|
const values = await getChartValues(repo, name, version);
|
||||||
|
|
||||||
if (values) {
|
if (values) {
|
||||||
this.setData(tabId, { ...data, values });
|
this.setData(tabId, { ...data, values });
|
||||||
|
|||||||
@ -11,7 +11,7 @@ import { Icon } from "../icon";
|
|||||||
import { LogTabData } from "./log-tab.store";
|
import { LogTabData } from "./log-tab.store";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
tabData: LogTabData
|
tabData?: LogTabData
|
||||||
logs: string[]
|
logs: string[]
|
||||||
save: (data: Partial<LogTabData>) => void
|
save: (data: Partial<LogTabData>) => void
|
||||||
reload: () => void
|
reload: () => void
|
||||||
@ -19,6 +19,11 @@ interface Props {
|
|||||||
|
|
||||||
export const LogControls = observer((props: Props) => {
|
export const LogControls = observer((props: Props) => {
|
||||||
const { tabData, save, reload, logs } = props;
|
const { tabData, save, reload, logs } = props;
|
||||||
|
|
||||||
|
if (!tabData) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const { showTimestamps, previous } = tabData;
|
const { showTimestamps, previous } = tabData;
|
||||||
const since = logs.length ? logStore.getTimestamps(logs[0]) : null;
|
const since = logs.length ? logStore.getTimestamps(logs[0]) : null;
|
||||||
const pod = new Pod(tabData.selectedPod);
|
const pod = new Pod(tabData.selectedPod);
|
||||||
|
|||||||
@ -26,23 +26,14 @@ export class Logs extends React.Component<Props> {
|
|||||||
|
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
disposeOnUnmount(this,
|
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() {
|
get tabId() {
|
||||||
return this.props.tab.id;
|
return this.props.tab.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
@autobind()
|
|
||||||
save(data: Partial<LogTabData>) {
|
|
||||||
logTabStore.setData(this.tabId, { ...this.tabData, ...data });
|
|
||||||
}
|
|
||||||
|
|
||||||
load = async () => {
|
load = async () => {
|
||||||
this.isLoading = true;
|
this.isLoading = true;
|
||||||
await logStore.load(this.tabId);
|
await logStore.load(this.tabId);
|
||||||
@ -82,15 +73,19 @@ export class Logs extends React.Component<Props> {
|
|||||||
}, 100);
|
}, 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
renderResourceSelector() {
|
renderResourceSelector(data?: LogTabData) {
|
||||||
|
if (!data) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const logs = logStore.logs;
|
const logs = logStore.logs;
|
||||||
const searchLogs = this.tabData.showTimestamps ? logs : logStore.logsWithoutTimestamps;
|
const searchLogs = data.showTimestamps ? logs : logStore.logsWithoutTimestamps;
|
||||||
const controls = (
|
const controls = (
|
||||||
<div className="flex gaps">
|
<div className="flex gaps">
|
||||||
<LogResourceSelector
|
<LogResourceSelector
|
||||||
tabId={this.tabId}
|
tabId={this.tabId}
|
||||||
tabData={this.tabData}
|
tabData={data}
|
||||||
save={this.save}
|
save={newData => logTabStore.setData(this.tabId, { ...data, ...newData })}
|
||||||
reload={this.reload}
|
reload={this.reload}
|
||||||
/>
|
/>
|
||||||
<LogSearch
|
<LogSearch
|
||||||
@ -115,10 +110,15 @@ export class Logs extends React.Component<Props> {
|
|||||||
|
|
||||||
render() {
|
render() {
|
||||||
const logs = logStore.logs;
|
const logs = logStore.logs;
|
||||||
|
const data = logTabStore.getData(this.tabId);
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
this.reload();
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="PodLogs flex column">
|
<div className="PodLogs flex column">
|
||||||
{this.renderResourceSelector()}
|
{this.renderResourceSelector(data)}
|
||||||
<LogList
|
<LogList
|
||||||
logs={logs}
|
logs={logs}
|
||||||
id={this.tabId}
|
id={this.tabId}
|
||||||
@ -128,8 +128,8 @@ export class Logs extends React.Component<Props> {
|
|||||||
/>
|
/>
|
||||||
<LogControls
|
<LogControls
|
||||||
logs={logs}
|
logs={logs}
|
||||||
tabData={this.tabData}
|
tabData={data}
|
||||||
save={this.save}
|
save={newData => logTabStore.setData(this.tabId, { ...data, ...newData })}
|
||||||
reload={this.reload}
|
reload={this.reload}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -315,6 +315,7 @@ export class Input extends React.Component<InputProps, State> {
|
|||||||
rows: multiLine ? (rows || 1) : null,
|
rows: multiLine ? (rows || 1) : null,
|
||||||
ref: this.bindRef,
|
ref: this.bindRef,
|
||||||
spellCheck: "false",
|
spellCheck: "false",
|
||||||
|
disabled,
|
||||||
});
|
});
|
||||||
const showErrors = errors.length > 0 && !valid && dirty;
|
const showErrors = errors.length > 0 && !valid && dirty;
|
||||||
const errorsInfo = (
|
const errorsInfo = (
|
||||||
|
|||||||
@ -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 = {
|
export const isPath: InputValidator = {
|
||||||
condition: ({ type }) => type === "text",
|
condition: ({ type }) => type === "text",
|
||||||
message: () => `This field must be a valid path`,
|
message: () => `This field must be a valid path`,
|
||||||
|
|||||||
@ -2,105 +2,106 @@ import "./kube-object-status-icon.scss";
|
|||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { Icon } from "../icon";
|
import { Icon } from "../icon";
|
||||||
import { KubeObject } from "../../api/kube-object";
|
|
||||||
import { cssNames, formatDuration } from "../../utils";
|
import { cssNames, formatDuration } from "../../utils";
|
||||||
import { KubeObjectStatusRegistration, kubeObjectStatusRegistry } from "../../../extensions/registries/kube-object-status-registry";
|
import { KubeObject, KubeObjectStatus, KubeObjectStatusLevel } from "../../..//extensions/renderer-api/k8s-api";
|
||||||
import { KubeObjectStatus, KubeObjectStatusLevel } from "../../..//extensions/renderer-api/k8s-api";
|
import { kubeObjectStatusRegistry } from "../../../extensions/registries";
|
||||||
import { computed } from "mobx";
|
|
||||||
|
function statusClassName(level: number): string {
|
||||||
|
switch (level) {
|
||||||
|
case KubeObjectStatusLevel.INFO:
|
||||||
|
return "info";
|
||||||
|
case KubeObjectStatusLevel.WARNING:
|
||||||
|
return "warning";
|
||||||
|
case KubeObjectStatusLevel.CRITICAL:
|
||||||
|
return "error";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusTitle(level: KubeObjectStatusLevel): string {
|
||||||
|
switch (level) {
|
||||||
|
case KubeObjectStatusLevel.INFO:
|
||||||
|
return "Info";
|
||||||
|
case KubeObjectStatusLevel.WARNING:
|
||||||
|
return "Warning";
|
||||||
|
case KubeObjectStatusLevel.CRITICAL:
|
||||||
|
return "Critical";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAge(timestamp: string) {
|
||||||
|
return timestamp
|
||||||
|
? formatDuration(Date.now() - new Date(timestamp).getTime(), true)
|
||||||
|
: "";
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SplitStatusesByLevel {
|
||||||
|
maxLevel: string,
|
||||||
|
criticals: KubeObjectStatus[];
|
||||||
|
warnings: KubeObjectStatus[];
|
||||||
|
infos: KubeObjectStatus[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This fuction returns the class level for corresponding to the highest status level
|
||||||
|
* and the statuses split by their levels.
|
||||||
|
* @param src a list of status items
|
||||||
|
*/
|
||||||
|
function splitByLevel(src: KubeObjectStatus[]): SplitStatusesByLevel {
|
||||||
|
const parts = new Map(Object.values(KubeObjectStatusLevel).map(v => [v, []]));
|
||||||
|
|
||||||
|
src.forEach(status => parts.get(status.level).push(status));
|
||||||
|
|
||||||
|
const criticals = parts.get(KubeObjectStatusLevel.CRITICAL);
|
||||||
|
const warnings = parts.get(KubeObjectStatusLevel.WARNING);
|
||||||
|
const infos = parts.get(KubeObjectStatusLevel.INFO);
|
||||||
|
const maxLevel = statusClassName(criticals[0]?.level ?? warnings[0]?.level ?? infos[0].level);
|
||||||
|
|
||||||
|
return { maxLevel, criticals, warnings, infos };
|
||||||
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
object: KubeObject;
|
object: KubeObject;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class KubeObjectStatusIcon extends React.Component<Props> {
|
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 {
|
|
||||||
switch (level) {
|
|
||||||
case KubeObjectStatusLevel.INFO:
|
|
||||||
return "info";
|
|
||||||
case KubeObjectStatusLevel.WARNING:
|
|
||||||
return "warning";
|
|
||||||
case KubeObjectStatusLevel.CRITICAL:
|
|
||||||
return "error";
|
|
||||||
default:
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
statusTitle(level: number): string {
|
|
||||||
switch (level) {
|
|
||||||
case KubeObjectStatusLevel.INFO:
|
|
||||||
return "Info";
|
|
||||||
case KubeObjectStatusLevel.WARNING:
|
|
||||||
return "Warning";
|
|
||||||
case KubeObjectStatusLevel.CRITICAL:
|
|
||||||
return "Critical";
|
|
||||||
default:
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
getAge(timestamp: string) {
|
|
||||||
if (!timestamp) return "";
|
|
||||||
const diff = Date.now() - new Date(timestamp).getTime();
|
|
||||||
|
|
||||||
return formatDuration(diff, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
renderStatuses(statuses: KubeObjectStatus[], level: number) {
|
renderStatuses(statuses: KubeObjectStatus[], level: number) {
|
||||||
const filteredStatuses = statuses.filter((item) => item.level == level);
|
const filteredStatuses = statuses.filter((item) => item.level == level);
|
||||||
|
|
||||||
return filteredStatuses.length > 0 && (
|
return filteredStatuses.length > 0 && (
|
||||||
<div className={cssNames("level", this.statusClassName(level))}>
|
<div className={cssNames("level", statusClassName(level))}>
|
||||||
<span className="title">
|
<span className="title">
|
||||||
{this.statusTitle(level)}
|
{statusTitle(level)}
|
||||||
</span>
|
</span>
|
||||||
{ filteredStatuses.map((status, index) =>{
|
{
|
||||||
return (
|
filteredStatuses.map((status, index) => (
|
||||||
<div key={`kube-resource-status-${level}-${index}`} className={cssNames("status", "msg")}>
|
<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>
|
||||||
);
|
))
|
||||||
})}
|
}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { objectStatuses} = this;
|
const statuses = kubeObjectStatusRegistry.getItemsForObject(this.props.object);
|
||||||
|
|
||||||
if (!objectStatuses.length) return null;
|
if (statuses.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const sortedStatuses = objectStatuses.sort((a: KubeObjectStatus, b: KubeObjectStatus) => {
|
const { maxLevel, criticals, warnings, infos } = splitByLevel(statuses);
|
||||||
if (a.level < b.level ) {
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (a.level > b.level ) {
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
return 0;
|
|
||||||
});
|
|
||||||
|
|
||||||
const level = this.statusClassName(sortedStatuses[0].level);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Icon
|
<Icon
|
||||||
material={level}
|
material={maxLevel}
|
||||||
className={cssNames("KubeObjectStatusIcon", level)}
|
className={cssNames("KubeObjectStatusIcon", maxLevel)}
|
||||||
tooltip={{
|
tooltip={{
|
||||||
children: (
|
children: (
|
||||||
<div className="KubeObjectStatusTooltip">
|
<div className="KubeObjectStatusTooltip">
|
||||||
{this.renderStatuses(sortedStatuses, KubeObjectStatusLevel.CRITICAL)}
|
{this.renderStatuses(criticals, KubeObjectStatusLevel.CRITICAL)}
|
||||||
{this.renderStatuses(sortedStatuses, KubeObjectStatusLevel.WARNING)}
|
{this.renderStatuses(warnings, KubeObjectStatusLevel.WARNING)}
|
||||||
{this.renderStatuses(sortedStatuses, KubeObjectStatusLevel.INFO)}
|
{this.renderStatuses(infos, KubeObjectStatusLevel.INFO)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
|
|||||||
@ -24,13 +24,11 @@ export class KubeObjectMeta extends React.Component<KubeObjectMetaProps> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const object = this.props.object;
|
const { object } = this.props;
|
||||||
const {
|
const {
|
||||||
getName, getNs, getLabels, getResourceVersion, selfLink,
|
getNs, getLabels, getResourceVersion, selfLink, getAnnotations,
|
||||||
getAnnotations, getFinalizers, getId, getAge,
|
getFinalizers, getId, getAge, getName, metadata: { creationTimestamp },
|
||||||
metadata: { creationTimestamp },
|
|
||||||
} = object;
|
} = object;
|
||||||
|
|
||||||
const ownerRefs = object.getOwnerRefs();
|
const ownerRefs = object.getOwnerRefs();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -39,7 +37,8 @@ export class KubeObjectMeta extends React.Component<KubeObjectMetaProps> {
|
|||||||
{getAge(true, false)} ago ({<LocaleDate date={creationTimestamp} />})
|
{getAge(true, false)} ago ({<LocaleDate date={creationTimestamp} />})
|
||||||
</DrawerItem>
|
</DrawerItem>
|
||||||
<DrawerItem name="Name" hidden={this.isHidden("name")}>
|
<DrawerItem name="Name" hidden={this.isHidden("name")}>
|
||||||
{getName()} <KubeObjectStatusIcon key="icon" object={object} />
|
{getName()}
|
||||||
|
<KubeObjectStatusIcon key="icon" object={object} />
|
||||||
</DrawerItem>
|
</DrawerItem>
|
||||||
<DrawerItem name="Namespace" hidden={this.isHidden("namespace") || !getNs()}>
|
<DrawerItem name="Namespace" hidden={this.isHidden("namespace") || !getNs()}>
|
||||||
{getNs()}
|
{getNs()}
|
||||||
@ -68,7 +67,7 @@ export class KubeObjectMeta extends React.Component<KubeObjectMetaProps> {
|
|||||||
labels={getFinalizers()}
|
labels={getFinalizers()}
|
||||||
hidden={this.isHidden("finalizers")}
|
hidden={this.isHidden("finalizers")}
|
||||||
/>
|
/>
|
||||||
{ownerRefs && ownerRefs.length > 0 &&
|
{ownerRefs?.length > 0 &&
|
||||||
<DrawerItem name="Controlled By" hidden={this.isHidden("ownerReferences")}>
|
<DrawerItem name="Controlled By" hidden={this.isHidden("ownerReferences")}>
|
||||||
{
|
{
|
||||||
ownerRefs.map(ref => {
|
ownerRefs.map(ref => {
|
||||||
|
|||||||
@ -2,7 +2,6 @@ import "./sidebar.scss";
|
|||||||
import type { TabLayoutRoute } from "./tab-layout";
|
import type { TabLayoutRoute } from "./tab-layout";
|
||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { computed } from "mobx";
|
|
||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
import { NavLink } from "react-router-dom";
|
import { NavLink } from "react-router-dom";
|
||||||
import { cssNames } from "../../utils";
|
import { cssNames } from "../../utils";
|
||||||
@ -45,9 +44,13 @@ export class Sidebar extends React.Component<Props> {
|
|||||||
crdStore.reloadAll();
|
crdStore.reloadAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
@computed get crdSubMenus(): React.ReactNode {
|
renderCustomResources() {
|
||||||
if (!crdStore.isLoaded && crdStore.isLoading) {
|
if (crdStore.isLoading) {
|
||||||
return <Spinner centerHorizontal/>;
|
return (
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<Spinner />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Object.entries(crdStore.groups).map(([group, crds]) => {
|
return Object.entries(crdStore.groups).map(([group, crds]) => {
|
||||||
@ -268,7 +271,7 @@ export class Sidebar extends React.Component<Props> {
|
|||||||
icon={<Icon material="extension"/>}
|
icon={<Icon material="extension"/>}
|
||||||
>
|
>
|
||||||
{this.renderTreeFromTabRoutes(CustomResources.tabRoutes)}
|
{this.renderTreeFromTabRoutes(CustomResources.tabRoutes)}
|
||||||
{this.crdSubMenus}
|
{this.renderCustomResources()}
|
||||||
</SidebarItem>
|
</SidebarItem>
|
||||||
{this.renderRegisteredMenus()}
|
{this.renderRegisteredMenus()}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -34,12 +34,6 @@
|
|||||||
margin-top: calc(var(--spinner-size) / -2);
|
margin-top: calc(var(--spinner-size) / -2);
|
||||||
}
|
}
|
||||||
|
|
||||||
&.centerHorizontal {
|
|
||||||
position: absolute;
|
|
||||||
left: 50%;
|
|
||||||
margin-left: calc(var(--spinner-size) / -2);
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes rotate {
|
@keyframes rotate {
|
||||||
0% {
|
0% {
|
||||||
transform: rotate(0deg);
|
transform: rotate(0deg);
|
||||||
|
|||||||
@ -6,7 +6,6 @@ import { cssNames } from "../../utils";
|
|||||||
export interface SpinnerProps extends React.HTMLProps<any> {
|
export interface SpinnerProps extends React.HTMLProps<any> {
|
||||||
singleColor?: boolean;
|
singleColor?: boolean;
|
||||||
center?: boolean;
|
center?: boolean;
|
||||||
centerHorizontal?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Spinner extends React.Component<SpinnerProps, {}> {
|
export class Spinner extends React.Component<SpinnerProps, {}> {
|
||||||
@ -16,8 +15,8 @@ export class Spinner extends React.Component<SpinnerProps, {}> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { center, singleColor, centerHorizontal, className, ...props } = this.props;
|
const { center, singleColor, className, ...props } = this.props;
|
||||||
const classNames = cssNames("Spinner", className, { singleColor, center, centerHorizontal });
|
const classNames = cssNames("Spinner", className, { singleColor, center });
|
||||||
|
|
||||||
return <div {...props} className={classNames} />;
|
return <div {...props} className={classNames} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { addClusterURL } from "../components/+add-cluster";
|
import { addClusterURL } from "../components/+add-cluster";
|
||||||
import { extensionsURL } from "../components/+extensions";
|
|
||||||
import { catalogURL } from "../components/+catalog";
|
import { catalogURL } from "../components/+catalog";
|
||||||
|
import { attemptInstallByInfo, extensionsURL } from "../components/+extensions";
|
||||||
import { preferencesURL } from "../components/+preferences";
|
import { preferencesURL } from "../components/+preferences";
|
||||||
import { clusterViewURL } from "../components/cluster-manager/cluster-view.route";
|
import { clusterViewURL } from "../components/cluster-manager/cluster-view.route";
|
||||||
import { LensProtocolRouterRenderer } from "./router";
|
import { LensProtocolRouterRenderer } from "./router";
|
||||||
@ -8,6 +8,7 @@ import { navigate } from "../navigation/helpers";
|
|||||||
import { entitySettingsURL } from "../components/+entity-settings";
|
import { entitySettingsURL } from "../components/+entity-settings";
|
||||||
import { catalogEntityRegistry } from "../api/catalog-entity-registry";
|
import { catalogEntityRegistry } from "../api/catalog-entity-registry";
|
||||||
import { ClusterStore } from "../../common/cluster-store";
|
import { ClusterStore } from "../../common/cluster-store";
|
||||||
|
import { EXTENSION_NAME_MATCH, EXTENSION_PUBLISHER_MATCH, LensProtocolRouter } from "../../common/protocol-handler";
|
||||||
|
|
||||||
export function bindProtocolAddRouteHandlers() {
|
export function bindProtocolAddRouteHandlers() {
|
||||||
LensProtocolRouterRenderer
|
LensProtocolRouterRenderer
|
||||||
@ -33,9 +34,6 @@ export function bindProtocolAddRouteHandlers() {
|
|||||||
console.log("[APP-HANDLER]: catalog entity with given ID does not exist", { entityId });
|
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
|
// Handlers below are deprecated and only kept for backward compact purposes
|
||||||
.addInternalHandler("/cluster/:clusterId", ({ pathname: { clusterId } }) => {
|
.addInternalHandler("/cluster/:clusterId", ({ pathname: { clusterId } }) => {
|
||||||
const cluster = ClusterStore.getInstance().getById(clusterId);
|
const cluster = ClusterStore.getInstance().getById(clusterId);
|
||||||
@ -54,5 +52,18 @@ export function bindProtocolAddRouteHandlers() {
|
|||||||
} else {
|
} else {
|
||||||
console.log("[APP-HANDLER]: cluster with given ID does not exist", { clusterId });
|
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 });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
|
||||||
}
|
|
||||||
Loading…
Reference in New Issue
Block a user