Merge remote-tracking branch 'origin/master' into mobx-6.2

# Conflicts:
#	src/common/hotbar-store.ts
#	src/extensions/extension-discovery.ts
#	src/renderer/components/+apps-helm-charts/helm-chart-details.tsx
#	src/renderer/components/+apps-helm-charts/helm-chart.store.ts
#	src/renderer/components/+catalog/catalog.tsx
#	src/renderer/components/+extensions/extensions.tsx
#	src/renderer/components/confirm-dialog/confirm-dialog.tsx
#	src/renderer/components/hotbar/hotbar-icon.tsx
#	src/renderer/components/kube-object-status-icon/kube-object-status-icon.tsx
#	src/renderer/components/layout/sidebar.tsx
This commit is contained in:
Roman committed 2021-04-27 12:32:29 +03:00
commit e93bca5169
81 files changed
+2319 -2116

No files matched your search

+5 -1
View File
@@ -43,13 +43,17 @@ jest.mock("electron", () => {
},
ipcMain: {
handle: jest.fn(),
on: jest.fn()
on: jest.fn(),
removeAllListeners: jest.fn(),
off: jest.fn(),
send: jest.fn(),
}
};
});
describe("empty config", () => {
beforeEach(async () => {
ClusterStore.getInstance(false)?.unregisterIpcListener();
ClusterStore.resetInstance();
const mockOpts = {
"tmp": {
+3 -3
View File
@@ -125,8 +125,8 @@ export abstract class BaseStore<T = any> extends Singleton {
}
unregisterIpcListener() {
ipcRenderer.removeAllListeners(this.syncMainChannel);
ipcRenderer.removeAllListeners(this.syncRendererChannel);
ipcRenderer?.removeAllListeners(this.syncMainChannel);
ipcRenderer?.removeAllListeners(this.syncRendererChannel);
}
disableSync() {
@@ -168,7 +168,7 @@ export abstract class BaseStore<T = any> extends Singleton {
/**
* toJSON is called when syncing the store to the filesystem. It should
* produce a JSON serializable object representaion of the current state.
* produce a JSON serializable object representation of the current state.
*
* It is recommended that a round trip is valid. Namely, calling
* `this.fromStore(this.toJSON())` shouldn't change the state.
@@ -50,7 +50,7 @@ export class KubernetesCluster extends CatalogEntity<CatalogEntityMetadata, Kube
},
];
if (this.status.active) {
if (this.status.phase == "connected") {
context.menuItems.unshift({
icon: "link_off",
title: "Disconnect",
+16 -11
View File
@@ -1,5 +1,5 @@
import path from "path";
import { app, ipcRenderer, remote, webFrame } from "electron";
import { app, ipcMain, ipcRenderer, remote, webFrame } from "electron";
import { unlink } from "fs-extra";
import { action, comparer, computed, makeObservable, observable, reaction } from "mobx";
import { BaseStore } from "./base-store";
@@ -12,6 +12,7 @@ import { saveToAppFiles, toJS } from "./utils";
import { KubeConfig } from "@kubernetes/client-node";
import { handleRequest, requestMain, subscribeToBroadcast, unsubscribeAllFromBroadcast } from "./ipc";
import { ResourceType } from "../renderer/components/cluster-settings/components/cluster-metrics-setting";
import { disposer, noop } from "./utils";
export interface ClusterIconUpload {
clusterId: string;
@@ -111,6 +112,7 @@ export class ClusterStore extends BaseStore<ClusterStoreModel> {
@observable clusters = observable.map<ClusterId, Cluster>();
private static stateRequestChannel = "cluster:states";
protected disposer = disposer();
constructor() {
super({
@@ -145,7 +147,7 @@ export class ClusterStore extends BaseStore<ClusterStoreModel> {
cluster.setState(clusterState.state);
}
});
} else {
} else if (ipcMain) {
handleRequest(ClusterStore.stateRequestChannel, (): clusterStateSync[] => {
const states: clusterStateSync[] = [];
@@ -162,13 +164,16 @@ export class ClusterStore extends BaseStore<ClusterStoreModel> {
}
protected pushStateToViewsAutomatically() {
if (!ipcRenderer) {
reaction(() => this.enabledClustersList, () => {
this.pushState();
});
reaction(() => this.connectedClustersList, () => {
this.pushState();
});
if (ipcMain) {
this.disposer.push(
reaction(() => this.enabledClustersList, () => {
this.pushState();
}),
reaction(() => this.connectedClustersList, () => {
this.pushState();
}),
() => unsubscribeAllFromBroadcast("cluster:state"),
);
}
}
@@ -182,7 +187,7 @@ export class ClusterStore extends BaseStore<ClusterStoreModel> {
unregisterIpcListener() {
super.unregisterIpcListener();
unsubscribeAllFromBroadcast("cluster:state");
this.disposer();
}
pushState() {
@@ -290,7 +295,7 @@ export class ClusterStore extends BaseStore<ClusterStoreModel> {
// remove only custom kubeconfigs (pasted as text)
if (cluster.kubeConfigPath == ClusterStore.getCustomKubeConfigPath(clusterId)) {
unlink(cluster.kubeConfigPath).catch(() => null);
await unlink(cluster.kubeConfigPath).catch(noop);
}
}
}
+57 -2
View File
@@ -3,6 +3,9 @@ import { BaseStore } from "./base-store";
import migrations from "../migrations/hotbar-store";
import * as uuid from "uuid";
import { toJS } from "./utils";
import { CatalogEntityItem } from "../renderer/components/+catalog/catalog-entity.store";
import { CatalogEntity } from "./catalog/catalog-entity";
import isNull from "lodash/isNull";
export interface HotbarItem {
entity: {
@@ -30,6 +33,8 @@ export interface HotbarStoreModel {
activeHotbarId: string;
}
export const defaultHotbarCells = 12; // Number is choosen to easy hit any item with keyboard
export class HotbarStore extends BaseStore<HotbarStoreModel> {
@observable hotbars: Hotbar[] = [];
@observable private _activeHotbarId: string;
@@ -60,13 +65,17 @@ export class HotbarStore extends BaseStore<HotbarStoreModel> {
return this.hotbars.findIndex((hotbar) => hotbar.id === this.activeHotbarId);
}
get initialItems() {
return [...Array.from(Array(defaultHotbarCells).fill(null))];
}
@action
protected async fromStore(data: Partial<HotbarStoreModel> = {}) {
if (data.hotbars?.length === 0) {
this.hotbars = [{
id: uuid.v4(),
name: "Default",
items: []
items: this.initialItems,
}];
} else {
this.hotbars = data.hotbars;
@@ -98,7 +107,7 @@ export class HotbarStore extends BaseStore<HotbarStoreModel> {
add(data: HotbarCreateOptions) {
const {
id = uuid.v4(),
items = [],
items = this.initialItems,
name,
} = data;
@@ -118,6 +127,52 @@ export class HotbarStore extends BaseStore<HotbarStoreModel> {
}
}
addToHotbar(item: CatalogEntityItem, cellIndex = -1) {
const hotbar = this.getActive();
const newItem = { entity: { uid: item.id }};
if (hotbar.items.find(i => i?.entity.uid === item.id)) {
return;
}
if (cellIndex == -1) {
// Add item to empty cell
const emptyCellIndex = hotbar.items.findIndex(isNull);
if (emptyCellIndex != -1) {
hotbar.items[emptyCellIndex] = newItem;
} else {
// Add new item to the end of list
hotbar.items.push(newItem);
}
} else {
hotbar.items[cellIndex] = newItem;
}
}
removeFromHotbar(item: CatalogEntity) {
const hotbar = this.getActive();
const index = hotbar.items.findIndex((i) => i?.entity.uid === item.getId());
if (index == -1) {
return;
}
hotbar.items[index] = null;
}
addEmptyCell() {
const hotbar = this.getActive();
hotbar.items.push(null);
}
removeEmptyCell(index: number) {
const hotbar = this.getActive();
hotbar.items.splice(index, 1);
}
switchToPrevious() {
const hotbarStore = HotbarStore.getInstance();
let index = hotbarStore.activeHotbarIndex - 1;
+4 -4
View File
@@ -28,7 +28,7 @@ export async function broadcastMessage(channel: string, ...args: any[]) {
if (ipcRenderer) {
ipcRenderer.send(channel, ...args);
} else {
} else if (ipcMain) {
ipcMain.emit(channel, ...args);
}
@@ -55,7 +55,7 @@ export async function broadcastMessage(channel: string, ...args: any[]) {
export function subscribeToBroadcast(channel: string, listener: (...args: any[]) => any) {
if (ipcRenderer) {
ipcRenderer.on(channel, listener);
} else {
} else if (ipcMain) {
ipcMain.on(channel, listener);
}
@@ -65,7 +65,7 @@ export function subscribeToBroadcast(channel: string, listener: (...args: any[])
export function unsubscribeFromBroadcast(channel: string, listener: (...args: any[]) => any) {
if (ipcRenderer) {
ipcRenderer.off(channel, listener);
} else {
} else if (ipcMain) {
ipcMain.off(channel, listener);
}
}
@@ -73,7 +73,7 @@ export function unsubscribeFromBroadcast(channel: string, listener: (...args: an
export function unsubscribeAllFromBroadcast(channel: string) {
if (ipcRenderer) {
ipcRenderer.removeAllListeners(channel);
} else {
} else if (ipcMain) {
ipcMain.removeAllListeners(channel);
}
}
+3 -3
View File
@@ -23,8 +23,8 @@ export const ProtocolHandlerExtension= `${ProtocolHandlerIpcPrefix}:extension`;
* Though under the current (2021/01/18) implementation, these are never matched
* against in the final matching so their names are less of a concern.
*/
const EXTENSION_PUBLISHER_MATCH = "LENS_INTERNAL_EXTENSION_PUBLISHER_MATCH";
const EXTENSION_NAME_MATCH = "LENS_INTERNAL_EXTENSION_NAME_MATCH";
export const EXTENSION_PUBLISHER_MATCH = "LENS_INTERNAL_EXTENSION_PUBLISHER_MATCH";
export const EXTENSION_NAME_MATCH = "LENS_INTERNAL_EXTENSION_NAME_MATCH";
export abstract class LensProtocolRouter extends Singleton {
// Map between path schemas and the handlers
@@ -32,7 +32,7 @@ export abstract class LensProtocolRouter extends Singleton {
public static readonly LoggingPrefix = "[PROTOCOL ROUTER]";
protected static readonly ExtensionUrlSchema = `/:${EXTENSION_PUBLISHER_MATCH}(\@[A-Za-z0-9_]+)?/:${EXTENSION_NAME_MATCH}`;
static readonly ExtensionUrlSchema = `/:${EXTENSION_PUBLISHER_MATCH}(\@[A-Za-z0-9_]+)?/:${EXTENSION_NAME_MATCH}`;
/**
*
+20
View File
@@ -0,0 +1,20 @@
export type Disposer = () => void;
interface Extendable<T> {
push(...vals: T[]): void;
}
export type ExtendableDisposer = Disposer & Extendable<Disposer>;
export function disposer(...args: Disposer[]): ExtendableDisposer {
const res = () => {
args.forEach(dispose => dispose?.());
args.length = 0;
};
res.push = (...vals: Disposer[]) => {
args.push(...vals);
};
return res;
}
+12 -3
View File
@@ -6,13 +6,13 @@ export interface DownloadFileOptions {
timeout?: number;
}
export interface DownloadFileTicket {
export interface DownloadFileTicket<T> {
url: string;
promise: Promise<Buffer>;
promise: Promise<T>;
cancel(): void;
}
export function downloadFile({ url, timeout, gzip = true }: DownloadFileOptions): DownloadFileTicket {
export function downloadFile({ url, timeout, gzip = true }: DownloadFileOptions): DownloadFileTicket<Buffer> {
const fileChunks: Buffer[] = [];
const req = request(url, { gzip, timeout });
const promise: Promise<Buffer> = new Promise((resolve, reject) => {
@@ -35,3 +35,12 @@ export function downloadFile({ url, timeout, gzip = true }: DownloadFileOptions)
}
};
}
export function downloadJson(args: DownloadFileOptions): DownloadFileTicket<any> {
const { promise, ...rest } = downloadFile(args);
return {
promise: promise.then(res => JSON.parse(res.toString())),
...rest
};
}
+2
View File
@@ -20,6 +20,8 @@ export * from "./downloadFile";
export * from "./escapeRegExp";
export * from "./tar";
export * from "./type-narrowing";
export * from "./disposer";
import * as iter from "./iter";
export { iter };
+79 -3
View File
@@ -1,13 +1,89 @@
/**
* Narrows `val` to include the property `key` (if true is returned)
* @param val The object to be tested
* @param key The key to test if it is present on the object
* @param key The key to test if it is present on the object (must be a literal for tsc to do any meaningful typing)
*/
export function hasOwnProperty<V extends object, K extends PropertyKey>(val: V, key: K): val is (V & { [key in K]: unknown }) {
export function hasOwnProperty<S extends object, K extends PropertyKey>(val: S, key: K): val is (S & { [key in K]: unknown }) {
// this call syntax is for when `val` was created by `Object.create(null)`
return Object.prototype.hasOwnProperty.call(val, key);
}
export function hasOwnProperties<V extends object, K extends PropertyKey>(val: V, ...keys: K[]): val is (V & { [key in K]: unknown}) {
/**
* Narrows `val` to a static type that includes fields of names in `keys`
* @param val the value that we are trying to type narrow
* @param keys the key names (must be literals for tsc to do any meaningful typing)
*/
export function hasOwnProperties<S extends object, K extends PropertyKey>(val: S, ...keys: K[]): val is (S & { [key in K]: unknown }) {
return keys.every(key => hasOwnProperty(val, key));
}
/**
* Narrows `val` to include the property `key` with type `V`
* @param val the value that we are trying to type narrow
* @param key The key to test if it is present on the object (must be a literal for tsc to do any meaningful typing)
* @param isValid a function to check if the field is valid
*/
export function hasTypedProperty<S extends object, K extends PropertyKey, V>(val: S, key: K, isValid: (value: unknown) => value is V): val is (S & { [key in K]: V }) {
return hasOwnProperty(val, key) && isValid(val[key]);
}
/**
* Narrows `val` to include the property `key` with type `V | undefined` or doesn't contain it
* @param val the value that we are trying to type narrow
* @param key The key to test if it is present on the object (must be a literal for tsc to do any meaningful typing)
* @param isValid a function to check if the field (when present) is valid
*/
export function hasOptionalProperty<S extends object, K extends PropertyKey, V>(val: S, key: K, isValid: (value: unknown) => value is V): val is (S & { [key in K]?: V }) {
if (hasOwnProperty(val, key)) {
return typeof val[key] === "undefined" || isValid(val[key]);
}
return true;
}
/**
* isRecord checks if `val` matches the signature `Record<T, V>` or `{ [label in T]: V }`
* @param val The value to be checked
* @param isKey a function for checking if the key is of the correct type
* @param isValue a function for checking if a value is of the correct type
*/
export function isRecord<T extends PropertyKey, V>(val: unknown, isKey: (key: unknown) => key is T, isValue: (value: unknown) => value is V): val is Record<T, V> {
return isObject(val) && Object.entries(val).every(([key, value]) => isKey(key) && isValue(value));
}
/**
* isTypedArray checks if `val` is an array and all of its entries are of type `T`
* @param val The value to be checked
* @param isEntry a function for checking if an entry is the correct type
*/
export function isTypedArray<T>(val: unknown, isEntry: (entry: unknown) => entry is T): val is T[] {
return Array.isArray(val) && val.every(isEntry);
}
/**
* checks if val is of type string
* @param val the value to be checked
*/
export function isString(val: unknown): val is string {
return typeof val === "string";
}
/**
* checks if val is of type object and isn't null
* @param val the value to be checked
*/
export function isObject(val: unknown): val is object {
return typeof val === "object" && val !== null;
}
/**
* Creates a new predicate function (with the same predicate) from `fn`. Such
* that it can be called with just the value to be tested.
*
* This is useful for when using `hasOptionalProperty` and `hasTypedProperty`
* @param fn A typescript user predicate function to be bound
* @param boundArgs the set of arguments to be passed to `fn` in the new function
*/
export function bindPredicate<FnArgs extends any[], T>(fn: (arg1: unknown, ...args: FnArgs) => arg1 is T, ...boundArgs: FnArgs): (arg1: unknown) => arg1 is T {
return (arg1: unknown): arg1 is T => fn(arg1, ...boundArgs);
}
+8 -1
View File
@@ -1,5 +1,6 @@
// App's common configuration for any process (main, renderer, build pipeline, etc.)
import path from "path";
import { SemVer } from "semver";
import packageInfo from "../../package.json";
import { defineGlobal } from "./utils/defineGlobal";
@@ -44,5 +45,11 @@ export const apiKubePrefix = "/api-kube"; // k8s cluster apis
// Links
export const issuesTrackerUrl = "https://github.com/lensapp/lens/issues";
export const slackUrl = "https://join.slack.com/t/k8slens/shared_invite/enQtOTc5NjAyNjYyOTk4LWU1NDQ0ZGFkOWJkNTRhYTc2YjVmZDdkM2FkNGM5MjhiYTRhMDU2NDQ1MzIyMDA4ZGZlNmExOTc0N2JmY2M3ZGI";
export const docsUrl = "https://docs.k8slens.dev/";
export const supportUrl = "https://docs.k8slens.dev/latest/support/";
// This explicitly ignores the prerelease info on the package version
const { major, minor, patch } = new SemVer(packageInfo.version);
const mmpVersion = [major, minor, patch].join(".");
const docsVersion = isProduction ? `v${mmpVersion}` : "latest";
export const docsUrl = `https://docs.k8slens.dev/${docsVersion}`;
@@ -1,10 +1,14 @@
import mockFs from "mock-fs";
import { watch } from "chokidar";
import { join, normalize } from "path";
import { ExtensionDiscovery, InstalledExtension } from "../extension-discovery";
import { ExtensionsStore } from "../extensions-store";
import path from "path";
import { ExtensionDiscovery } from "../extension-discovery";
import os from "os";
import { Console } from "console";
jest.setTimeout(60_000);
jest.mock("../../common/ipc");
jest.mock("fs-extra");
jest.mock("chokidar", () => ({
watch: jest.fn()
}));
@@ -15,6 +19,7 @@ jest.mock("../extension-installer", () => ({
}
}));
console = new Console(process.stdout, process.stderr); // fix mockFS
const mockedWatch = watch as jest.MockedFunction<typeof watch>;
describe("ExtensionDiscovery", () => {
@@ -24,47 +29,59 @@ describe("ExtensionDiscovery", () => {
ExtensionsStore.createInstance();
});
it("emits add for added extension", async done => {
globalThis.__non_webpack_require__.mockImplementation(() => ({
name: "my-extension"
}));
let addHandler: (filePath: string) => void;
const mockWatchInstance: any = {
on: jest.fn((event: string, handler: typeof addHandler) => {
if (event === "add") {
addHandler = handler;
}
return mockWatchInstance;
})
};
mockedWatch.mockImplementationOnce(() =>
(mockWatchInstance) as any
);
const extensionDiscovery = ExtensionDiscovery.createInstance();
// Need to force isLoaded to be true so that the file watching is started
extensionDiscovery.isLoaded = true;
await extensionDiscovery.watchExtensions();
extensionDiscovery.events.on("add", (extension: InstalledExtension) => {
expect(extension).toEqual({
absolutePath: expect.any(String),
id: normalize("node_modules/my-extension/package.json"),
isBundled: false,
isEnabled: false,
manifest: {
name: "my-extension",
},
manifestPath: normalize("node_modules/my-extension/package.json"),
describe("with mockFs", () => {
beforeEach(() => {
mockFs({
[`${os.homedir()}/.k8slens/extensions/my-extension/package.json`]: JSON.stringify({
name: "my-extension"
}),
});
done();
});
addHandler(join(extensionDiscovery.localFolderPath, "/my-extension/package.json"));
afterEach(() => {
mockFs.restore();
});
it("emits add for added extension", async (done) => {
let addHandler: (filePath: string) => void;
const mockWatchInstance: any = {
on: jest.fn((event: string, handler: typeof addHandler) => {
if (event === "add") {
addHandler = handler;
}
return mockWatchInstance;
})
};
mockedWatch.mockImplementationOnce(() =>
(mockWatchInstance) as any
);
const extensionDiscovery = ExtensionDiscovery.createInstance();
// Need to force isLoaded to be true so that the file watching is started
extensionDiscovery.isLoaded = true;
await extensionDiscovery.watchExtensions();
extensionDiscovery.events.on("add", extension => {
expect(extension).toEqual({
absolutePath: expect.any(String),
id: path.normalize("node_modules/my-extension/package.json"),
isBundled: false,
isEnabled: false,
manifest: {
name: "my-extension",
},
manifestPath: path.normalize("node_modules/my-extension/package.json"),
});
done();
});
addHandler(path.join(extensionDiscovery.localFolderPath, "/my-extension/package.json"));
});
});
it("doesn't emit add for added file under extension", async done => {
@@ -94,7 +111,7 @@ describe("ExtensionDiscovery", () => {
extensionDiscovery.events.on("add", onAdd);
addHandler(join(extensionDiscovery.localFolderPath, "/my-extension/node_modules/dep/package.json"));
addHandler(path.join(extensionDiscovery.localFolderPath, "/my-extension/node_modules/dep/package.json"));
setTimeout(() => {
expect(onAdd).not.toHaveBeenCalled();
+65 -71
View File
@@ -1,15 +1,17 @@
import { watch } from "chokidar";
import { ipcRenderer } from "electron";
import { EventEmitter } from "events";
import fs from "fs-extra";
import fse from "fs-extra";
import { makeObservable, observable, reaction, when } from "mobx";
import os from "os";
import path from "path";
import { broadcastMessage, handleRequest, requestMain, subscribeToBroadcast } from "../common/ipc";
import { Singleton, toJS } from "../common/utils";
import logger from "../main/logger";
import { ExtensionInstallationStateStore } from "../renderer/components/+extensions/extension-install.store";
import { extensionInstaller, PackageJson } from "./extension-installer";
import { ExtensionsStore } from "./extensions-store";
import { ExtensionLoader } from "./extension-loader";
import type { LensExtensionId, LensExtensionManifest } from "./lens-extension";
export interface InstalledExtension {
@@ -38,7 +40,7 @@ interface ExtensionDiscoveryChannelMessage {
* Returns true if the lstat is for a directory-like file (e.g. isDirectory or symbolic link)
* @param lstat the stats to compare
*/
const isDirectoryLike = (lstat: fs.Stats) => lstat.isDirectory() || lstat.isSymbolicLink();
const isDirectoryLike = (lstat: fse.Stats) => lstat.isDirectory() || lstat.isSymbolicLink();
/**
* Discovers installed bundled and local extensions from the filesystem.
@@ -146,8 +148,10 @@ export class ExtensionDiscovery extends Singleton {
})
// Extension add is detected by watching "<extensionDir>/package.json" add
.on("add", this.handleWatchFileAdd)
// Extension remove is detected by watching <extensionDir>" unlink
.on("unlinkDir", this.handleWatchUnlinkDir);
// Extension remove is detected by watching "<extensionDir>" unlink
.on("unlinkDir", this.handleWatchUnlinkEvent)
// Extension remove is detected by watching "<extensionSymLink>" unlink
.on("unlink", this.handleWatchUnlinkEvent);
}
handleWatchFileAdd = async (manifestPath: string) => {
@@ -161,6 +165,7 @@ export class ExtensionDiscovery extends Singleton {
if (path.basename(manifestPath) === manifestFilename && isUnderLocalFolderPath) {
try {
ExtensionInstallationStateStore.setInstallingFromMain(manifestPath);
const absPath = path.dirname(manifestPath);
// this.loadExtensionFromPath updates this.packagesJson
@@ -168,7 +173,7 @@ export class ExtensionDiscovery extends Singleton {
if (extension) {
// Remove a broken symlink left by a previous installation if it exists.
await this.removeSymlinkByManifestPath(manifestPath);
await fse.remove(extension.manifestPath);
// Install dependencies for the new extension
await this.installPackage(extension.absolutePath);
@@ -178,40 +183,46 @@ export class ExtensionDiscovery extends Singleton {
this.events.emit("add", extension);
}
} catch (error) {
console.error(error);
logger.error(`${logModule}: failed to add extension: ${error}`, { error });
} finally {
ExtensionInstallationStateStore.clearInstallingFromMain(manifestPath);
}
}
};
handleWatchUnlinkDir = async (filePath: string) => {
// filePath is the non-symlinked path to the extension folder
// this.packagesJson.dependencies value is the non-symlinked path to the extension folder
// LensExtensionId in extension-loader is the symlinked path to the extension folder manifest file
/**
* Handle any unlink event, filtering out non-package.json links so the delete code
* only happens once per extension.
* @param filePath The absolute path to either a folder or file in the extensions folder
*/
handleWatchUnlinkEvent = async (filePath: string): Promise<void> => {
// Check that the removed path is directly under this.localFolderPath
// Note that the watcher can create unlink events for subdirectories of the extension
const extensionFolderName = path.basename(filePath);
const expectedPath = path.relative(this.localFolderPath, filePath);
if (path.relative(this.localFolderPath, filePath) === extensionFolderName) {
const extension = Array.from(this.extensions.values()).find((extension) => extension.absolutePath === filePath);
if (extension) {
const extensionName = extension.manifest.name;
// If the extension is deleted manually while the application is running, also remove the symlink
await this.removeSymlinkByPackageName(extensionName);
// The path to the manifest file is the lens extension id
// Note that we need to use the symlinked path
const lensExtensionId = extension.manifestPath;
this.extensions.delete(extension.id);
logger.info(`${logModule} removed extension ${extensionName}`);
this.events.emit("remove", lensExtensionId as LensExtensionId);
} else {
logger.warn(`${logModule} extension ${extensionFolderName} not found, can't remove`);
}
if (expectedPath !== extensionFolderName) {
return;
}
const extension = Array.from(this.extensions.values()).find((extension) => extension.absolutePath === filePath);
if (!extension) {
return void logger.warn(`${logModule} extension ${extensionFolderName} not found, can't remove`);
}
const extensionName = extension.manifest.name;
// If the extension is deleted manually while the application is running, also remove the symlink
await this.removeSymlinkByPackageName(extensionName);
// The path to the manifest file is the lens extension id
// Note: that we need to use the symlinked path
const lensExtensionId = extension.manifestPath;
this.extensions.delete(extension.id);
logger.info(`${logModule} removed extension ${extensionName}`);
this.events.emit("remove", lensExtensionId);
};
/**
@@ -221,31 +232,23 @@ export class ExtensionDiscovery extends Singleton {
* @param name e.g. "@mirantis/lens-extension-cc"
*/
removeSymlinkByPackageName(name: string) {
return fs.remove(this.getInstalledPath(name));
}
/**
* Remove the symlink under node_modules if it exists.
* @param manifestPath Path to package.json
*/
removeSymlinkByManifestPath(manifestPath: string) {
const manifestJson = __non_webpack_require__(manifestPath);
return this.removeSymlinkByPackageName(manifestJson.name);
return fse.remove(this.getInstalledPath(name));
}
/**
* Uninstalls extension.
* The application will detect the folder unlink and remove the extension from the UI automatically.
* @param extension Extension to uninstall.
* @param extensionId The ID of the extension to uninstall.
*/
async uninstallExtension({ absolutePath, manifest }: InstalledExtension) {
async uninstallExtension(extensionId: LensExtensionId) {
const { manifest, absolutePath } = this.extensions.get(extensionId) ?? ExtensionLoader.getInstance().getExtension(extensionId);
logger.info(`${logModule} Uninstalling ${manifest.name}`);
await this.removeSymlinkByPackageName(manifest.name);
// fs.remove does nothing if the path doesn't exist anymore
await fs.remove(absolutePath);
await fse.remove(absolutePath);
}
async load(): Promise<Map<LensExtensionId, InstalledExtension>> {
@@ -259,11 +262,10 @@ export class ExtensionDiscovery extends Singleton {
logger.info(`${logModule} loading extensions from ${extensionInstaller.extensionPackagesRoot}`);
// fs.remove won't throw if path is missing
await fs.remove(path.join(extensionInstaller.extensionPackagesRoot, "package-lock.json"));
await fse.remove(path.join(extensionInstaller.extensionPackagesRoot, "package-lock.json"));
try {
// Verify write access to static/extensions, which is needed for symlinking
await fs.access(this.inTreeFolderPath, fs.constants.W_OK);
await fse.access(this.inTreeFolderPath, fse.constants.W_OK);
// Set bundled folder path to static/extensions
this.bundledFolderPath = this.inTreeFolderPath;
@@ -272,20 +274,20 @@ export class ExtensionDiscovery extends Singleton {
// The error can happen if there is read-only rights to static/extensions, which would fail symlinking.
// Remove e.g. /Users/<username>/Library/Application Support/LensDev/extensions
await fs.remove(this.inTreeTargetPath);
await fse.remove(this.inTreeTargetPath);
// Create folder e.g. /Users/<username>/Library/Application Support/LensDev/extensions
await fs.ensureDir(this.inTreeTargetPath);
await fse.ensureDir(this.inTreeTargetPath);
// Copy static/extensions to e.g. /Users/<username>/Library/Application Support/LensDev/extensions
await fs.copy(this.inTreeFolderPath, this.inTreeTargetPath);
await fse.copy(this.inTreeFolderPath, this.inTreeTargetPath);
// Set bundled folder path to e.g. /Users/<username>/Library/Application Support/LensDev/extensions
this.bundledFolderPath = this.inTreeTargetPath;
}
await fs.ensureDir(this.nodeModulesPath);
await fs.ensureDir(this.localFolderPath);
await fse.ensureDir(this.nodeModulesPath);
await fse.ensureDir(this.localFolderPath);
const extensions = await this.ensureExtensions();
@@ -314,30 +316,22 @@ export class ExtensionDiscovery extends Singleton {
* Returns InstalledExtension from path to package.json file.
* Also updates this.packagesJson.
*/
protected async getByManifest(manifestPath: string, { isBundled = false }: {
isBundled?: boolean;
} = {}): Promise<InstalledExtension | null> {
let manifestJson: LensExtensionManifest;
protected async getByManifest(manifestPath: string, { isBundled = false } = {}): Promise<InstalledExtension | null> {
try {
// check manifest file for existence
fs.accessSync(manifestPath, fs.constants.F_OK);
manifestJson = __non_webpack_require__(manifestPath);
const installedManifestPath = this.getInstalledManifestPath(manifestJson.name);
const isEnabled = isBundled || ExtensionsStore.getInstance().isEnabled(installedManifestPath);
const manifest = await fse.readJson(manifestPath);
const installedManifestPath = this.getInstalledManifestPath(manifest.name);
const isEnabled = isBundled || ExtensionsStore.getInstance().isEnabled(installedManifestPath);
return {
id: installedManifestPath,
absolutePath: path.dirname(manifestPath),
manifestPath: installedManifestPath,
manifest: manifestJson,
manifest,
isBundled,
isEnabled
};
} catch (error) {
logger.error(`${logModule}: can't load extension manifest at ${manifestPath}: ${error}`, { manifestJson });
logger.error(`${logModule}: can't load extension manifest at ${manifestPath}: ${error}`);
return null;
}
@@ -351,7 +345,7 @@ export class ExtensionDiscovery extends Singleton {
const userExtensions = await this.loadFromFolder(this.localFolderPath, bundledExtensions.map((extension) => extension.manifest.name));
for (const extension of userExtensions) {
if ((await fs.pathExists(extension.manifestPath)) === false) {
if ((await fse.pathExists(extension.manifestPath)) === false) {
await this.installPackage(extension.absolutePath);
}
}
@@ -382,7 +376,7 @@ export class ExtensionDiscovery extends Singleton {
async loadBundledExtensions() {
const extensions: InstalledExtension[] = [];
const folderPath = this.bundledFolderPath;
const paths = await fs.readdir(folderPath);
const paths = await fse.readdir(folderPath);
for (const fileName of paths) {
const absPath = path.resolve(folderPath, fileName);
@@ -399,7 +393,7 @@ export class ExtensionDiscovery extends Singleton {
async loadFromFolder(folderPath: string, bundledExtensions: string[]): Promise<InstalledExtension[]> {
const extensions: InstalledExtension[] = [];
const paths = await fs.readdir(folderPath);
const paths = await fse.readdir(folderPath);
for (const fileName of paths) {
// do not allow to override bundled extensions
@@ -409,11 +403,11 @@ export class ExtensionDiscovery extends Singleton {
const absPath = path.resolve(folderPath, fileName);
if (!fs.existsSync(absPath)) {
if (!fse.existsSync(absPath)) {
continue;
}
const lstat = await fs.lstat(absPath);
const lstat = await fse.lstat(absPath);
// skip non-directories
if (!isDirectoryLike(lstat)) {
+13 -22
View File
@@ -13,8 +13,6 @@ import type { LensExtension, LensExtensionConstructor, LensExtensionId } from ".
import type { LensMainExtension } from "./lens-main-extension";
import type { LensRendererExtension } from "./lens-renderer-extension";
import * as registries from "./registries";
import fs from "fs";
export function extensionPackagesRoot() {
return path.join((app || remote.app).getPath("userData"));
@@ -222,6 +220,7 @@ export class ExtensionLoader extends Singleton {
registries.entitySettingRegistry.add(extension.entitySettings),
registries.statusBarRegistry.add(extension.statusBarItems),
registries.commandRegistry.add(extension.commands),
registries.welcomeMenuRegistry.add(extension.welcomeMenus),
];
this.events.on("remove", (removedExtension: LensRendererExtension) => {
@@ -296,28 +295,20 @@ export class ExtensionLoader extends Singleton {
});
}
protected requireExtension(extension: InstalledExtension): LensExtensionConstructor {
let extEntrypoint = "";
protected requireExtension(extension: InstalledExtension): LensExtensionConstructor | null {
const entryPointName = ipcRenderer ? "renderer" : "main";
const extRelativePath = extension.manifest[entryPointName];
if (!extRelativePath) {
return null;
}
const extAbsolutePath = path.resolve(path.join(path.dirname(extension.manifestPath), extRelativePath));
try {
if (ipcRenderer && extension.manifest.renderer) {
extEntrypoint = path.resolve(path.join(path.dirname(extension.manifestPath), extension.manifest.renderer));
} else if (!ipcRenderer && extension.manifest.main) {
extEntrypoint = path.resolve(path.join(path.dirname(extension.manifestPath), extension.manifest.main));
}
if (extEntrypoint !== "") {
if (!fs.existsSync(extEntrypoint)) {
console.log(`${logModule}: entrypoint ${extEntrypoint} not found, skipping ...`);
return;
}
return __non_webpack_require__(extEntrypoint).default;
}
} catch (err) {
console.error(`${logModule}: can't load extension main at ${extEntrypoint}: ${err}`, { extension });
console.trace(err);
return __non_webpack_require__(extAbsolutePath).default;
} catch (error) {
logger.error(`${logModule}: can't load extension main at ${extAbsolutePath}: ${error}`, { extension, error });
}
}
+5 -1
View File
@@ -1,4 +1,7 @@
import type { AppPreferenceRegistration, ClusterPageMenuRegistration, KubeObjectDetailRegistration, KubeObjectMenuRegistration, KubeObjectStatusRegistration, PageMenuRegistration, PageRegistration, StatusBarRegistration, } from "./registries";
import type {
AppPreferenceRegistration, ClusterPageMenuRegistration, KubeObjectDetailRegistration, KubeObjectMenuRegistration,
KubeObjectStatusRegistration, PageMenuRegistration, PageRegistration, StatusBarRegistration, WelcomeMenuRegistration,
} from "./registries";
import type { Cluster } from "../main/cluster";
import { LensExtension } from "./lens-extension";
import { getExtensionPageUrl } from "./registries/page-registry";
@@ -17,6 +20,7 @@ export class LensRendererExtension extends LensExtension {
kubeObjectDetailItems: KubeObjectDetailRegistration[] = [];
kubeObjectMenuItems: KubeObjectMenuRegistration[] = [];
commands: CommandRegistration[] = [];
welcomeMenus: WelcomeMenuRegistration[] = [];
async navigate<P extends object>(pageId?: string, params?: P) {
const { navigate } = await import("../renderer/navigation");
+1
View File
@@ -10,3 +10,4 @@ export * from "./kube-object-menu-registry";
export * from "./kube-object-status-registry";
export * from "./command-registry";
export * from "./entity-setting-registry";
export * from "./welcome-menu-registry";
@@ -9,9 +9,17 @@ export interface KubeObjectStatusRegistration {
export class KubeObjectStatusRegistry extends BaseRegistry<KubeObjectStatusRegistration> {
getItemsForKind(kind: string, apiVersion: string) {
return this.getItems().filter((item) => {
return item.kind === kind && item.apiVersions.includes(apiVersion);
});
return this.getItems()
.filter((item) => (
item.kind === kind
&& item.apiVersions.includes(apiVersion)
));
}
getItemsForObject(src: KubeObject) {
return this.getItemsForKind(src.kind, src.apiVersion)
.map(item => item.resolve(src))
.filter(Boolean);
}
}
@@ -0,0 +1,11 @@
import { BaseRegistry } from "./base-registry";
export interface WelcomeMenuRegistration {
title: string;
icon: string;
click: () => void | Promise<void>;
}
export class WelcomeMenuRegistry extends BaseRegistry<WelcomeMenuRegistration> {}
export const welcomeMenuRegistry = new WelcomeMenuRegistry();
+2 -2
View File
@@ -1,6 +1,6 @@
import { app, remote } from "electron";
import winston from "winston";
import { isDebugging } from "../common/vars";
import { isDebugging, isTestEnv } from "../common/vars";
const logLevel = process.env.LOG_LEVEL ? process.env.LOG_LEVEL : isDebugging ? "debug" : "info";
const consoleOptions: winston.transports.ConsoleTransportOptions = {
@@ -23,7 +23,7 @@ const logger = winston.createLogger({
),
transports: [
new winston.transports.Console(consoleOptions),
new winston.transports.File(fileOptions),
...(isTestEnv ? [] : [new winston.transports.File(fileOptions)]),
],
});
+3 -3
View File
@@ -4,7 +4,7 @@ import { WindowManager } from "./window-manager";
import { appName, isMac, isWindows, isTestEnv, docsUrl, supportUrl, productName } from "../common/vars";
import { addClusterURL } from "../renderer/components/+add-cluster/add-cluster.route";
import { preferencesURL } from "../renderer/components/+preferences/preferences.route";
import { whatsNewURL } from "../renderer/components/+whats-new/whats-new.route";
import { welcomeURL } from "../renderer/components/+welcome/welcome.route";
import { extensionsURL } from "../renderer/components/+extensions/extensions.route";
import { catalogURL } from "../renderer/components/+catalog/catalog.route";
import { menuRegistry } from "../extensions/registries/menu-registry";
@@ -201,9 +201,9 @@ export function buildMenu(windowManager: WindowManager) {
role: "help",
submenu: [
{
label: "What's new?",
label: "Welcome",
click() {
navigate(whatsNewURL());
navigate(welcomeURL());
},
},
{
@@ -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);
});
});
});
+41 -29
View File
@@ -16,39 +16,51 @@ const endpoint = compile(`/v2/charts/:repo?/:name?`) as (params?: {
name?: string;
}) => string;
export const helmChartsApi = {
list() {
return apiBase
.get<HelmChartList>(endpoint())
.then(data => {
return Object
.values(data)
.reduce((allCharts, repoCharts) => allCharts.concat(Object.values(repoCharts)), [])
.map(([chart]) => HelmChart.create(chart));
});
},
/**
* Get a list of all helm charts from all saved helm repos
*/
export async function listCharts(): Promise<HelmChart[]> {
const data = await apiBase.get<HelmChartList>(endpoint());
get(repo: string, name: string, readmeVersion?: string) {
const path = endpoint({ repo, name });
return Object
.values(data)
.reduce((allCharts, repoCharts) => allCharts.concat(Object.values(repoCharts)), [])
.map(([chart]) => HelmChart.create(chart));
}
return apiBase
.get<IHelmChartDetails>(`${path}?${stringify({ version: readmeVersion })}`)
.then(data => {
const versions = data.versions.map(HelmChart.create);
const readme = data.readme;
export interface GetChartDetailsOptions {
version?: string;
reqInit?: RequestInit;
}
return {
readme,
versions,
};
});
},
/**
* Get the readme and all versions of a chart
* @param repo The repo to get from
* @param name The name of the chart to request the data of
* @param options.version The version of the chart's readme to get, default latest
* @param options.reqInit A way for passing in an abort controller or other browser request options
*/
export async function getChartDetails(repo: string, name: string, { version, reqInit }: GetChartDetailsOptions = {}): Promise<IHelmChartDetails> {
const path = endpoint({ repo, name });
getValues(repo: string, name: string, version: string) {
return apiBase
.get<string>(`/v2/charts/${repo}/${name}/values?${stringify({ version })}`);
}
};
const { readme, ...data } = await apiBase.get<IHelmChartDetails>(`${path}?${stringify({ version })}`, undefined, reqInit);
const versions = data.versions.map(HelmChart.create);
return {
readme,
versions,
};
}
/**
* Get chart values related to a specific repos' version of a chart
* @param repo The repo to get from
* @param name The name of the chart to request the data of
* @param version The version to get the values from
*/
export async function getChartValues(repo: string, name: string, version: string): Promise<string> {
return apiBase.get<string>(`/v2/charts/${repo}/${name}/values?${stringify({ version })}`);
}
@autobind()
export class HelmChart {
+35 -32
View File
@@ -2,8 +2,8 @@
import { stringify } from "querystring";
import { EventEmitter } from "../../common/event-emitter";
import { cancelableFetch } from "../utils/cancelableFetch";
import { randomBytes } from "crypto";
export interface JsonApiData {
}
@@ -72,13 +72,11 @@ export class JsonApi<D = JsonApiData, P extends JsonApiParams = JsonApiParams> {
reqUrl += (reqUrl.includes("?") ? "&" : "?") + queryString;
}
const infoLog: JsonApiLog = {
this.writeLog({
method: reqInit.method.toUpperCase(),
reqUrl: reqPath,
reqInit,
};
this.writeLog({ ...infoLog });
});
return fetch(reqUrl, reqInit);
}
@@ -99,7 +97,7 @@ export class JsonApi<D = JsonApiData, P extends JsonApiParams = JsonApiParams> {
return this.request<T>(path, params, { ...reqInit, method: "delete" });
}
protected request<D>(path: string, params?: P, init: RequestInit = {}) {
protected async request<D>(path: string, params?: P, init: RequestInit = {}) {
let reqUrl = this.config.apiBase + path;
const reqInit: RequestInit = { ...this.reqInit, ...init };
const { data, query } = params || {} as P;
@@ -119,48 +117,53 @@ export class JsonApi<D = JsonApiData, P extends JsonApiParams = JsonApiParams> {
reqInit,
};
return cancelableFetch(reqUrl, reqInit).then(res => {
return this.parseResponse<D>(res, infoLog);
});
const res = await fetch(reqUrl, reqInit);
return this.parseResponse<D>(res, infoLog);
}
protected parseResponse<D>(res: Response, log: JsonApiLog): Promise<D> {
protected async parseResponse<D>(res: Response, log: JsonApiLog): Promise<D> {
const { status } = res;
return res.text().then(text => {
let data;
const text = await res.text();
let data;
try {
data = text ? JSON.parse(text) : ""; // DELETE-requests might not have response-body
} catch (e) {
data = text;
}
try {
data = text ? JSON.parse(text) : ""; // DELETE-requests might not have response-body
} catch (e) {
data = text;
}
if (status >= 200 && status < 300) {
this.onData.emit(data, res);
this.writeLog({ ...log, data });
if (status >= 200 && status < 300) {
this.onData.emit(data, res);
this.writeLog({ ...log, data });
return data;
} else if (log.method === "GET" && res.status === 403) {
this.writeLog({ ...log, data });
} else {
const error = new JsonApiErrorParsed(data, this.parseError(data, res));
return data;
}
this.onError.emit(error, res);
this.writeLog({ ...log, error });
throw error;
}
});
if (log.method === "GET" && res.status === 403) {
this.writeLog({ ...log, error: data });
throw data;
}
const error = new JsonApiErrorParsed(data, this.parseError(data, res));
this.onError.emit(error, res);
this.writeLog({ ...log, error });
throw error;
}
protected parseError(error: JsonApiError | string, res: Response): string[] {
if (typeof error === "string") {
return [error];
}
else if (Array.isArray(error.errors)) {
if (Array.isArray(error.errors)) {
return error.errors.map(error => error.title);
}
else if (error.message) {
if (error.message) {
return [error.message];
}
+115 -100
View File
@@ -7,11 +7,12 @@ import logger from "../../main/logger";
import { apiManager } from "./api-manager";
import { apiKube } from "./index";
import { createKubeApiURL, parseKubeApi } from "./kube-api-parse";
import { KubeJsonApi, KubeJsonApiData, KubeJsonApiDataList } from "./kube-json-api";
import { IKubeObjectConstructor, KubeObject, KubeStatus } from "./kube-object";
import byline from "byline";
import { IKubeWatchEvent } from "./kube-watch-api";
import { ReadableWebToNodeStream } from "../utils/readableStream";
import { KubeJsonApi, KubeJsonApiData } from "./kube-json-api";
import { noop } from "../utils";
export interface IKubeApiOptions<T extends KubeObject> {
/**
@@ -34,6 +35,11 @@ export interface IKubeApiOptions<T extends KubeObject> {
checkPreferredVersion?: boolean;
}
export interface KubeApiListOptions {
namespace?: string;
reqInit?: RequestInit;
}
export interface IKubeApiQueryParams {
watch?: boolean | number;
resourceVersion?: string;
@@ -245,7 +251,7 @@ export class KubeApi<T extends KubeObject = any> {
return this.resourceVersions.get(namespace);
}
async refreshResourceVersion(params?: { namespace: string }) {
async refreshResourceVersion(params?: KubeApiListOptions) {
return this.list(params, { limit: 1 });
}
@@ -273,20 +279,12 @@ export class KubeApi<T extends KubeObject = any> {
return query;
}
protected parseResponse(data: KubeJsonApiData | KubeJsonApiData[] | KubeJsonApiDataList, namespace?: string): any {
protected parseResponse(data: unknown, namespace?: string): T | T[] | null {
if (!data) return;
const KubeObjectConstructor = this.objectConstructor;
if (KubeObject.isJsonApiData(data)) {
const object = new KubeObjectConstructor(data);
ensureObjectSelfLink(this, object);
return object;
}
// process items list response
if (KubeObject.isJsonApiDataList(data)) {
// process items list response, check before single item since there is overlap
if (KubeObject.isJsonApiDataList(data, KubeObject.isPartialJsonApiData)) {
const { apiVersion, items, metadata } = data;
this.setResourceVersion(namespace, metadata.resourceVersion);
@@ -305,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.
if (Array.isArray(data)) {
return data.map(data => new KubeObjectConstructor(data));
}
return data;
return null;
}
async list({ namespace = "" } = {}, query?: IKubeApiQueryParams): Promise<T[]> {
async list({ namespace = "", reqInit }: KubeApiListOptions = {}, query?: IKubeApiQueryParams): Promise<T[] | null> {
await this.checkPreferredVersion();
return this.request
.get(this.getUrl({ namespace }), { query })
.then(data => this.parseResponse(data, namespace));
const url = this.getUrl({ namespace });
const res = await this.request.get(url, { query }, reqInit);
const parsed = this.parseResponse(res, namespace);
if (Array.isArray(parsed)) {
return parsed;
}
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();
return this.request
.get(this.getUrl({ namespace, name }), { query })
.then(this.parseResponse);
const url = this.getUrl({ namespace, name });
const res = await this.request.get(url, { query });
const parsed = this.parseResponse(res);
if (Array.isArray(parsed)) {
throw new Error(`GET single request to ${url} returned an array: ${JSON.stringify(parsed)}`);
}
return parsed;
}
async create({ name = "", namespace = "default" } = {}, data?: Partial<T>): Promise<T> {
async create({ name = "", namespace = "default" } = {}, data?: Partial<T>): Promise<T | null> {
await this.checkPreferredVersion();
const apiUrl = this.getUrl({ namespace });
const res = await this.request.post(apiUrl, {
data: merge({
kind: this.kind,
apiVersion: this.apiVersionWithGroup,
metadata: {
name,
namespace
}
}, data)
});
const parsed = this.parseResponse(res);
return this.request
.post(apiUrl, {
data: merge({
kind: this.kind,
apiVersion: this.apiVersionWithGroup,
metadata: {
name,
namespace
}
}, data)
})
.then(this.parseResponse);
if (Array.isArray(parsed)) {
throw new Error(`POST request to ${apiUrl} returned an array: ${JSON.stringify(parsed)}`);
}
return parsed;
}
async update({ name = "", namespace = "default" } = {}, data?: Partial<T>): Promise<T> {
async update({ name = "", namespace = "default" } = {}, data?: Partial<T>): Promise<T | null> {
await this.checkPreferredVersion();
const apiUrl = this.getUrl({ namespace, name });
return this.request
.put(apiUrl, { data })
.then(this.parseResponse);
const res = await this.request.put(apiUrl, { data });
const parsed = this.parseResponse(res);
if (Array.isArray(parsed)) {
throw new Error(`PUT request to ${apiUrl} returned an array: ${JSON.stringify(parsed)}`);
}
return parsed;
}
async delete({ name = "", namespace = "default" }) {
@@ -372,78 +405,60 @@ export class KubeApi<T extends KubeObject = any> {
}
watch(opts: KubeApiWatchOptions = { namespace: "" }): () => void {
if (!opts.abortController) {
opts.abortController = new AbortController();
}
let errorReceived = false;
let timedRetry: NodeJS.Timeout;
const { abortController, namespace, callback } = opts;
const { abortController: { abort, signal } = new AbortController(), namespace, callback = noop } = opts;
abortController.signal.addEventListener("abort", () => {
signal.addEventListener("abort", () => {
clearTimeout(timedRetry);
});
const watchUrl = this.getWatchUrl(namespace);
const responsePromise = this.request.getResponse(watchUrl, null, {
signal: abortController.signal
});
const responsePromise = this.request.getResponse(watchUrl, null, { signal });
responsePromise.then((response) => {
if (!response.ok && !abortController.signal.aborted) {
callback?.(null, response);
return;
}
const nodeStream = new ReadableWebToNodeStream(response.body);
["end", "close", "error"].forEach((eventName) => {
nodeStream.on(eventName, () => {
if (errorReceived) return; // kubernetes errors should be handled in a callback
clearTimeout(timedRetry);
timedRetry = setTimeout(() => { // we did not get any kubernetes errors so let's retry
if (abortController.signal.aborted) return;
this.watch({...opts, namespace, callback});
}, 1000);
});
});
const stream = byline(nodeStream);
stream.on("data", (line) => {
try {
const event: IKubeWatchEvent = JSON.parse(line);
if (event.type === "ERROR" && event.object.kind === "Status") {
errorReceived = true;
callback(null, new KubeStatus(event.object as any));
return;
}
this.modifyWatchEvent(event);
if (callback) {
callback(event, null);
}
} catch (ignore) {
// ignore parse errors
responsePromise
.then(response => {
if (!response.ok) {
return callback(null, response);
}
const nodeStream = new ReadableWebToNodeStream(response.body);
["end", "close", "error"].forEach((eventName) => {
nodeStream.on(eventName, () => {
if (errorReceived) return; // kubernetes errors should be handled in a callback
clearTimeout(timedRetry);
timedRetry = setTimeout(() => { // we did not get any kubernetes errors so let's retry
this.watch({...opts, namespace, callback});
}, 1000);
});
});
byline(nodeStream).on("data", (line) => {
try {
const event: IKubeWatchEvent = JSON.parse(line);
if (event.type === "ERROR" && event.object.kind === "Status") {
errorReceived = true;
return callback(null, new KubeStatus(event.object as any));
}
this.modifyWatchEvent(event);
callback(event, null);
} catch (ignore) {
// ignore parse errors
}
});
})
.catch(error => {
if (error instanceof DOMException) return; // AbortController rejects, we can ignore it
callback(null, error);
});
}, (error) => {
if (error instanceof DOMException) return; // AbortController rejects, we can ignore it
callback?.(null, error);
}).catch((error) => {
callback?.(null, error);
});
const disposer = () => {
abortController.abort();
};
return disposer;
return abort;
}
protected modifyWatchEvent(event: IKubeWatchEvent) {
+23 -19
View File
@@ -1,34 +1,38 @@
import { JsonApi, JsonApiData, JsonApiError } from "./json-api";
export interface KubeJsonApiListMetadata {
resourceVersion: string;
selfLink?: string;
}
export interface KubeJsonApiDataList<T = KubeJsonApiData> {
kind: string;
apiVersion: string;
items: T[];
metadata: {
resourceVersion: string;
selfLink: string;
metadata: KubeJsonApiListMetadata;
}
export interface KubeJsonApiMetadata {
uid: string;
name: string;
namespace?: string;
creationTimestamp?: string;
resourceVersion: string;
continue?: string;
finalizers?: string[];
selfLink?: string;
labels?: {
[label: string]: string;
};
annotations?: {
[annotation: string]: string;
};
}
export interface KubeJsonApiData extends JsonApiData {
kind: string;
apiVersion: string;
metadata: {
uid: string;
name: string;
namespace?: string;
creationTimestamp?: string;
resourceVersion: string;
continue?: string;
finalizers?: string[];
selfLink?: string;
labels?: {
[label: string]: string;
};
annotations?: {
[annotation: string]: string;
};
};
metadata: KubeJsonApiMetadata;
}
export interface KubeJsonApiError extends JsonApiError {
+51 -6
View File
@@ -1,12 +1,13 @@
// Base class for all kubernetes objects
import moment from "moment";
import { KubeJsonApiData, KubeJsonApiDataList } from "./kube-json-api";
import { KubeJsonApiData, KubeJsonApiDataList, KubeJsonApiListMetadata, KubeJsonApiMetadata } from "./kube-json-api";
import { autobind, formatDuration } from "../utils";
import { ItemObject } from "../item.store";
import { apiKube } from "./index";
import { JsonApiParams } from "./json-api";
import { resourceApplierApi } from "./endpoints/resource-applier.api";
import { hasOptionalProperty, hasTypedProperty, isObject, isString, bindPredicate, isTypedArray, isRecord } from "../../common/utils/type-narrowing";
export type IKubeObjectConstructor<T extends KubeObject = any> = (new (data: KubeJsonApiData | any) => T) & {
kind?: string;
@@ -78,15 +79,59 @@ export class KubeObject implements ItemObject {
return !item.metadata.name.startsWith("system:");
}
static isJsonApiData(object: any): object is KubeJsonApiData {
return !object.items && object.metadata;
static isJsonApiData(object: unknown): object is KubeJsonApiData {
return (
isObject(object)
&& hasTypedProperty(object, "kind", isString)
&& hasTypedProperty(object, "apiVersion", isString)
&& hasTypedProperty(object, "metadata", KubeObject.isKubeJsonApiMetadata)
);
}
static isJsonApiDataList(object: any): object is KubeJsonApiDataList {
return object.items && object.metadata;
static isKubeJsonApiListMetadata(object: unknown): object is KubeJsonApiListMetadata {
return (
isObject(object)
&& hasTypedProperty(object, "resourceVersion", isString)
&& hasOptionalProperty(object, "selfLink", isString)
);
}
static stringifyLabels(labels: { [name: string]: string }): string[] {
static isKubeJsonApiMetadata(object: unknown): object is KubeJsonApiMetadata {
return (
isObject(object)
&& hasTypedProperty(object, "uid", isString)
&& hasTypedProperty(object, "name", isString)
&& hasTypedProperty(object, "resourceVersion", isString)
&& hasOptionalProperty(object, "selfLink", isString)
&& hasOptionalProperty(object, "namespace", isString)
&& hasOptionalProperty(object, "creationTimestamp", isString)
&& hasOptionalProperty(object, "continue", isString)
&& hasOptionalProperty(object, "finalizers", bindPredicate(isTypedArray, isString))
&& hasOptionalProperty(object, "labels", bindPredicate(isRecord, isString, isString))
&& hasOptionalProperty(object, "annotations", bindPredicate(isRecord, isString, isString))
);
}
static isPartialJsonApiData(object: unknown): object is Partial<KubeJsonApiData> {
return (
isObject(object)
&& hasOptionalProperty(object, "kind", isString)
&& hasOptionalProperty(object, "apiVersion", isString)
&& hasOptionalProperty(object, "metadata", KubeObject.isKubeJsonApiMetadata)
);
}
static isJsonApiDataList<T>(object: unknown, verifyItem:(val: unknown) => val is T): object is KubeJsonApiDataList<T> {
return (
isObject(object)
&& hasTypedProperty(object, "kind", isString)
&& hasTypedProperty(object, "apiVersion", isString)
&& hasTypedProperty(object, "metadata", KubeObject.isKubeJsonApiListMetadata)
&& hasTypedProperty(object, "items", bindPredicate(isTypedArray, verifyItem))
);
}
static stringifyLabels(labels?: { [name: string]: string }): string[] {
if (!labels) return [];
return Object.entries(labels).map(([name, value]) => `${name}=${value}`);
+2
View File
@@ -20,6 +20,7 @@ import { App } from "./components/app";
import { LensApp } from "./lens-app";
import { ThemeStore } from "./theme.store";
import { HelmRepoManager } from "../main/helm/helm-repo-manager";
import { ExtensionInstallationStateStore } from "./components/+extensions/extension-install.store";
/**
* If this is a development buid, wait a second to attach
@@ -61,6 +62,7 @@ export async function bootstrap(App: AppComponent) {
const themeStore = ThemeStore.createInstance();
const hotbarStore = HotbarStore.createInstance();
ExtensionInstallationStateStore.bindIpcListeners();
HelmRepoManager.createInstance(); // initialize the manager
// preload common stores
@@ -1,14 +1,13 @@
import "./helm-chart-details.scss";
import React, { Component } from "react";
import { HelmChart, helmChartsApi } from "../../api/endpoints/helm-charts.api";
import { getChartDetails, HelmChart } from "../../api/endpoints/helm-charts.api";
import { observable, autorun, makeObservable } from "mobx";
import { observer } from "mobx-react";
import { Drawer, DrawerItem } from "../drawer";
import { autobind, stopPropagation } from "../../utils";
import { MarkdownViewer } from "../markdown-viewer";
import { Spinner } from "../spinner";
import { CancelablePromise } from "../../utils/cancelableFetch";
import { Button } from "../button";
import { Select, SelectOption } from "../select";
import { createInstallChartTab } from "../dock/install-chart.store";
@@ -26,7 +25,7 @@ export class HelmChartDetails extends Component<Props> {
@observable readme: string = null;
@observable error: string = null;
private chartPromise: CancelablePromise<{ readme: string; versions: HelmChart[] }>;
private abortController?: AbortController;
constructor(props: Props) {
super(props);
@@ -34,32 +33,34 @@ export class HelmChartDetails extends Component<Props> {
}
componentWillUnmount() {
this.chartPromise?.cancel();
this.abortController?.abort();
}
chartUpdater = autorun(() => {
this.selectedChart = null;
const { chart: { name, repo, version } } = this.props;
helmChartsApi.get(repo, name, version).then(result => {
this.readme = result.readme;
this.chartVersions = result.versions;
this.selectedChart = result.versions[0];
},
error => {
this.error = error;
});
getChartDetails(repo, name, { version })
.then(result => {
this.readme = result.readme;
this.chartVersions = result.versions;
this.selectedChart = result.versions[0];
})
.catch(error => {
this.error = error;
});
});
@autobind()
async onVersionChange({ value: version }: SelectOption) {
async onVersionChange({ value: version }: SelectOption<string>) {
this.selectedChart = this.chartVersions.find(chart => chart.version === version);
this.readme = null;
try {
this.chartPromise?.cancel();
this.abortController?.abort();
this.abortController = new AbortController();
const { chart: { name, repo } } = this.props;
const { readme } = await (this.chartPromise = helmChartsApi.get(repo, name, version));
const { readme } = await getChartDetails(repo, name, { version, reqInit: { signal: this.abortController.signal }});
this.readme = readme;
} catch (error) {
@@ -1,6 +1,6 @@
import semver from "semver";
import { makeObservable, observable } from "mobx";
import { HelmChart, helmChartsApi } from "../../api/endpoints/helm-charts.api";
import { getChartDetails, HelmChart, listCharts } from "../../api/endpoints/helm-charts.api";
import { ItemStore } from "../../item.store";
import flatten from "lodash/flatten";
@@ -20,7 +20,7 @@ export class HelmChartStore extends ItemStore<HelmChart> {
async loadAll() {
try {
const res = await this.loadItems(() => helmChartsApi.list());
const res = await this.loadItems(() => listCharts());
this.failedLoading = false;
@@ -52,13 +52,13 @@ export class HelmChartStore extends ItemStore<HelmChart> {
return versions;
}
const loadVersions = (repo: string) => {
return helmChartsApi.get(repo, chartName).then(({ versions }) => {
return versions.map(chart => ({
repo,
version: chart.getVersion()
}));
});
const loadVersions = async (repo: string) => {
const { versions } = await getChartDetails(repo, chartName);
return versions.map(chart => ({
repo,
version: chart.getVersion()
}));
};
if (!this.isLoaded) {
+2 -18
View File
@@ -13,7 +13,6 @@ import { CatalogEntityContextMenu, CatalogEntityContextMenuContext, catalogEntit
import { Badge } from "../badge";
import { HotbarStore } from "../../../common/hotbar-store";
import { autobind } from "../../utils";
import { Notifications } from "../notifications";
import { ConfirmDialog } from "../confirm-dialog";
import { Tab, Tabs } from "../tabs";
import { catalogCategoryRegistry } from "../../../common/catalog";
@@ -51,25 +50,10 @@ export class Catalog extends React.Component {
}
}, { fireImmediately: true })
]);
setTimeout(() => {
if (this.catalogEntityStore.items.length === 0) {
Notifications.info(<><b>Welcome!</b><p>Get started by associating one or more clusters to Lens</p></>, {
timeout: 30_000,
id: "catalog-welcome"
});
}
}, 2_000);
}
addToHotbar(item: CatalogEntityItem) {
const hotbar = HotbarStore.getInstance().getActive();
if (!hotbar) {
return;
}
hotbar.items.push({ entity: { uid: item.id } });
addToHotbar(item: CatalogEntityItem): void {
HotbarStore.getInstance().addToHotbar(item);
}
onDetails(item: CatalogEntityItem) {
@@ -47,7 +47,8 @@ export class HorizontalPodAutoscalers extends React.Component<Props> {
[columnId.namespace]: (item: HorizontalPodAutoscaler) => item.getNs(),
[columnId.minPods]: (item: HorizontalPodAutoscaler) => item.getMinPods(),
[columnId.maxPods]: (item: HorizontalPodAutoscaler) => item.getMaxPods(),
[columnId.replicas]: (item: HorizontalPodAutoscaler) => item.getReplicas()
[columnId.replicas]: (item: HorizontalPodAutoscaler) => item.getReplicas(),
[columnId.age]: (item: HorizontalPodAutoscaler) => item.getTimeDiffFromNow(),
}}
searchFilters={[
(item: HorizontalPodAutoscaler) => item.getSearchFields()
@@ -1,16 +1,18 @@
import "@testing-library/jest-dom/extend-expect";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { fireEvent, render, waitFor } from "@testing-library/react";
import fse from "fs-extra";
import React from "react";
import { UserStore } from "../../../../common/user-store";
import { ExtensionDiscovery } from "../../../../extensions/extension-discovery";
import { ExtensionLoader } from "../../../../extensions/extension-loader";
import { ThemeStore } from "../../../theme.store";
import { ConfirmDialog } from "../../confirm-dialog";
import { Notifications } from "../../notifications";
import { ExtensionInstallationStateStore } from "../extension-install.store";
import { Extensions } from "../extensions";
import mockFs from "mock-fs";
jest.setTimeout(30000);
jest.mock("fs-extra");
jest.mock("../../notifications");
jest.mock("../../../../common/utils", () => ({
...jest.requireActual("../../../../common/utils"),
@@ -20,37 +22,30 @@ jest.mock("../../../../common/utils", () => ({
extractTar: jest.fn(() => Promise.resolve())
}));
jest.mock("../../notifications", () => ({
ok: jest.fn(),
error: jest.fn(),
info: jest.fn()
jest.mock("electron", () => ({
app: {
getVersion: () => "99.99.99",
getPath: () => "tmp",
getLocale: () => "en",
setLoginItemSettings: (): void => void 0,
}
}));
jest.mock("electron", () => {
return {
app: {
getVersion: () => "99.99.99",
getPath: () => "tmp",
getLocale: () => "en",
setLoginItemSettings: (): void => void 0,
}
};
});
describe("Extensions", () => {
beforeEach(async () => {
mockFs({
"tmp": {}
});
ExtensionInstallationStateStore.reset();
UserStore.resetInstance();
ThemeStore.resetInstance();
await UserStore.createInstance().load();
await ThemeStore.createInstance().init();
ExtensionLoader.resetInstance();
ExtensionDiscovery.resetInstance();
Extensions.installStates.clear();
ExtensionDiscovery.createInstance().uninstallExtension = jest.fn(() => Promise.resolve());
ExtensionLoader.resetInstance();
ExtensionLoader.createInstance().addExtension({
id: "extensionId",
manifest: {
@@ -64,49 +59,38 @@ describe("Extensions", () => {
});
});
it("disables uninstall and disable buttons while uninstalling", async () => {
ExtensionDiscovery.getInstance().isLoaded = true;
render(<><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();
afterEach(() => {
mockFs.restore();
});
it("displays error notification on uninstall error", () => {
it("disables uninstall and disable buttons while uninstalling", async () => {
ExtensionDiscovery.getInstance().isLoaded = true;
(ExtensionDiscovery.getInstance().uninstallExtension as any).mockImplementationOnce(() =>
Promise.reject()
);
render(<><Extensions /><ConfirmDialog/></>);
expect(screen.getByText("Disable").closest("button")).not.toBeDisabled();
expect(screen.getByText("Uninstall").closest("button")).not.toBeDisabled();
const res = render(<><Extensions /><ConfirmDialog /></>);
fireEvent.click(screen.getByText("Uninstall"));
expect(res.getByText("Disable").closest("button")).not.toBeDisabled();
expect(res.getByText("Uninstall").closest("button")).not.toBeDisabled();
fireEvent.click(res.getByText("Uninstall"));
// Approve confirm dialog
fireEvent.click(screen.getByText("Yes"));
fireEvent.click(res.getByText("Yes"));
waitFor(() => {
expect(screen.getByText("Disable").closest("button")).not.toBeDisabled();
expect(screen.getByText("Uninstall").closest("button")).not.toBeDisabled();
expect(Notifications.error).toHaveBeenCalledTimes(1);
await waitFor(() => {
expect(ExtensionDiscovery.getInstance().uninstallExtension).toHaveBeenCalled();
expect(res.getByText("Disable").closest("button")).toBeDisabled();
expect(res.getByText("Uninstall").closest("button")).toBeDisabled();
}, {
timeout: 30000,
});
});
it("disables install button while installing", () => {
render(<Extensions />);
it("disables install button while installing", async () => {
const res = render(<Extensions />);
fireEvent.change(screen.getByPlaceholderText("Path or URL to an extension package", {
(fse.unlink as jest.MockedFunction<typeof fse.unlink>).mockReturnValue(Promise.resolve() as any);
fireEvent.change(res.getByPlaceholderText("Path or URL to an extension package", {
exact: false
}), {
target: {
@@ -114,13 +98,8 @@ describe("Extensions", () => {
}
});
fireEvent.click(screen.getByText("Install"));
waitFor(() => {
expect(screen.getByText("Install").closest("button")).toBeDisabled();
expect(fse.move).toHaveBeenCalledWith("");
expect(Notifications.error).not.toHaveBeenCalled();
});
fireEvent.click(res.getByText("Install"));
expect(res.getByText("Install").closest("button")).toBeDisabled();
});
it("displays spinner while extensions are loading", () => {
@@ -128,8 +107,11 @@ describe("Extensions", () => {
const { container } = render(<Extensions />);
expect(container.querySelector(".Spinner")).toBeInTheDocument();
});
it("does not display the spinner while extensions are not loading", async () => {
ExtensionDiscovery.getInstance().isLoaded = true;
const { container } = render(<Extensions />);
waitFor(() =>
expect(container.querySelector(".Spinner")).not.toBeInTheDocument()
@@ -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
@@ -0,0 +1,2 @@
export * from "./welcome";
export * from "./welcome.route";
@@ -0,0 +1,8 @@
import type { RouteProps } from "react-router";
import { buildURL } from "../../../common/utils/buildUrl";
export const welcomeRoute: RouteProps = {
path: "/welcome"
};
export const welcomeURL = buildURL(welcomeRoute.path);
@@ -0,0 +1,49 @@
.Welcome {
text-align: center;
width: 100%;
z-index: 1;
.box {
width: 320px;
}
h2 {
color: var(--textColorAccent);
font-weight: 600;
margin-top: 15px;
margin-bottom: 20px;
}
p {
line-height: 1.5;
}
ul {
width: 200px;
margin-top: 20px;
li {
text-align: left;
line-height: 1.5;
background-color: var(--layoutBackground);
padding: 7px;
border-radius: 4px;
margin-bottom: 7px;
cursor: pointer;
a {
margin-left: 10px;
border-bottom: none;
}
}
li:hover {
color: var(--textColorAccent);
}
}
.Icon.logo {
width: 200px;
height: 200px;
color: var(--primary);
}
}
@@ -0,0 +1,58 @@
import "./welcome.scss";
import React from "react";
import { observer } from "mobx-react";
import { Icon } from "../icon";
import { productName, slackUrl } from "../../../common/vars";
import { welcomeMenuRegistry } from "../../../extensions/registries";
import { navigate } from "../../navigation";
import { catalogURL } from "../+catalog";
import { preferencesURL } from "../+preferences";
@observer
export class Welcome extends React.Component {
componentDidMount() {
if (welcomeMenuRegistry.getItems().find((item) => item.title === "Browse Your Catalog")) {
return;
}
welcomeMenuRegistry.add({
title: "Browse Your Catalog",
icon: "view_list",
click: () => navigate(catalogURL())
});
if (welcomeMenuRegistry.getItems().length === 1) {
welcomeMenuRegistry.add({
title: "Configure Preferences",
icon: "settings",
click: () => navigate(preferencesURL())
});
}
}
render() {
return (
<div className="Welcome flex justify-center align-center">
<div className="box">
<Icon svg="logo-lens" className="logo" />
<h2>Welcome to {productName} 5 Beta!</h2>
<p>
Here are some steps to help you get started with {productName} 5 Beta.
If you have any questions or feedback, please join our <a href={slackUrl} target="_blank" rel="noreferrer">Lens Community slack channel</a>.
</p>
<ul className="box">
{ welcomeMenuRegistry.getItems().map((item, index) => (
<li key={index} className="flex grid-12" onClick={() => item.click()}>
<Icon material={item.icon} className="box col-1" /> <a className="box col-10">{item.title}</a> <Icon material="navigate_next" className="box col-1" />
</li>
))}
</ul>
</div>
</div>
);
}
}
@@ -1,2 +0,0 @@
export * from "./whats-new.route";
export * from "./whats-new";
@@ -1,8 +0,0 @@
import type { RouteProps } from "react-router";
import { buildURL } from "../../../common/utils/buildUrl";
export const whatsNewRoute: RouteProps = {
path: "/what-s-new"
};
export const whatsNewURL = buildURL(whatsNewRoute.path);
@@ -1,50 +0,0 @@
.WhatsNew {
$spacing: $padding * 2;
&::after {
content: "";
background: url(../../components/icon/crane.svg) no-repeat;
background-position: 0 35%;
background-size: 85%;
background-clip: content-box;
opacity: .75;
top: 0;
left: 0;
bottom: 0;
right: 0;
position: absolute;
z-index: -1;
.theme-light & {
opacity: 0.2;
}
}
.logo {
width: 200px;
margin-bottom: $spacing;
}
> .content {
overflow: auto;
margin-top: $spacing;
padding: $spacing * 2;
a {
color: $colorInfo;
text-decoration: underline;
}
ul {
list-style: disc inside;
line-height: 120%;
padding-left: $spacing * 2;
}
}
> .bottom {
text-align: center;
padding: $spacing;
background: $contentColor;
}
}
@@ -1,43 +0,0 @@
import "./whats-new.scss";
import fs from "fs";
import path from "path";
import React from "react";
import { observer } from "mobx-react";
import { UserStore } from "../../../common/user-store";
import { navigate } from "../../navigation";
import { Button } from "../button";
import marked from "marked";
@observer
export class WhatsNew extends React.Component {
releaseNotes = fs.readFileSync(path.join(__static, "RELEASE_NOTES.md")).toString();
ok = () => {
navigate("/");
UserStore.getInstance().saveLastSeenAppVersion();
};
render() {
const logo = require("../../components/icon/lens-logo.svg");
const releaseNotes = marked(this.releaseNotes);
return (
<div className="WhatsNew flex column">
<div className="content box grow">
<img className="logo" src={logo} alt="Lens"/>
<div
className="release-notes flex column gaps"
dangerouslySetInnerHTML={{ __html: releaseNotes }}
/>
</div>
<div className="bottom">
<Button
primary autoFocus
label="Ok, got it!"
onClick={this.ok}
/>
</div>
</div>
);
}
}
+8 -14
View File
@@ -1,5 +1,5 @@
import "./button.scss";
import React, { ButtonHTMLAttributes, ReactNode } from "react";
import React, { ButtonHTMLAttributes } from "react";
import { cssNames } from "../../utils";
import { TooltipDecoratorProps, withTooltip } from "../tooltip";
@@ -26,29 +26,22 @@ export class Button extends React.PureComponent<ButtonProps, {}> {
render() {
const {
className, waiting, label, primary, accent, plain, hidden, active, big,
round, outlined, tooltip, light, children, ...props
waiting, label, primary, accent, plain, hidden, active, big,
round, outlined, tooltip, light, children, ...btnProps
} = this.props;
const btnProps: Partial<ButtonProps> = props;
if (hidden) return null;
btnProps.className = cssNames("Button", className, {
btnProps.className = cssNames("Button", btnProps.className, {
waiting, primary, accent, plain, active, big, round, outlined, light,
});
const btnContent: ReactNode = (
<>
{label}
{children}
</>
);
// render as link
if (this.props.href) {
return (
<a {...btnProps} ref={e => this.link = e}>
{btnContent}
{label}
{children}
</a>
);
}
@@ -56,7 +49,8 @@ export class Button extends React.PureComponent<ButtonProps, {}> {
// render as button
return (
<button type="button" {...btnProps} ref={e => this.button = e}>
{btnContent}
{label}
{children}
</button>
);
}
@@ -48,7 +48,7 @@ export class BottomBar extends React.Component {
<div className="BottomBar flex gaps">
<div id="catalog-link" data-test-id="catalog-link" className="flex gaps align-center" onClick={() => navigate(catalogURL())}>
<Icon smallest material="view_list"/>
<span className="workspace-name" data-test-id="current-workspace-name">Catalog</span>
<span className="catalog-link" data-test-id="catalog-link">Catalog</span>
</div>
{this.renderRegisteredItems()}
</div>
@@ -5,7 +5,7 @@ import { Redirect, Route, Switch } from "react-router";
import { comparer, reaction } from "mobx";
import { disposeOnUnmount, observer } from "mobx-react";
import { BottomBar } from "./bottom-bar";
import { Catalog, catalogRoute, catalogURL } from "../+catalog";
import { Catalog, catalogRoute } from "../+catalog";
import { Preferences, preferencesRoute } from "../+preferences";
import { AddCluster, addClusterRoute } from "../+add-cluster";
import { ClusterView } from "./cluster-view";
@@ -17,6 +17,7 @@ import { Extensions, extensionsRoute } from "../+extensions";
import { getMatchedClusterId } from "../../navigation";
import { HotbarMenu } from "../hotbar/hotbar-menu";
import { EntitySettings, entitySettingsRoute } from "../+entity-settings";
import { Welcome, welcomeRoute, welcomeURL } from "../+welcome";
@observer
export class ClusterManager extends React.Component {
@@ -27,6 +28,7 @@ export class ClusterManager extends React.Component {
reaction(getMatchedClusterId, initView, {
fireImmediately: true
}),
reaction(() => !getMatchedClusterId(), () => ClusterStore.getInstance().setActive(null)),
reaction(() => [
getMatchedClusterId(), // refresh when active cluster-view changed
hasLoadedView(getMatchedClusterId()), // refresh when cluster's webview loaded
@@ -43,16 +45,13 @@ export class ClusterManager extends React.Component {
lensViews.clear();
}
get startUrl() {
return catalogURL();
}
render() {
return (
<div className="ClusterManager">
<main>
<div id="lens-views"/>
<Switch>
<Route component={Welcome} {...welcomeRoute} />
<Route component={Catalog} {...catalogRoute} />
<Route component={Preferences} {...preferencesRoute} />
<Route component={Extensions} {...extensionsRoute} />
@@ -65,7 +64,7 @@ export class ClusterManager extends React.Component {
<Route key={url} path={url} component={Page} />
))
}
<Redirect exact to={this.startUrl}/>
<Redirect exact to={welcomeURL()}/>
</Switch>
</main>
<HotbarMenu/>
@@ -112,7 +112,6 @@ entitySettingRegistry.add([
{
apiVersions: ["entity.k8slens.dev/v1alpha1"],
kind: "KubernetesCluster",
source: "local",
title: "Metrics",
components: {
View: (props: { entity: CatalogEntity }) => {
@@ -11,14 +11,18 @@ import { Icon } from "../icon";
export interface ConfirmDialogProps extends Partial<DialogProps> {
}
export interface ConfirmDialogParams {
ok?: () => void;
export interface ConfirmDialogParams extends ConfirmDialogBooleanParams {
ok?: () => any | Promise<any>;
cancel?: () => any | Promise<any>;
}
export interface ConfirmDialogBooleanParams {
labelOk?: ReactNode;
labelCancel?: ReactNode;
message?: ReactNode;
message: ReactNode;
icon?: ReactNode;
okButtonProps?: Partial<ButtonProps>
cancelButtonProps?: Partial<ButtonProps>
okButtonProps?: Partial<ButtonProps>;
cancelButtonProps?: Partial<ButtonProps>;
}
@observer
@@ -40,19 +44,26 @@ export class ConfirmDialog extends React.Component<ConfirmDialogProps> {
ConfirmDialog.metadata.params = params;
}
static close() {
ConfirmDialog.metadata.isOpen = false;
static confirm(params: ConfirmDialogBooleanParams): Promise<boolean> {
return new Promise(resolve => {
ConfirmDialog.open({
ok: () => resolve(true),
cancel: () => resolve(false),
...params,
});
});
}
public defaultParams: ConfirmDialogParams = {
static defaultParams: Partial<ConfirmDialogParams> = {
ok: noop,
cancel: noop,
labelOk: "Ok",
labelCancel: "Cancel",
icon: <Icon big material="warning"/>,
};
get params(): ConfirmDialogParams {
return Object.assign({}, this.defaultParams, ConfirmDialog.metadata.params);
return Object.assign({}, ConfirmDialog.defaultParams, ConfirmDialog.metadata.params);
}
ok = async () => {
@@ -61,16 +72,21 @@ export class ConfirmDialog extends React.Component<ConfirmDialogProps> {
await Promise.resolve(this.params.ok()).catch(noop);
} finally {
this.isSaving = false;
ConfirmDialog.metadata.isOpen = false;
}
this.close();
};
onClose = () => {
this.isSaving = false;
};
close = () => {
ConfirmDialog.close();
close = async () => {
try {
await Promise.resolve(this.params.cancel()).catch(noop);
} finally {
this.isSaving = false;
ConfirmDialog.metadata.isOpen = false;
}
};
render() {
@@ -1,7 +1,7 @@
import { action, autorun, makeObservable } from "mobx";
import { dockStore, IDockTab, TabId, TabKind } from "./dock.store";
import { DockTabStore } from "./dock-tab.store";
import { HelmChart, helmChartsApi } from "../../api/endpoints/helm-charts.api";
import { getChartDetails, getChartValues, HelmChart } from "../../api/endpoints/helm-charts.api";
import { IReleaseUpdateDetails } from "../../api/endpoints/helm-releases.api";
import { Notifications } from "../notifications";
@@ -55,7 +55,7 @@ export class InstallChartStore extends DockTabStore<IChartInstallData> {
const { repo, name, version } = this.getData(tabId);
this.versions.clearData(tabId); // reset
const charts = await helmChartsApi.get(repo, name, version);
const charts = await getChartDetails(repo, name, { version });
const versions = charts.versions.map(chartVersion => chartVersion.version);
this.versions.setData(tabId, versions);
@@ -65,7 +65,7 @@ export class InstallChartStore extends DockTabStore<IChartInstallData> {
async loadValues(tabId: TabId, attempt = 0): Promise<void> {
const data = this.getData(tabId);
const { repo, name, version } = data;
const values = await helmChartsApi.getValues(repo, name, version);
const values = await getChartValues(repo, name, version);
if (values) {
this.setData(tabId, { ...data, values });
@@ -11,7 +11,7 @@ import { Icon } from "../icon";
import { LogTabData } from "./log-tab.store";
interface Props {
tabData: LogTabData
tabData?: LogTabData
logs: string[]
save: (data: Partial<LogTabData>) => void
reload: () => void
@@ -19,6 +19,11 @@ interface Props {
export const LogControls = observer((props: Props) => {
const { tabData, save, reload, logs } = props;
if (!tabData) {
return null;
}
const { showTimestamps, previous } = tabData;
const since = logs.length ? logStore.getTimestamps(logs[0]) : null;
const pod = new Pod(tabData.selectedPod);
+17 -17
View File
@@ -31,23 +31,14 @@ export class Logs extends React.Component<Props> {
componentDidMount() {
disposeOnUnmount(this,
reaction(() => this.props.tab.id, this.reload, { fireImmediately: true })
reaction(() => this.props.tab.id, this.reload, { fireImmediately: true }),
);
}
get tabData() {
return logTabStore.getData(this.tabId);
}
get tabId() {
return this.props.tab.id;
}
@autobind()
save(data: Partial<LogTabData>) {
logTabStore.setData(this.tabId, { ...this.tabData, ...data });
}
load = async () => {
this.isLoading = true;
await logStore.load(this.tabId);
@@ -87,15 +78,19 @@ export class Logs extends React.Component<Props> {
}, 100);
}
renderResourceSelector() {
renderResourceSelector(data?: LogTabData) {
if (!data) {
return null;
}
const logs = logStore.logs;
const searchLogs = this.tabData.showTimestamps ? logs : logStore.logsWithoutTimestamps;
const searchLogs = data.showTimestamps ? logs : logStore.logsWithoutTimestamps;
const controls = (
<div className="flex gaps">
<LogResourceSelector
tabId={this.tabId}
tabData={this.tabData}
save={this.save}
tabData={data}
save={newData => logTabStore.setData(this.tabId, { ...data, ...newData })}
reload={this.reload}
/>
<LogSearch
@@ -120,10 +115,15 @@ export class Logs extends React.Component<Props> {
render() {
const logs = logStore.logs;
const data = logTabStore.getData(this.tabId);
if (!data) {
this.reload();
}
return (
<div className="PodLogs flex column">
{this.renderResourceSelector()}
{this.renderResourceSelector(data)}
<LogList
logs={logs}
id={this.tabId}
@@ -133,8 +133,8 @@ export class Logs extends React.Component<Props> {
/>
<LogControls
logs={logs}
tabData={this.tabData}
save={this.save}
tabData={data}
save={newData => logTabStore.setData(this.tabId, { ...data, ...newData })}
reload={this.reload}
/>
</div>
+18 -25
View File
@@ -2,53 +2,46 @@
.HotbarIcon {
--size: 37px;
position: relative;
border-radius: 8px;
padding: 2px;
border-radius: 6px;
user-select: none;
cursor: pointer;
transition: none;
div.MuiAvatar-colorDefault {
font-weight:500;
text-transform: uppercase;
border-radius: 4px;
}
div.active {
background-color: var(--primary);
}
&.interactive {
margin-left: -3px;
border: 3px solid var(--clusterMenuBackground);
border-radius: 6px;
}
&.active {
border: 3px solid #fff;
box-shadow: 0 0 0px 3px #ffffff;
transition: all 0s 0.8s;
}
&.active, &.interactive:hover {
div {
background-color: var(--primary);
}
img {
opacity: 1;
}
}
.badge {
color: $textColorAccent;
position: absolute;
right: 0;
bottom: 0;
margin: -$padding;
font-size: $font-size-small;
background: $clusterMenuBackground;
right: -2px;
bottom: -3px;
margin: -8px;
font-size: var(--font-size-small);
background: var(--clusterMenuBackground);
color: white;
padding: 0px;
border-radius: 50%;
border: 3px solid var(--clusterMenuBackground);
width: 15px;
height: 15px;
&.online {
background-color: #44b700;
}
svg {
width: 13px;
}
+8 -12
View File
@@ -73,8 +73,8 @@ export class HotbarIcon extends React.Component<Props> {
].filter(Boolean).join("");
}
get badgeIcon() {
const className = "badge";
get kindIcon() {
const className = cssNames("badge", { online: this.props.entity.status.phase == "connected"});
const category = catalogCategoryRegistry.getCategoryForEntity(this.props.entity);
if (!category) {
@@ -92,14 +92,10 @@ export class HotbarIcon extends React.Component<Props> {
this.menuOpen = !this.menuOpen;
}
removeFromHotbar(item: CatalogEntity) {
const hotbar = HotbarStore.getInstance().getActive();
remove(item: CatalogEntity) {
const hotbar = HotbarStore.getInstance();
if (!hotbar) {
return;
}
hotbar.items = hotbar.items.filter((i) => i.entity.uid !== item.metadata.uid);
hotbar.removeFromHotbar(item);
}
onMenuItemClick(menuItem: CatalogEntityContextMenu) {
@@ -153,9 +149,9 @@ export class HotbarIcon extends React.Component<Props> {
>
{this.iconString}
</Avatar>
{this.badgeIcon}
{this.kindIcon}
<Menu
usePortal={false}
usePortal
htmlFor={entityIconId}
className="HotbarIconMenu"
isOpen={this.menuOpen}
@@ -163,7 +159,7 @@ export class HotbarIcon extends React.Component<Props> {
position={{ right: true, bottom: true }} // FIXME: position does not work
open={() => onOpen()}
close={() => this.toggleMenu()}>
<MenuItem key="remove-from-hotbar" onClick={() => this.removeFromHotbar(entity)}>
<MenuItem key="remove-from-hotbar" onClick={() => this.remove(entity)}>
<Icon material="clear" small interactive={true} title="Remove from hotbar"/> Remove from Hotbar
</MenuItem>
{this.contextMenu && menuItems.map((menuItem) => {
+175 -13
View File
@@ -4,32 +4,194 @@
position: relative;
text-align: center;
background: $clusterMenuBackground;
border-right: 1px solid $clusterMenuBorderColor;
padding: $spacing 0;
min-width: 75px;
padding-top: 28px;
width: 75px;
.is-mac &:before {
content: "";
height: 20px; // extra spacing for mac-os "traffic-light" buttons
height: 4px; // extra spacing for mac-os "traffic-light" buttons
}
.items {
padding: 0 $spacing; // extra spacing for cluster-icon's badge
margin-bottom: $margin;
overflow: visible;
&:hover {
.AddCellButton {
opacity: 1;
}
}
&:empty {
display: none;
.HotbarItems {
--cellWidth: 40px;
--cellHeight: 40px;
box-sizing: content-box;
margin: 0 auto;
height: 100%;
overflow: hidden;
padding-bottom: 8px;
&:hover {
overflow: overlay;
&::-webkit-scrollbar {
width: 0.4em;
background: transparent;
z-index: 1;
}
&::-webkit-scrollbar-thumb {
background: var(--borderFaintColor);
}
}
.HotbarCell {
width: var(--cellWidth);
height: var(--cellHeight);
min-height: var(--cellHeight);
margin: 12px;
background: var(--layoutBackground);
border-radius: 6px;
position: relative;
transform: translateZ(0); // Remove flickering artifacts
&:hover {
.cellDeleteButton {
opacity: 1;
transition: opacity 0.1s 0.2s;
}
&:not(.empty) {
box-shadow: 0 0 0px 3px #ffffff1a;
}
}
&.animating {
&.empty {
animation: shake .6s cubic-bezier(.36,.07,.19,.97) both;
transform: translate3d(0, 0, 0);
backface-visibility: hidden;
perspective: 1000px;
}
&:not(.empty) {
animation: outline 0.8s cubic-bezier(0.19, 1, 0.22, 1);
}
}
.cellDeleteButton {
width: 2rem;
height: 2rem;
border-radius: 50%;
background-color: var(--textColorDimmed);
position: absolute;
top: -7px;
right: -7px;
color: var(--secondaryBackground);
opacity: 0;
border: 3px solid var(--clusterMenuBackground);
box-sizing: border-box;
&:hover {
background-color: white;
transition: all 0.2s;
}
.Icon {
--smallest-size: 12px;
font-weight: bold;
position: relative;
top: -2px;
left: .5px;
}
}
}
}
.HotbarSelector {
position: absolute;
bottom: 0;
width: 100%;
height: 26px;
background-color: var(--layoutBackground);
position: relative;
&:before {
content: " ";
position: absolute;
width: 100%;
height: 20px;
background: linear-gradient(0deg, var(--clusterMenuBackground), transparent);
top: -20px;
}
.Badge {
cursor: pointer;
background: var(--secondaryBackground);
width: 100%;
color: var(--settingsColor);
padding-top: 3px;
}
.Icon {
--size: 16px;
padding: 0 4px;
&:hover {
box-shadow: none;
background-color: transparent;
}
&.previous {
transform: rotateY(180deg);
}
}
}
.AddCellButton {
width: 40px;
height: 40px;
min-height: 40px;
margin: 12px auto 8px;
background-color: transparent;
color: var(--textColorDimmed);
border-radius: 6px;
transition: all 0.2s;
cursor: pointer;
z-index: 1;
opacity: 0;
transition: all 0.2s;
&:hover {
background-color: var(--sidebarBackground);
}
.Icon {
--size: 24px;
margin-left: 2px;
}
}
}
@keyframes shake {
10%, 90% {
transform: translate3d(-1px, 0, 0);
}
20%, 80% {
transform: translate3d(2px, 0, 0);
}
30%, 50%, 70% {
transform: translate3d(-4px, 0, 0);
}
40%, 60% {
transform: translate3d(4px, 0, 0);
}
}
// TODO: Use theme-aware colors
@keyframes outline {
0% {
box-shadow: 0 0 0px 11px $clusterMenuBackground, 0 0 0px 15px #ffffff00;
}
100% {
box-shadow: 0 0 0px 0px $clusterMenuBackground, 0 0 0px 3px #ffffff;
}
}
+82 -22
View File
@@ -1,17 +1,18 @@
import "./hotbar-menu.scss";
import "./hotbar.commands";
import React from "react";
import React, { ReactNode, useState } from "react";
import { observer } from "mobx-react";
import { HotbarIcon } from "./hotbar-icon";
import { cssNames, IClassName } from "../../utils";
import { catalogEntityRegistry } from "../../api/catalog-entity-registry";
import { HotbarStore } from "../../../common/hotbar-store";
import { catalogEntityRunContext } from "../../api/catalog-entity";
import { defaultHotbarCells, HotbarItem, HotbarStore } from "../../../common/hotbar-store";
import { CatalogEntity, catalogEntityRunContext } from "../../api/catalog-entity";
import { Icon } from "../icon";
import { Badge } from "../badge";
import { CommandOverlay } from "../command-palette";
import { HotbarSwitchCommand } from "./hotbar-switch-command";
import { ClusterStore } from "../../../common/cluster-store";
import { Tooltip, TooltipPosition } from "../tooltip";
interface Props {
@@ -20,14 +21,22 @@ interface Props {
@observer
export class HotbarMenu extends React.Component<Props> {
get hotbarItems() {
get hotbar() {
return HotbarStore.getInstance().getActive();
}
isActive(item: CatalogEntity) {
return ClusterStore.getInstance().activeClusterId == item.getId();
}
getEntity(item: HotbarItem) {
const hotbar = HotbarStore.getInstance().getActive();
if (!hotbar) {
return [];
return null;
}
return hotbar.items.map((item) => catalogEntityRegistry.items.find((entity) => entity.metadata.uid === item.entity.uid)).filter(Boolean);
return item ? catalogEntityRegistry.items.find((entity) => entity.metadata.uid === item.entity.uid) : null;
}
previous() {
@@ -42,6 +51,36 @@ export class HotbarMenu extends React.Component<Props> {
CommandOverlay.open(<HotbarSwitchCommand />);
}
renderGrid() {
if (!this.hotbar.items.length) return;
return this.hotbar.items.map((item, index) => {
const entity = this.getEntity(item);
return (
<HotbarCell key={index} index={index}>
{entity && (
<HotbarIcon
key={index}
index={index}
entity={entity}
isActive={this.isActive(entity)}
onClick={() => entity.onRun(catalogEntityRunContext)}
/>
)}
</HotbarCell>
);
});
}
renderAddCellButton() {
return (
<button className="AddCellButton" onClick={() => HotbarStore.getInstance().addEmptyCell()}>
<Icon material="add"/>
</button>
);
}
render() {
const { className } = this.props;
const hotbarStore = HotbarStore.getInstance();
@@ -50,22 +89,13 @@ export class HotbarMenu extends React.Component<Props> {
return (
<div className={cssNames("HotbarMenu flex column", className)}>
<div className="items flex column gaps">
{this.hotbarItems.map((entity, index) => {
return (
<HotbarIcon
key={index}
index={index}
entity={entity}
isActive={entity.status.active}
onClick={() => entity.onRun(catalogEntityRunContext)}
/>
);
})}
<div className="HotbarItems flex column gaps">
{this.renderGrid()}
{this.hotbar.items.length != defaultHotbarCells && this.renderAddCellButton()}
</div>
<div className="HotbarSelector flex gaps auto">
<Icon material="chevron_left" className="previous box" onClick={() => this.previous()} />
<div className="box">
<div className="HotbarSelector flex align-center">
<Icon material="play_arrow" className="previous box" onClick={() => this.previous()} />
<div className="box grow flex align-center">
<Badge id="hotbarIndex" small label={activeIndexDisplay} onClick={() => this.openSelector()} />
<Tooltip
targetId="hotbarIndex"
@@ -74,9 +104,39 @@ export class HotbarMenu extends React.Component<Props> {
{hotbar.name}
</Tooltip>
</div>
<Icon material="chevron_right" className="next box" onClick={() => this.next()} />
<Icon material="play_arrow" className="next box" onClick={() => this.next()} />
</div>
</div>
);
}
}
interface HotbarCellProps {
children?: ReactNode;
index: number;
}
function HotbarCell(props: HotbarCellProps) {
const [animating, setAnimating] = useState(false);
const onAnimationEnd = () => { setAnimating(false); };
const onClick = () => { setAnimating(true); };
const onDeleteClick = (evt: Event | React.SyntheticEvent) => {
evt.stopPropagation();
HotbarStore.getInstance().removeEmptyCell(props.index);
};
return (
<div
className={cssNames("HotbarCell", { animating, empty: !props.children })}
onAnimationEnd={onAnimationEnd}
onClick={onClick}
>
{props.children}
{!props.children && (
<div className="cellDeleteButton" onClick={onDeleteClick}>
<Icon material="close" smallest/>
</div>
)}
</div>
);
}
+1
View File
@@ -315,6 +315,7 @@ export class Input extends React.Component<InputProps, State> {
rows: multiLine ? (rows || 1) : null,
ref: this.bindRef,
spellCheck: "false",
disabled,
});
const showErrors = errors.length > 0 && !valid && dirty;
const errorsInfo = (
@@ -47,6 +47,14 @@ export const isUrl: InputValidator = {
},
};
export const isExtensionNameInstallRegex = /^(?<name>(@[-\w]+\/)?[-\w]+)(@(?<version>\d\.\d\.\d(-\w+)?))?$/gi;
export const isExtensionNameInstall: InputValidator = {
condition: ({ type }) => type === "text",
message: () => "Not an extension name with optional version",
validate: value => value.match(isExtensionNameInstallRegex) !== null,
};
export const isPath: InputValidator = {
condition: ({ type }) => type === "text",
message: () => `This field must be a valid path`,
@@ -2,110 +2,106 @@ import "./kube-object-status-icon.scss";
import React from "react";
import { Icon } from "../icon";
import { KubeObject } from "../../api/kube-object";
import { cssNames, formatDuration } from "../../utils";
import { KubeObjectStatusRegistration, kubeObjectStatusRegistry } from "../../../extensions/registries/kube-object-status-registry";
import { KubeObjectStatus, KubeObjectStatusLevel } from "../../..//extensions/renderer-api/k8s-api";
import { computed, makeObservable } from "mobx";
import { KubeObject, KubeObjectStatus, KubeObjectStatusLevel } from "../../..//extensions/renderer-api/k8s-api";
import { kubeObjectStatusRegistry } from "../../../extensions/registries";
function statusClassName(level: number): string {
switch (level) {
case KubeObjectStatusLevel.INFO:
return "info";
case KubeObjectStatusLevel.WARNING:
return "warning";
case KubeObjectStatusLevel.CRITICAL:
return "error";
}
}
function statusTitle(level: KubeObjectStatusLevel): string {
switch (level) {
case KubeObjectStatusLevel.INFO:
return "Info";
case KubeObjectStatusLevel.WARNING:
return "Warning";
case KubeObjectStatusLevel.CRITICAL:
return "Critical";
}
}
function getAge(timestamp: string) {
return timestamp
? formatDuration(Date.now() - new Date(timestamp).getTime(), true)
: "";
}
interface SplitStatusesByLevel {
maxLevel: string,
criticals: KubeObjectStatus[];
warnings: KubeObjectStatus[];
infos: KubeObjectStatus[];
}
/**
* This fuction returns the class level for corresponding to the highest status level
* and the statuses split by their levels.
* @param src a list of status items
*/
function splitByLevel(src: KubeObjectStatus[]): SplitStatusesByLevel {
const parts = new Map(Object.values(KubeObjectStatusLevel).map(v => [v, []]));
src.forEach(status => parts.get(status.level).push(status));
const criticals = parts.get(KubeObjectStatusLevel.CRITICAL);
const warnings = parts.get(KubeObjectStatusLevel.WARNING);
const infos = parts.get(KubeObjectStatusLevel.INFO);
const maxLevel = statusClassName(criticals[0]?.level ?? warnings[0]?.level ?? infos[0].level);
return { maxLevel, criticals, warnings, infos };
}
interface Props {
object: KubeObject;
}
export class KubeObjectStatusIcon extends React.Component<Props> {
constructor(props: Props) {
super(props);
makeObservable(this);
}
@computed get objectStatuses() {
const { object } = this.props;
const registrations = kubeObjectStatusRegistry.getItemsForKind(object.kind, object.apiVersion);
return registrations.map((item: KubeObjectStatusRegistration) => { return item.resolve(object); }).filter((item: KubeObjectStatus) => !!item);
}
statusClassName(level: number): string {
switch (level) {
case KubeObjectStatusLevel.INFO:
return "info";
case KubeObjectStatusLevel.WARNING:
return "warning";
case KubeObjectStatusLevel.CRITICAL:
return "error";
default:
return "";
}
}
statusTitle(level: number): string {
switch (level) {
case KubeObjectStatusLevel.INFO:
return "Info";
case KubeObjectStatusLevel.WARNING:
return "Warning";
case KubeObjectStatusLevel.CRITICAL:
return "Critical";
default:
return "";
}
}
getAge(timestamp: string) {
if (!timestamp) return "";
const diff = Date.now() - new Date(timestamp).getTime();
return formatDuration(diff, true);
}
renderStatuses(statuses: KubeObjectStatus[], level: number) {
const filteredStatuses = statuses.filter((item) => item.level == level);
return filteredStatuses.length > 0 && (
<div className={cssNames("level", this.statusClassName(level))}>
<div className={cssNames("level", statusClassName(level))}>
<span className="title">
{this.statusTitle(level)}
{statusTitle(level)}
</span>
{ filteredStatuses.map((status, index) =>{
return (
{
filteredStatuses.map((status, index) => (
<div key={`kube-resource-status-${level}-${index}`} className={cssNames("status", "msg")}>
- {status.text} <span className="age"> · { this.getAge(status.timestamp) }</span>
- {status.text} <span className="age"> · {getAge(status.timestamp)}</span>
</div>
);
})}
))
}
</div>
);
}
render() {
const { objectStatuses} = this;
const statuses = kubeObjectStatusRegistry.getItemsForObject(this.props.object);
if (!objectStatuses.length) return null;
if (statuses.length === 0) {
return null;
}
const sortedStatuses = objectStatuses.sort((a: KubeObjectStatus, b: KubeObjectStatus) => {
if (a.level < b.level ) {
return 1;
}
if (a.level > b.level ) {
return -1;
}
return 0;
});
const level = this.statusClassName(sortedStatuses[0].level);
const { maxLevel, criticals, warnings, infos } = splitByLevel(statuses);
return (
<Icon
material={level}
className={cssNames("KubeObjectStatusIcon", level)}
material={maxLevel}
className={cssNames("KubeObjectStatusIcon", maxLevel)}
tooltip={{
children: (
<div className="KubeObjectStatusTooltip">
{this.renderStatuses(sortedStatuses, KubeObjectStatusLevel.CRITICAL)}
{this.renderStatuses(sortedStatuses, KubeObjectStatusLevel.WARNING)}
{this.renderStatuses(sortedStatuses, KubeObjectStatusLevel.INFO)}
{this.renderStatuses(criticals, KubeObjectStatusLevel.CRITICAL)}
{this.renderStatuses(warnings, KubeObjectStatusLevel.WARNING)}
{this.renderStatuses(infos, KubeObjectStatusLevel.INFO)}
</div>
)
}}
@@ -24,13 +24,11 @@ export class KubeObjectMeta extends React.Component<KubeObjectMetaProps> {
}
render() {
const object = this.props.object;
const { object } = this.props;
const {
getName, getNs, getLabels, getResourceVersion, selfLink,
getAnnotations, getFinalizers, getId, getAge,
metadata: { creationTimestamp },
getNs, getLabels, getResourceVersion, selfLink, getAnnotations,
getFinalizers, getId, getAge, getName, metadata: { creationTimestamp },
} = object;
const ownerRefs = object.getOwnerRefs();
return (
@@ -39,7 +37,8 @@ export class KubeObjectMeta extends React.Component<KubeObjectMetaProps> {
{getAge(true, false)} ago ({<LocaleDate date={creationTimestamp} />})
</DrawerItem>
<DrawerItem name="Name" hidden={this.isHidden("name")}>
{getName()} <KubeObjectStatusIcon key="icon" object={object} />
{getName()}
<KubeObjectStatusIcon key="icon" object={object} />
</DrawerItem>
<DrawerItem name="Namespace" hidden={this.isHidden("namespace") || !getNs()}>
{getNs()}
@@ -68,7 +67,7 @@ export class KubeObjectMeta extends React.Component<KubeObjectMetaProps> {
labels={getFinalizers()}
hidden={this.isHidden("finalizers")}
/>
{ownerRefs && ownerRefs.length > 0 &&
{ownerRefs?.length > 0 &&
<DrawerItem name="Controlled By" hidden={this.isHidden("ownerReferences")}>
{
ownerRefs.map(ref => {
+8 -10
View File
@@ -2,7 +2,6 @@ import "./sidebar.scss";
import type { TabLayoutRoute } from "./tab-layout";
import React from "react";
import { computed, makeObservable } from "mobx";
import { observer } from "mobx-react";
import { NavLink } from "react-router-dom";
import { cssNames } from "../../utils";
@@ -41,18 +40,17 @@ interface Props {
export class Sidebar extends React.Component<Props> {
static displayName = "Sidebar";
constructor(props: Props) {
super(props);
makeObservable(this);
}
async componentDidMount() {
crdStore.reloadAll();
}
@computed get crdSubMenus(): React.ReactNode {
if (!crdStore.isLoaded && crdStore.isLoading) {
return <Spinner centerHorizontal/>;
renderCustomResources() {
if (crdStore.isLoading) {
return (
<div className="flex justify-center">
<Spinner />
</div>
);
}
return Object.entries(crdStore.groups).map(([group, crds]) => {
@@ -273,7 +271,7 @@ export class Sidebar extends React.Component<Props> {
icon={<Icon material="extension"/>}
>
{this.renderTreeFromTabRoutes(CustomResources.tabRoutes)}
{this.crdSubMenus}
{this.renderCustomResources()}
</SidebarItem>
{this.renderRegisteredMenus()}
</div>
+10 -4
View File
@@ -5,7 +5,6 @@ import { createPortal } from "react-dom";
import { autobind, cssNames, noop } from "../../utils";
import { Animate } from "../animate";
import { Icon, IconProps } from "../icon";
import debounce from "lodash/debounce";
export const MenuContext = React.createContext<MenuContextValue>(null);
export type MenuContextValue = Menu;
@@ -122,8 +121,11 @@ export class Menu extends React.Component<MenuProps, State> {
}
}
refreshPosition = debounce(() => {
if (!this.props.usePortal || !this.opener) return;
refreshPosition = () => {
if (!this.props.usePortal || !this.opener || !this.elem) {
return;
}
const { width, height } = this.opener.getBoundingClientRect();
let { left, top, bottom, right } = this.opener.getBoundingClientRect();
const withScroll = window.getComputedStyle(this.elem).position !== "fixed";
@@ -157,7 +159,7 @@ export class Menu extends React.Component<MenuProps, State> {
delete position.bottom;
}
this.setState({ position });
}, Animate.VISIBILITY_DELAY_MS);
};
open() {
if (this.isOpen) return;
@@ -248,6 +250,10 @@ export class Menu extends React.Component<MenuProps, State> {
}
render() {
if (this.isOpen) {
setImmediate(() => this.refreshPosition());
}
const { position, id } = this.props;
let { className, usePortal } = this.props;
@@ -34,12 +34,6 @@
margin-top: calc(var(--spinner-size) / -2);
}
&.centerHorizontal {
position: absolute;
left: 50%;
margin-left: calc(var(--spinner-size) / -2);
}
@keyframes rotate {
0% {
transform: rotate(0deg);
+2 -3
View File
@@ -6,7 +6,6 @@ import { cssNames } from "../../utils";
export interface SpinnerProps extends React.HTMLProps<any> {
singleColor?: boolean;
center?: boolean;
centerHorizontal?: boolean;
}
export class Spinner extends React.Component<SpinnerProps, {}> {
@@ -16,8 +15,8 @@ export class Spinner extends React.Component<SpinnerProps, {}> {
};
render() {
const { center, singleColor, centerHorizontal, className, ...props } = this.props;
const classNames = cssNames("Spinner", className, { singleColor, center, centerHorizontal });
const { center, singleColor, className, ...props } = this.props;
const classNames = cssNames("Spinner", className, { singleColor, center });
return <div {...props} className={classNames} />;
}
-4
View File
@@ -2,11 +2,9 @@ import "../common/system-ca";
import React from "react";
import { Route, Router, Switch } from "react-router";
import { observer } from "mobx-react";
import { UserStore } from "../common/user-store";
import { history } from "./navigation";
import { ClusterManager } from "./components/cluster-manager";
import { ErrorBoundary } from "./components/error-boundary";
import { WhatsNew, whatsNewRoute } from "./components/+whats-new";
import { Notifications } from "./components/notifications";
import { ConfirmDialog } from "./components/confirm-dialog";
import { ExtensionLoader } from "../extensions/extension-loader";
@@ -52,8 +50,6 @@ export class LensApp extends React.Component {
<Router history={history}>
<ErrorBoundary>
<Switch>
{UserStore.getInstance().isNewVersion && <Route component={WhatsNew}/>}
<Route component={WhatsNew} {...whatsNewRoute}/>
<Route component={ClusterManager}/>
</Switch>
</ErrorBoundary>
+15 -4
View File
@@ -1,6 +1,6 @@
import { addClusterURL } from "../components/+add-cluster";
import { extensionsURL } from "../components/+extensions";
import { catalogURL } from "../components/+catalog";
import { attemptInstallByInfo, extensionsURL } from "../components/+extensions";
import { preferencesURL } from "../components/+preferences";
import { clusterViewURL } from "../components/cluster-manager/cluster-view.route";
import { LensProtocolRouterRenderer } from "./router";
@@ -8,6 +8,7 @@ import { navigate } from "../navigation/helpers";
import { entitySettingsURL } from "../components/+entity-settings";
import { catalogEntityRegistry } from "../api/catalog-entity-registry";
import { ClusterStore } from "../../common/cluster-store";
import { EXTENSION_NAME_MATCH, EXTENSION_PUBLISHER_MATCH, LensProtocolRouter } from "../../common/protocol-handler";
export function bindProtocolAddRouteHandlers() {
LensProtocolRouterRenderer
@@ -33,9 +34,6 @@ export function bindProtocolAddRouteHandlers() {
console.log("[APP-HANDLER]: catalog entity with given ID does not exist", { entityId });
}
})
.addInternalHandler("/extensions", () => {
navigate(extensionsURL());
})
// Handlers below are deprecated and only kept for backward compact purposes
.addInternalHandler("/cluster/:clusterId", ({ pathname: { clusterId } }) => {
const cluster = ClusterStore.getInstance().getById(clusterId);
@@ -54,5 +52,18 @@ export function bindProtocolAddRouteHandlers() {
} else {
console.log("[APP-HANDLER]: cluster with given ID does not exist", { clusterId });
}
})
.addInternalHandler("/extensions", () => {
navigate(extensionsURL());
})
.addInternalHandler(`/extensions/install${LensProtocolRouter.ExtensionUrlSchema}`, ({ pathname, search: { version } }) => {
const name = [
pathname[EXTENSION_PUBLISHER_MATCH],
pathname[EXTENSION_NAME_MATCH],
].filter(Boolean)
.join("/");
navigate(extensionsURL());
attemptInstallByInfo({ name, version, requireConfirmation: true });
});
}
-36
View File
@@ -1,36 +0,0 @@
// Allow to cancel request for window.fetch()
export interface CancelablePromise<T> extends Promise<T> {
then<TResult1 = T, TResult2 = never>(onfulfilled?: ((value: T) => TResult1 | PromiseLike<TResult1>) | undefined | null, onrejected?: ((reason: any) => TResult2 | PromiseLike<TResult2>) | undefined | null): CancelablePromise<TResult1 | TResult2>;
catch<TResult = never>(onrejected?: ((reason: any) => TResult | PromiseLike<TResult>) | undefined | null): CancelablePromise<T | TResult>;
finally(onfinally?: (() => void) | undefined | null): CancelablePromise<T>;
cancel(): void;
}
interface WrappingFunction {
<T>(result: Promise<T>): CancelablePromise<T>;
<T>(result: T): T;
}
export function cancelableFetch(reqInfo: RequestInfo, reqInit: RequestInit = {}) {
const abortController = new AbortController();
const signal = abortController.signal;
const cancel = abortController.abort.bind(abortController);
const wrapResult: WrappingFunction = function (result: any) {
if (result instanceof Promise) {
const promise: CancelablePromise<any> = result as any;
promise.then = function (onfulfilled, onrejected) {
const data = Object.getPrototypeOf(this).then.call(this, onfulfilled, onrejected);
return wrapResult(data);
};
promise.cancel = cancel;
}
return result;
};
const req = fetch(reqInfo, { ...reqInit, signal });
return wrapResult(req);
}