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

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 2021-04-27 12:32:29 +03:00
commit e93bca5169
81 changed files with 2319 additions and 2116 deletions

View File

@ -62,6 +62,7 @@ jobs:
WIN_CSC_KEY_PASSWORD: $(WIN_CSC_KEY_PASSWORD)
AWS_ACCESS_KEY_ID: $(AWS_ACCESS_KEY_ID)
AWS_SECRET_ACCESS_KEY: $(AWS_SECRET_ACCESS_KEY)
BUILD_NUMBER: $(Build.BuildNumber)
- job: macOS
pool:
vmImage: macOS-10.14
@ -113,6 +114,7 @@ jobs:
CSC_KEY_PASSWORD: $(CSC_KEY_PASSWORD)
AWS_ACCESS_KEY_ID: $(AWS_ACCESS_KEY_ID)
AWS_SECRET_ACCESS_KEY: $(AWS_SECRET_ACCESS_KEY)
BUILD_NUMBER: $(Build.BuildNumber)
- job: Linux
pool:
vmImage: ubuntu-16.04
@ -170,6 +172,7 @@ jobs:
env:
AWS_ACCESS_KEY_ID: $(AWS_ACCESS_KEY_ID)
AWS_SECRET_ACCESS_KEY: $(AWS_SECRET_ACCESS_KEY)
BUILD_NUMBER: $(Build.BuildNumber)
- script: make publish-npm
displayName: Publish npm package
condition: "and(succeeded(), startsWith(variables['Build.SourceBranch'], 'refs/tags/'))"

View File

@ -65,6 +65,7 @@ integration-win: binaries/client build-extension-types build-extensions
.PHONY: build
build: node_modules binaries/client build-extensions
yarn run npm:fix-build-version
yarn run compile
ifeq "$(DETECTED_OS)" "Windows"
yarn run electron-builder --publish onTag --x64 --ia32

View File

@ -0,0 +1,23 @@
import * as fs from "fs";
import * as path from "path";
import appInfo from "../package.json";
import semver from "semver";
const packagePath = path.join(__dirname, "../package.json");
const versionInfo = semver.parse(appInfo.version);
const buildNumber = process.env.BUILD_NUMBER || "1";
let buildChannel = "alpha";
if (versionInfo.prerelease) {
if (versionInfo.prerelease.includes("alpha")) {
buildChannel = "alpha";
} else {
buildChannel = "beta";
}
appInfo.version = `${versionInfo.major}.${versionInfo.minor}.${versionInfo.patch}-${buildChannel}.${versionInfo.prerelease[1]}.${buildNumber}`;
} else {
appInfo.version = `${appInfo.version}-latest.${buildNumber}`;
}
fs.writeFileSync(packagePath, `${JSON.stringify(appInfo, null, 2)}\n`);

View File

@ -6,6 +6,9 @@ module.exports = [
context: __dirname,
target: "electron-renderer",
mode: "production",
optimization: {
minimize: false
},
module: {
rules: [
{

View File

@ -6,6 +6,9 @@ module.exports = [
context: __dirname,
target: "electron-renderer",
mode: "production",
optimization: {
minimize: false
},
module: {
rules: [
{

View File

@ -6,6 +6,9 @@ module.exports = [
context: __dirname,
target: "electron-renderer",
mode: "production",
optimization: {
minimize: false
},
module: {
rules: [
{

View File

@ -6,6 +6,9 @@ module.exports = [
context: __dirname,
target: "electron-renderer",
mode: "production",
optimization: {
minimize: false
},
module: {
rules: [
{

View File

@ -30,10 +30,6 @@ describe("Lens integration tests", () => {
}
});
it('shows "whats new"', async () => {
await utils.clickWhatsNew(app);
});
it('shows "add cluster"', async () => {
await app.electron.ipcRenderer.send("test-menu-item-click", "File", "Add Cluster");
await app.client.waitUntilTextExists("h2", "Add Clusters from Kubeconfig");

View File

@ -24,8 +24,6 @@ describe("Lens cluster pages", () => {
utils.describeIf(ready)("test common pages", () => {
let clusterAdded = false;
const addCluster = async () => {
await utils.clickWhatsNew(app);
await utils.clickWelcomeNotification(app);
await app.client.waitUntilTextExists("div", "Catalog");
await addMinikubeCluster(app);
await waitForMinikubeDashboard(app);

View File

@ -18,7 +18,6 @@ describe("Lens command palette", () => {
});
it("opens command dialog from menu", async () => {
await utils.clickWhatsNew(app);
await app.electron.ipcRenderer.send("test-menu-item-click", "View", "Command Palette...");
await app.client.waitUntilTextExists(".Select__option", "Preferences: Open");
await app.client.keys("Escape");

View File

@ -73,24 +73,14 @@ export async function appStart() {
while (await app.client.getWindowCount() > 1);
await app.client.windowByIndex(0);
await app.client.waitUntilWindowLoaded();
await showCatalog(app);
return app;
}
export async function clickWhatsNew(app: Application) {
await app.client.waitUntilTextExists("h1", "What's new?");
await app.client.click("button.primary");
await app.client.waitUntilTextExists("div", "Catalog");
}
export async function clickWelcomeNotification(app: Application) {
const itemsText = await app.client.$("div.info-panel").getText();
if (itemsText === "0 items") {
// welcome notification should be present, dismiss it
await app.client.waitUntilTextExists("div.message", "Welcome!");
await app.client.click(".notification i.Icon.close");
}
export async function showCatalog(app: Application) {
await app.client.waitUntilTextExists("[data-test-id=catalog-link]", "Catalog");
await app.client.click("[data-test-id=catalog-link]");
}
type AsyncPidGetter = () => Promise<number>;

View File

@ -2,7 +2,7 @@
"name": "open-lens",
"productName": "OpenLens",
"description": "OpenLens - Open Source IDE for Kubernetes",
"version": "5.0.0-alpha.2",
"version": "5.0.0-alpha.3",
"main": "static/build/main.js",
"copyright": "© 2021 OpenLens Authors",
"license": "MIT",
@ -21,6 +21,7 @@
"compile:main": "yarn run webpack --config webpack.main.ts",
"compile:renderer": "yarn run webpack --config webpack.renderer.ts",
"compile:extension-types": "yarn run webpack --config webpack.extensions.ts",
"npm:fix-build-version": "yarn run ts-node build/set_build_version.ts",
"npm:fix-package-version": "yarn run ts-node build/set_npm_version.ts",
"build:linux": "yarn run compile && electron-builder --linux --dir",
"build:mac": "yarn run compile && electron-builder --mac --dir",
@ -42,7 +43,7 @@
},
"config": {
"bundledKubectlVersion": "1.18.15",
"bundledHelmVersion": "3.4.2"
"bundledHelmVersion": "3.5.4"
},
"engines": {
"node": ">=12 <13"
@ -280,7 +281,6 @@
"@types/tar": "^4.0.4",
"@types/tcp-port-used": "^1.0.0",
"@types/tempy": "^0.3.0",
"@types/terser-webpack-plugin": "^3.0.0",
"@types/universal-analytics": "^0.4.4",
"@types/url-parse": "^1.4.3",
"@types/uuid": "^8.3.0",
@ -340,7 +340,6 @@
"sharp": "^0.26.1",
"spectron": "11.0.0",
"style-loader": "^1.2.1",
"terser-webpack-plugin": "^3.0.3",
"ts-jest": "^26.1.0",
"ts-loader": "^7.0.5",
"ts-node": "^8.10.2",

View File

@ -43,13 +43,17 @@ jest.mock("electron", () => {
},
ipcMain: {
handle: jest.fn(),
on: jest.fn()
on: jest.fn(),
removeAllListeners: jest.fn(),
off: jest.fn(),
send: jest.fn(),
}
};
});
describe("empty config", () => {
beforeEach(async () => {
ClusterStore.getInstance(false)?.unregisterIpcListener();
ClusterStore.resetInstance();
const mockOpts = {
"tmp": {

View File

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

View File

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

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

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;

View File

@ -28,7 +28,7 @@ export async function broadcastMessage(channel: string, ...args: any[]) {
if (ipcRenderer) {
ipcRenderer.send(channel, ...args);
} else {
} else if (ipcMain) {
ipcMain.emit(channel, ...args);
}
@ -55,7 +55,7 @@ export async function broadcastMessage(channel: string, ...args: any[]) {
export function subscribeToBroadcast(channel: string, listener: (...args: any[]) => any) {
if (ipcRenderer) {
ipcRenderer.on(channel, listener);
} else {
} else if (ipcMain) {
ipcMain.on(channel, listener);
}
@ -65,7 +65,7 @@ export function subscribeToBroadcast(channel: string, listener: (...args: any[])
export function unsubscribeFromBroadcast(channel: string, listener: (...args: any[]) => any) {
if (ipcRenderer) {
ipcRenderer.off(channel, listener);
} else {
} else if (ipcMain) {
ipcMain.off(channel, listener);
}
}
@ -73,7 +73,7 @@ export function unsubscribeFromBroadcast(channel: string, listener: (...args: an
export function unsubscribeAllFromBroadcast(channel: string) {
if (ipcRenderer) {
ipcRenderer.removeAllListeners(channel);
} else {
} else if (ipcMain) {
ipcMain.removeAllListeners(channel);
}
}

View File

@ -23,8 +23,8 @@ export const ProtocolHandlerExtension= `${ProtocolHandlerIpcPrefix}:extension`;
* Though under the current (2021/01/18) implementation, these are never matched
* against in the final matching so their names are less of a concern.
*/
const EXTENSION_PUBLISHER_MATCH = "LENS_INTERNAL_EXTENSION_PUBLISHER_MATCH";
const EXTENSION_NAME_MATCH = "LENS_INTERNAL_EXTENSION_NAME_MATCH";
export const EXTENSION_PUBLISHER_MATCH = "LENS_INTERNAL_EXTENSION_PUBLISHER_MATCH";
export const EXTENSION_NAME_MATCH = "LENS_INTERNAL_EXTENSION_NAME_MATCH";
export abstract class LensProtocolRouter extends Singleton {
// Map between path schemas and the handlers
@ -32,7 +32,7 @@ export abstract class LensProtocolRouter extends Singleton {
public static readonly LoggingPrefix = "[PROTOCOL ROUTER]";
protected static readonly ExtensionUrlSchema = `/:${EXTENSION_PUBLISHER_MATCH}(\@[A-Za-z0-9_]+)?/:${EXTENSION_NAME_MATCH}`;
static readonly ExtensionUrlSchema = `/:${EXTENSION_PUBLISHER_MATCH}(\@[A-Za-z0-9_]+)?/:${EXTENSION_NAME_MATCH}`;
/**
*

View File

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

View File

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

View File

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

View File

@ -1,13 +1,89 @@
/**
* Narrows `val` to include the property `key` (if true is returned)
* @param val The object to be tested
* @param key The key to test if it is present on the object
* @param key The key to test if it is present on the object (must be a literal for tsc to do any meaningful typing)
*/
export function hasOwnProperty<V extends object, K extends PropertyKey>(val: V, key: K): val is (V & { [key in K]: unknown }) {
export function hasOwnProperty<S extends object, K extends PropertyKey>(val: S, key: K): val is (S & { [key in K]: unknown }) {
// this call syntax is for when `val` was created by `Object.create(null)`
return Object.prototype.hasOwnProperty.call(val, key);
}
export function hasOwnProperties<V extends object, K extends PropertyKey>(val: V, ...keys: K[]): val is (V & { [key in K]: unknown}) {
/**
* Narrows `val` to a static type that includes fields of names in `keys`
* @param val the value that we are trying to type narrow
* @param keys the key names (must be literals for tsc to do any meaningful typing)
*/
export function hasOwnProperties<S extends object, K extends PropertyKey>(val: S, ...keys: K[]): val is (S & { [key in K]: unknown }) {
return keys.every(key => hasOwnProperty(val, key));
}
/**
* Narrows `val` to include the property `key` with type `V`
* @param val the value that we are trying to type narrow
* @param key The key to test if it is present on the object (must be a literal for tsc to do any meaningful typing)
* @param isValid a function to check if the field is valid
*/
export function hasTypedProperty<S extends object, K extends PropertyKey, V>(val: S, key: K, isValid: (value: unknown) => value is V): val is (S & { [key in K]: V }) {
return hasOwnProperty(val, key) && isValid(val[key]);
}
/**
* Narrows `val` to include the property `key` with type `V | undefined` or doesn't contain it
* @param val the value that we are trying to type narrow
* @param key The key to test if it is present on the object (must be a literal for tsc to do any meaningful typing)
* @param isValid a function to check if the field (when present) is valid
*/
export function hasOptionalProperty<S extends object, K extends PropertyKey, V>(val: S, key: K, isValid: (value: unknown) => value is V): val is (S & { [key in K]?: V }) {
if (hasOwnProperty(val, key)) {
return typeof val[key] === "undefined" || isValid(val[key]);
}
return true;
}
/**
* isRecord checks if `val` matches the signature `Record<T, V>` or `{ [label in T]: V }`
* @param val The value to be checked
* @param isKey a function for checking if the key is of the correct type
* @param isValue a function for checking if a value is of the correct type
*/
export function isRecord<T extends PropertyKey, V>(val: unknown, isKey: (key: unknown) => key is T, isValue: (value: unknown) => value is V): val is Record<T, V> {
return isObject(val) && Object.entries(val).every(([key, value]) => isKey(key) && isValue(value));
}
/**
* isTypedArray checks if `val` is an array and all of its entries are of type `T`
* @param val The value to be checked
* @param isEntry a function for checking if an entry is the correct type
*/
export function isTypedArray<T>(val: unknown, isEntry: (entry: unknown) => entry is T): val is T[] {
return Array.isArray(val) && val.every(isEntry);
}
/**
* checks if val is of type string
* @param val the value to be checked
*/
export function isString(val: unknown): val is string {
return typeof val === "string";
}
/**
* checks if val is of type object and isn't null
* @param val the value to be checked
*/
export function isObject(val: unknown): val is object {
return typeof val === "object" && val !== null;
}
/**
* Creates a new predicate function (with the same predicate) from `fn`. Such
* that it can be called with just the value to be tested.
*
* This is useful for when using `hasOptionalProperty` and `hasTypedProperty`
* @param fn A typescript user predicate function to be bound
* @param boundArgs the set of arguments to be passed to `fn` in the new function
*/
export function bindPredicate<FnArgs extends any[], T>(fn: (arg1: unknown, ...args: FnArgs) => arg1 is T, ...boundArgs: FnArgs): (arg1: unknown) => arg1 is T {
return (arg1: unknown): arg1 is T => fn(arg1, ...boundArgs);
}

View File

@ -1,5 +1,6 @@
// App's common configuration for any process (main, renderer, build pipeline, etc.)
import path from "path";
import { SemVer } from "semver";
import packageInfo from "../../package.json";
import { defineGlobal } from "./utils/defineGlobal";
@ -44,5 +45,11 @@ export const apiKubePrefix = "/api-kube"; // k8s cluster apis
// Links
export const issuesTrackerUrl = "https://github.com/lensapp/lens/issues";
export const slackUrl = "https://join.slack.com/t/k8slens/shared_invite/enQtOTc5NjAyNjYyOTk4LWU1NDQ0ZGFkOWJkNTRhYTc2YjVmZDdkM2FkNGM5MjhiYTRhMDU2NDQ1MzIyMDA4ZGZlNmExOTc0N2JmY2M3ZGI";
export const docsUrl = "https://docs.k8slens.dev/";
export const supportUrl = "https://docs.k8slens.dev/latest/support/";
// This explicitly ignores the prerelease info on the package version
const { major, minor, patch } = new SemVer(packageInfo.version);
const mmpVersion = [major, minor, patch].join(".");
const docsVersion = isProduction ? `v${mmpVersion}` : "latest";
export const docsUrl = `https://docs.k8slens.dev/${docsVersion}`;

View File

@ -1,10 +1,14 @@
import mockFs from "mock-fs";
import { watch } from "chokidar";
import { join, normalize } from "path";
import { ExtensionDiscovery, InstalledExtension } from "../extension-discovery";
import { ExtensionsStore } from "../extensions-store";
import path from "path";
import { ExtensionDiscovery } from "../extension-discovery";
import os from "os";
import { Console } from "console";
jest.setTimeout(60_000);
jest.mock("../../common/ipc");
jest.mock("fs-extra");
jest.mock("chokidar", () => ({
watch: jest.fn()
}));
@ -15,6 +19,7 @@ jest.mock("../extension-installer", () => ({
}
}));
console = new Console(process.stdout, process.stderr); // fix mockFS
const mockedWatch = watch as jest.MockedFunction<typeof watch>;
describe("ExtensionDiscovery", () => {
@ -24,10 +29,20 @@ describe("ExtensionDiscovery", () => {
ExtensionsStore.createInstance();
});
it("emits add for added extension", async done => {
globalThis.__non_webpack_require__.mockImplementation(() => ({
describe("with mockFs", () => {
beforeEach(() => {
mockFs({
[`${os.homedir()}/.k8slens/extensions/my-extension/package.json`]: JSON.stringify({
name: "my-extension"
}));
}),
});
});
afterEach(() => {
mockFs.restore();
});
it("emits add for added extension", async (done) => {
let addHandler: (filePath: string) => void;
const mockWatchInstance: any = {
@ -43,6 +58,7 @@ describe("ExtensionDiscovery", () => {
mockedWatch.mockImplementationOnce(() =>
(mockWatchInstance) as any
);
const extensionDiscovery = ExtensionDiscovery.createInstance();
// Need to force isLoaded to be true so that the file watching is started
@ -50,21 +66,22 @@ describe("ExtensionDiscovery", () => {
await extensionDiscovery.watchExtensions();
extensionDiscovery.events.on("add", (extension: InstalledExtension) => {
extensionDiscovery.events.on("add", extension => {
expect(extension).toEqual({
absolutePath: expect.any(String),
id: normalize("node_modules/my-extension/package.json"),
id: path.normalize("node_modules/my-extension/package.json"),
isBundled: false,
isEnabled: false,
manifest: {
name: "my-extension",
},
manifestPath: normalize("node_modules/my-extension/package.json"),
manifestPath: path.normalize("node_modules/my-extension/package.json"),
});
done();
});
addHandler(join(extensionDiscovery.localFolderPath, "/my-extension/package.json"));
addHandler(path.join(extensionDiscovery.localFolderPath, "/my-extension/package.json"));
});
});
it("doesn't emit add for added file under extension", async done => {
@ -94,7 +111,7 @@ describe("ExtensionDiscovery", () => {
extensionDiscovery.events.on("add", onAdd);
addHandler(join(extensionDiscovery.localFolderPath, "/my-extension/node_modules/dep/package.json"));
addHandler(path.join(extensionDiscovery.localFolderPath, "/my-extension/node_modules/dep/package.json"));
setTimeout(() => {
expect(onAdd).not.toHaveBeenCalled();

View File

@ -1,15 +1,17 @@
import { watch } from "chokidar";
import { ipcRenderer } from "electron";
import { EventEmitter } from "events";
import fs from "fs-extra";
import fse from "fs-extra";
import { 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 (expectedPath !== extensionFolderName) {
return;
}
if (path.relative(this.localFolderPath, filePath) === extensionFolderName) {
const extension = Array.from(this.extensions.values()).find((extension) => extension.absolutePath === filePath);
if (extension) {
if (!extension) {
return void logger.warn(`${logModule} extension ${extensionFolderName} not found, can't remove`);
}
const extensionName = extension.manifest.name;
// If the extension is deleted manually while the application is running, also remove the symlink
await this.removeSymlinkByPackageName(extensionName);
// The path to the manifest file is the lens extension id
// Note that we need to use the symlinked path
// Note: that we need to use the symlinked path
const lensExtensionId = extension.manifestPath;
this.extensions.delete(extension.id);
logger.info(`${logModule} removed extension ${extensionName}`);
this.events.emit("remove", lensExtensionId as LensExtensionId);
} else {
logger.warn(`${logModule} extension ${extensionFolderName} not found, can't remove`);
}
}
this.events.emit("remove", lensExtensionId);
};
/**
@ -221,31 +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)) {

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

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

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";

View File

@ -9,9 +9,17 @@ export interface KubeObjectStatusRegistration {
export class KubeObjectStatusRegistry extends BaseRegistry<KubeObjectStatusRegistration> {
getItemsForKind(kind: string, apiVersion: string) {
return this.getItems().filter((item) => {
return item.kind === kind && item.apiVersions.includes(apiVersion);
});
return this.getItems()
.filter((item) => (
item.kind === kind
&& item.apiVersions.includes(apiVersion)
));
}
getItemsForObject(src: KubeObject) {
return this.getItemsForKind(src.kind, src.apiVersion)
.map(item => item.resolve(src))
.filter(Boolean);
}
}

View File

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

View File

@ -1,6 +1,6 @@
import { app, remote } from "electron";
import winston from "winston";
import { isDebugging } from "../common/vars";
import { isDebugging, isTestEnv } from "../common/vars";
const logLevel = process.env.LOG_LEVEL ? process.env.LOG_LEVEL : isDebugging ? "debug" : "info";
const consoleOptions: winston.transports.ConsoleTransportOptions = {
@ -23,7 +23,7 @@ const logger = winston.createLogger({
),
transports: [
new winston.transports.Console(consoleOptions),
new winston.transports.File(fileOptions),
...(isTestEnv ? [] : [new winston.transports.File(fileOptions)]),
],
});

View File

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

View File

@ -0,0 +1,228 @@
import { KubeObject } from "../kube-object";
describe("KubeObject", () => {
describe("isJsonApiData", () => {
{
type TestCase = [any];
const tests: TestCase[] = [
[false],
[true],
[null],
[undefined],
[""],
[1],
[(): unknown => void 0],
[Symbol("hello")],
[{}],
];
it.each(tests)("should reject invalid value: %p", (input) => {
expect(KubeObject.isJsonApiData(input)).toBe(false);
});
}
{
type TestCase = [string, any];
const tests: TestCase[] = [
["kind", { apiVersion: "", metadata: {uid: "", name: "", resourceVersion: "", selfLink: ""} }],
["apiVersion", { kind: "", metadata: {uid: "", name: "", resourceVersion: "", selfLink: ""} }],
["metadata", { kind: "", apiVersion: "" }],
["metadata.uid", { kind: "", apiVersion: "", metadata: { name: "", resourceVersion: "", selfLink: ""} }],
["metadata.name", { kind: "", apiVersion: "", metadata: { uid: "", resourceVersion: "", selfLink: "" } }],
["metadata.resourceVersion", { kind: "", apiVersion: "", metadata: { uid: "", name: "", selfLink: "" } }],
];
it.each(tests)("should reject with missing: %s", (missingField, input) => {
expect(KubeObject.isJsonApiData(input)).toBe(false);
});
}
{
type TestCase = [string, any];
const tests: TestCase[] = [
["kind", { kind: 1, apiVersion: "", metadata: {} }],
["apiVersion", { apiVersion: 1, kind: "", metadata: {} }],
["metadata", { kind: "", apiVersion: "", metadata: "" }],
["metadata.uid", { kind: "", apiVersion: "", metadata: { uid: 1 } }],
["metadata.name", { kind: "", apiVersion: "", metadata: { uid: "", name: 1 } }],
["metadata.resourceVersion", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: 1 } }],
["metadata.selfLink", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: 1 } }],
["metadata.namespace", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", namespace: 1 } }],
["metadata.creationTimestamp", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", creationTimestamp: 1 } }],
["metadata.continue", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", continue: 1 } }],
["metadata.finalizers", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", finalizers: 1 } }],
["metadata.finalizers", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", finalizers: [1] } }],
["metadata.finalizers", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", finalizers: {} } }],
["metadata.labels", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", labels: 1 } }],
["metadata.labels", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", labels: { food: 1 } } }],
["metadata.annotations", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", annotations: 1 } }],
["metadata.annotations", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", annotations: { food: 1 } } }],
];
it.each(tests)("should reject with wrong type for field: %s", (missingField, input) => {
expect(KubeObject.isJsonApiData(input)).toBe(false);
});
}
it("should accept valid KubeJsonApiData (ignoring other fields)", () => {
const valid = { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", annotations: { food: "" } } };
expect(KubeObject.isJsonApiData(valid)).toBe(true);
});
});
describe("isPartialJsonApiData", () => {
{
type TestCase = [any];
const tests: TestCase[] = [
[false],
[true],
[null],
[undefined],
[""],
[1],
[(): unknown => void 0],
[Symbol("hello")],
];
it.each(tests)("should reject invalid value: %p", (input) => {
expect(KubeObject.isPartialJsonApiData(input)).toBe(false);
});
}
it("should accept {}", () => {
expect(KubeObject.isPartialJsonApiData({})).toBe(true);
});
{
type TestCase = [string, any];
const tests: TestCase[] = [
["kind", { apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "" } }],
["apiVersion", { kind: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "" } }],
["metadata", { kind: "", apiVersion: "" }],
];
it.each(tests)("should not reject with missing top level field: %s", (missingField, input) => {
expect(KubeObject.isPartialJsonApiData(input)).toBe(true);
});
}
{
type TestCase = [string, any];
const tests: TestCase[] = [
["metadata.uid", { kind: "", apiVersion: "", metadata: { name: "", resourceVersion: "", selfLink: ""} }],
["metadata.name", { kind: "", apiVersion: "", metadata: { uid: "", resourceVersion: "", selfLink: "" } }],
["metadata.resourceVersion", { kind: "", apiVersion: "", metadata: { uid: "", name: "", selfLink: "" } }],
];
it.each(tests)("should reject with missing non-top level field: %s", (missingField, input) => {
expect(KubeObject.isPartialJsonApiData(input)).toBe(false);
});
}
{
type TestCase = [string, any];
const tests: TestCase[] = [
["kind", { kind: 1, apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "" } }],
["apiVersion", { apiVersion: 1, kind: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "" } }],
["metadata", { kind: "", apiVersion: "", metadata: "" }],
["metadata.uid", { kind: "", apiVersion: "", metadata: { uid: 1, name: "", resourceVersion: "", selfLink: "" } }],
["metadata.name", { kind: "", apiVersion: "", metadata: { uid: "", name: 1, resourceVersion: "", selfLink: "" } }],
["metadata.resourceVersion", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: 1, selfLink: "" } }],
["metadata.selfLink", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: 1 } }],
["metadata.namespace", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", namespace: 1 } }],
["metadata.creationTimestamp", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", creationTimestamp: 1 } }],
["metadata.continue", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", continue: 1 } }],
["metadata.finalizers", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", finalizers: 1 } }],
["metadata.finalizers", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", finalizers: [1] } }],
["metadata.finalizers", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", finalizers: {} } }],
["metadata.labels", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", labels: 1 } }],
["metadata.labels", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", labels: { food: 1 } } }],
["metadata.annotations", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", annotations: 1 } }],
["metadata.annotations", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", annotations: { food: 1 } } }],
];
it.each(tests)("should reject with wrong type for field: %s", (missingField, input) => {
expect(KubeObject.isPartialJsonApiData(input)).toBe(false);
});
}
it("should accept valid Partial<KubeJsonApiData> (ignoring other fields)", () => {
const valid = { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", annotations: { food: "" } } };
expect(KubeObject.isPartialJsonApiData(valid)).toBe(true);
});
});
describe("isJsonApiDataList", () => {
function isAny(val: unknown): val is any {
return !Boolean(void val);
}
function isNotAny(val: unknown): val is any {
return Boolean(void val);
}
function isBoolean(val: unknown): val is Boolean {
return typeof val === "boolean";
}
{
type TestCase = [any];
const tests: TestCase[] = [
[false],
[true],
[null],
[undefined],
[""],
[1],
[(): unknown => void 0],
[Symbol("hello")],
[{}],
];
it.each(tests)("should reject invalid value: %p", (input) => {
expect(KubeObject.isJsonApiDataList(input, isAny)).toBe(false);
});
}
{
type TestCase = [string, any];
const tests: TestCase[] = [
["kind", { apiVersion: "", items: [], metadata: { resourceVersion: "", selfLink: "" } }],
["apiVersion", { kind: "", items: [], metadata: { resourceVersion: "", selfLink: "" } }],
["metadata", { kind: "", items: [], apiVersion: "" }],
["metadata.resourceVersion", { kind: "", items: [], apiVersion: "", metadata: { selfLink: "" } }],
];
it.each(tests)("should reject with missing: %s", (missingField, input) => {
expect(KubeObject.isJsonApiDataList(input, isAny)).toBe(false);
});
}
{
type TestCase = [string, any];
const tests: TestCase[] = [
["kind", { kind: 1, items: [], apiVersion: "", metadata: { resourceVersion: "", selfLink: "" } }],
["apiVersion", { kind: "", items: [], apiVersion: 1, metadata: { resourceVersion: "", selfLink: "" } }],
["metadata", { kind: "", items: [], apiVersion: "", metadata: 1 }],
["metadata.resourceVersion", { kind: "", items: [], apiVersion: "", metadata: { resourceVersion: 1, selfLink: "" } }],
["metadata.selfLink", { kind: "", items: [], apiVersion: "", metadata: { resourceVersion: "", selfLink: 1 } }],
["items", { kind: "", items: 1, apiVersion: "", metadata: { resourceVersion: "", selfLink: "" } }],
["items", { kind: "", items: "", apiVersion: "", metadata: { resourceVersion: "", selfLink: "" } }],
["items", { kind: "", items: {}, apiVersion: "", metadata: { resourceVersion: "", selfLink: "" } }],
["items[0]", { kind: "", items: [""], apiVersion: "", metadata: { resourceVersion: "", selfLink: "" } }],
];
it.each(tests)("should reject with wrong type for field: %s", (missingField, input) => {
expect(KubeObject.isJsonApiDataList(input, isNotAny)).toBe(false);
});
}
it("should accept valid KubeJsonApiDataList (ignoring other fields)", () => {
const valid = { kind: "", items: [false], apiVersion: "", metadata: { resourceVersion: "", selfLink: "" } };
expect(KubeObject.isJsonApiDataList(valid, isBoolean)).toBe(true);
});
});
});

View File

@ -16,39 +16,51 @@ const endpoint = compile(`/v2/charts/:repo?/:name?`) as (params?: {
name?: string;
}) => string;
export const helmChartsApi = {
list() {
return apiBase
.get<HelmChartList>(endpoint())
.then(data => {
/**
* Get a list of all helm charts from all saved helm repos
*/
export async function listCharts(): Promise<HelmChart[]> {
const data = await apiBase.get<HelmChartList>(endpoint());
return Object
.values(data)
.reduce((allCharts, repoCharts) => allCharts.concat(Object.values(repoCharts)), [])
.map(([chart]) => HelmChart.create(chart));
});
},
}
get(repo: string, name: string, readmeVersion?: string) {
export interface GetChartDetailsOptions {
version?: string;
reqInit?: RequestInit;
}
/**
* Get the readme and all versions of a chart
* @param repo The repo to get from
* @param name The name of the chart to request the data of
* @param options.version The version of the chart's readme to get, default latest
* @param options.reqInit A way for passing in an abort controller or other browser request options
*/
export async function getChartDetails(repo: string, name: string, { version, reqInit }: GetChartDetailsOptions = {}): Promise<IHelmChartDetails> {
const path = endpoint({ repo, name });
return apiBase
.get<IHelmChartDetails>(`${path}?${stringify({ version: readmeVersion })}`)
.then(data => {
const { readme, ...data } = await apiBase.get<IHelmChartDetails>(`${path}?${stringify({ version })}`, undefined, reqInit);
const versions = data.versions.map(HelmChart.create);
const readme = data.readme;
return {
readme,
versions,
};
});
},
}
getValues(repo: string, name: string, version: string) {
return apiBase
.get<string>(`/v2/charts/${repo}/${name}/values?${stringify({ version })}`);
}
};
/**
* Get chart values related to a specific repos' version of a chart
* @param repo The repo to get from
* @param name The name of the chart to request the data of
* @param version The version to get the values from
*/
export async function getChartValues(repo: string, name: string, version: string): Promise<string> {
return apiBase.get<string>(`/v2/charts/${repo}/${name}/values?${stringify({ version })}`);
}
@autobind()
export class HelmChart {

View File

@ -2,8 +2,8 @@
import { stringify } from "querystring";
import { EventEmitter } from "../../common/event-emitter";
import { cancelableFetch } from "../utils/cancelableFetch";
import { randomBytes } from "crypto";
export interface JsonApiData {
}
@ -72,13 +72,11 @@ export class JsonApi<D = JsonApiData, P extends JsonApiParams = JsonApiParams> {
reqUrl += (reqUrl.includes("?") ? "&" : "?") + queryString;
}
const infoLog: JsonApiLog = {
this.writeLog({
method: reqInit.method.toUpperCase(),
reqUrl: reqPath,
reqInit,
};
this.writeLog({ ...infoLog });
});
return fetch(reqUrl, reqInit);
}
@ -99,7 +97,7 @@ export class JsonApi<D = JsonApiData, P extends JsonApiParams = JsonApiParams> {
return this.request<T>(path, params, { ...reqInit, method: "delete" });
}
protected request<D>(path: string, params?: P, init: RequestInit = {}) {
protected async request<D>(path: string, params?: P, init: RequestInit = {}) {
let reqUrl = this.config.apiBase + path;
const reqInit: RequestInit = { ...this.reqInit, ...init };
const { data, query } = params || {} as P;
@ -119,15 +117,15 @@ export class JsonApi<D = JsonApiData, P extends JsonApiParams = JsonApiParams> {
reqInit,
};
return cancelableFetch(reqUrl, reqInit).then(res => {
const res = await fetch(reqUrl, reqInit);
return this.parseResponse<D>(res, infoLog);
});
}
protected parseResponse<D>(res: Response, log: JsonApiLog): Promise<D> {
protected async parseResponse<D>(res: Response, log: JsonApiLog): Promise<D> {
const { status } = res;
return res.text().then(text => {
const text = await res.text();
let data;
try {
@ -141,26 +139,31 @@ export class JsonApi<D = JsonApiData, P extends JsonApiParams = JsonApiParams> {
this.writeLog({ ...log, data });
return data;
} else if (log.method === "GET" && res.status === 403) {
this.writeLog({ ...log, data });
} else {
}
if (log.method === "GET" && res.status === 403) {
this.writeLog({ ...log, error: data });
throw data;
}
const error = new JsonApiErrorParsed(data, this.parseError(data, res));
this.onError.emit(error, res);
this.writeLog({ ...log, error });
throw error;
}
});
}
protected parseError(error: JsonApiError | string, res: Response): string[] {
if (typeof error === "string") {
return [error];
}
else if (Array.isArray(error.errors)) {
if (Array.isArray(error.errors)) {
return error.errors.map(error => error.title);
}
else if (error.message) {
if (error.message) {
return [error.message];
}

View File

@ -7,11 +7,12 @@ import logger from "../../main/logger";
import { apiManager } from "./api-manager";
import { apiKube } from "./index";
import { createKubeApiURL, parseKubeApi } from "./kube-api-parse";
import { KubeJsonApi, KubeJsonApiData, KubeJsonApiDataList } from "./kube-json-api";
import { IKubeObjectConstructor, KubeObject, KubeStatus } from "./kube-object";
import byline from "byline";
import { IKubeWatchEvent } from "./kube-watch-api";
import { ReadableWebToNodeStream } from "../utils/readableStream";
import { KubeJsonApi, KubeJsonApiData } from "./kube-json-api";
import { noop } from "../utils";
export interface IKubeApiOptions<T extends KubeObject> {
/**
@ -34,6 +35,11 @@ export interface IKubeApiOptions<T extends KubeObject> {
checkPreferredVersion?: boolean;
}
export interface KubeApiListOptions {
namespace?: string;
reqInit?: RequestInit;
}
export interface IKubeApiQueryParams {
watch?: boolean | number;
resourceVersion?: string;
@ -245,7 +251,7 @@ export class KubeApi<T extends KubeObject = any> {
return this.resourceVersions.get(namespace);
}
async refreshResourceVersion(params?: { namespace: string }) {
async refreshResourceVersion(params?: KubeApiListOptions) {
return this.list(params, { limit: 1 });
}
@ -273,20 +279,12 @@ export class KubeApi<T extends KubeObject = any> {
return query;
}
protected parseResponse(data: KubeJsonApiData | KubeJsonApiData[] | KubeJsonApiDataList, namespace?: string): any {
protected parseResponse(data: unknown, namespace?: string): T | T[] | null {
if (!data) return;
const KubeObjectConstructor = this.objectConstructor;
if (KubeObject.isJsonApiData(data)) {
const object = new KubeObjectConstructor(data);
ensureObjectSelfLink(this, object);
return object;
}
// process items list response
if (KubeObject.isJsonApiDataList(data)) {
// process items list response, check before single item since there is overlap
if (KubeObject.isJsonApiDataList(data, KubeObject.isPartialJsonApiData)) {
const { apiVersion, items, metadata } = data;
this.setResourceVersion(namespace, metadata.resourceVersion);
@ -305,36 +303,60 @@ export class KubeApi<T extends KubeObject = any> {
});
}
// process a single item
if (KubeObject.isJsonApiData(data)) {
const object = new KubeObjectConstructor(data);
ensureObjectSelfLink(this, object);
return object;
}
// custom apis might return array for list response, e.g. users, groups, etc.
if (Array.isArray(data)) {
return data.map(data => new KubeObjectConstructor(data));
}
return data;
return null;
}
async list({ namespace = "" } = {}, query?: IKubeApiQueryParams): Promise<T[]> {
async list({ namespace = "", reqInit }: KubeApiListOptions = {}, query?: IKubeApiQueryParams): Promise<T[] | null> {
await this.checkPreferredVersion();
return this.request
.get(this.getUrl({ namespace }), { query })
.then(data => this.parseResponse(data, namespace));
const url = this.getUrl({ namespace });
const res = await this.request.get(url, { query }, reqInit);
const parsed = this.parseResponse(res, namespace);
if (Array.isArray(parsed)) {
return parsed;
}
async get({ name = "", namespace = "default" } = {}, query?: IKubeApiQueryParams): Promise<T> {
await this.checkPreferredVersion();
return this.request
.get(this.getUrl({ namespace, name }), { query })
.then(this.parseResponse);
if (!parsed) {
return null;
}
async create({ name = "", namespace = "default" } = {}, data?: Partial<T>): Promise<T> {
throw new Error(`GET multiple request to ${url} returned not an array: ${JSON.stringify(parsed)}`);
}
async get({ name = "", namespace = "default" } = {}, query?: IKubeApiQueryParams): Promise<T | null> {
await this.checkPreferredVersion();
const url = this.getUrl({ namespace, name });
const res = await this.request.get(url, { query });
const parsed = this.parseResponse(res);
if (Array.isArray(parsed)) {
throw new Error(`GET single request to ${url} returned an array: ${JSON.stringify(parsed)}`);
}
return parsed;
}
async create({ name = "", namespace = "default" } = {}, data?: Partial<T>): Promise<T | null> {
await this.checkPreferredVersion();
const apiUrl = this.getUrl({ namespace });
return this.request
.post(apiUrl, {
const res = await this.request.post(apiUrl, {
data: merge({
kind: this.kind,
apiVersion: this.apiVersionWithGroup,
@ -343,17 +365,28 @@ export class KubeApi<T extends KubeObject = any> {
namespace
}
}, data)
})
.then(this.parseResponse);
});
const parsed = this.parseResponse(res);
if (Array.isArray(parsed)) {
throw new Error(`POST request to ${apiUrl} returned an array: ${JSON.stringify(parsed)}`);
}
async update({ name = "", namespace = "default" } = {}, data?: Partial<T>): Promise<T> {
return parsed;
}
async update({ name = "", namespace = "default" } = {}, data?: Partial<T>): Promise<T | null> {
await this.checkPreferredVersion();
const apiUrl = this.getUrl({ namespace, name });
return this.request
.put(apiUrl, { data })
.then(this.parseResponse);
const res = await this.request.put(apiUrl, { data });
const parsed = this.parseResponse(res);
if (Array.isArray(parsed)) {
throw new Error(`PUT request to ${apiUrl} returned an array: ${JSON.stringify(parsed)}`);
}
return parsed;
}
async delete({ name = "", namespace = "default" }) {
@ -372,28 +405,23 @@ export class KubeApi<T extends KubeObject = any> {
}
watch(opts: KubeApiWatchOptions = { namespace: "" }): () => void {
if (!opts.abortController) {
opts.abortController = new AbortController();
}
let errorReceived = false;
let timedRetry: NodeJS.Timeout;
const { abortController, namespace, callback } = opts;
const { abortController: { abort, signal } = new AbortController(), namespace, callback = noop } = opts;
abortController.signal.addEventListener("abort", () => {
signal.addEventListener("abort", () => {
clearTimeout(timedRetry);
});
const watchUrl = this.getWatchUrl(namespace);
const responsePromise = this.request.getResponse(watchUrl, null, {
signal: abortController.signal
});
const responsePromise = this.request.getResponse(watchUrl, null, { signal });
responsePromise.then((response) => {
if (!response.ok && !abortController.signal.aborted) {
callback?.(null, response);
return;
responsePromise
.then(response => {
if (!response.ok) {
return callback(null, response);
}
const nodeStream = new ReadableWebToNodeStream(response.body);
["end", "close", "error"].forEach((eventName) => {
@ -402,48 +430,35 @@ export class KubeApi<T extends KubeObject = any> {
clearTimeout(timedRetry);
timedRetry = setTimeout(() => { // we did not get any kubernetes errors so let's retry
if (abortController.signal.aborted) return;
this.watch({...opts, namespace, callback});
}, 1000);
});
});
const stream = byline(nodeStream);
stream.on("data", (line) => {
byline(nodeStream).on("data", (line) => {
try {
const event: IKubeWatchEvent = JSON.parse(line);
if (event.type === "ERROR" && event.object.kind === "Status") {
errorReceived = true;
callback(null, new KubeStatus(event.object as any));
return;
return callback(null, new KubeStatus(event.object as any));
}
this.modifyWatchEvent(event);
if (callback) {
callback(event, null);
}
} catch (ignore) {
// ignore parse errors
}
});
}, (error) => {
})
.catch(error => {
if (error instanceof DOMException) return; // AbortController rejects, we can ignore it
callback?.(null, error);
}).catch((error) => {
callback?.(null, error);
callback(null, error);
});
const disposer = () => {
abortController.abort();
};
return disposer;
return abort;
}
protected modifyWatchEvent(event: IKubeWatchEvent) {

View File

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

View File

@ -1,12 +1,13 @@
// Base class for all kubernetes objects
import moment from "moment";
import { KubeJsonApiData, KubeJsonApiDataList } from "./kube-json-api";
import { KubeJsonApiData, KubeJsonApiDataList, KubeJsonApiListMetadata, KubeJsonApiMetadata } from "./kube-json-api";
import { autobind, formatDuration } from "../utils";
import { ItemObject } from "../item.store";
import { apiKube } from "./index";
import { JsonApiParams } from "./json-api";
import { resourceApplierApi } from "./endpoints/resource-applier.api";
import { hasOptionalProperty, hasTypedProperty, isObject, isString, bindPredicate, isTypedArray, isRecord } from "../../common/utils/type-narrowing";
export type IKubeObjectConstructor<T extends KubeObject = any> = (new (data: KubeJsonApiData | any) => T) & {
kind?: string;
@ -78,15 +79,59 @@ export class KubeObject implements ItemObject {
return !item.metadata.name.startsWith("system:");
}
static isJsonApiData(object: any): object is KubeJsonApiData {
return !object.items && object.metadata;
static isJsonApiData(object: unknown): object is KubeJsonApiData {
return (
isObject(object)
&& hasTypedProperty(object, "kind", isString)
&& hasTypedProperty(object, "apiVersion", isString)
&& hasTypedProperty(object, "metadata", KubeObject.isKubeJsonApiMetadata)
);
}
static isJsonApiDataList(object: any): object is KubeJsonApiDataList {
return object.items && object.metadata;
static isKubeJsonApiListMetadata(object: unknown): object is KubeJsonApiListMetadata {
return (
isObject(object)
&& hasTypedProperty(object, "resourceVersion", isString)
&& hasOptionalProperty(object, "selfLink", isString)
);
}
static stringifyLabels(labels: { [name: string]: string }): string[] {
static isKubeJsonApiMetadata(object: unknown): object is KubeJsonApiMetadata {
return (
isObject(object)
&& hasTypedProperty(object, "uid", isString)
&& hasTypedProperty(object, "name", isString)
&& hasTypedProperty(object, "resourceVersion", isString)
&& hasOptionalProperty(object, "selfLink", isString)
&& hasOptionalProperty(object, "namespace", isString)
&& hasOptionalProperty(object, "creationTimestamp", isString)
&& hasOptionalProperty(object, "continue", isString)
&& hasOptionalProperty(object, "finalizers", bindPredicate(isTypedArray, isString))
&& hasOptionalProperty(object, "labels", bindPredicate(isRecord, isString, isString))
&& hasOptionalProperty(object, "annotations", bindPredicate(isRecord, isString, isString))
);
}
static isPartialJsonApiData(object: unknown): object is Partial<KubeJsonApiData> {
return (
isObject(object)
&& hasOptionalProperty(object, "kind", isString)
&& hasOptionalProperty(object, "apiVersion", isString)
&& hasOptionalProperty(object, "metadata", KubeObject.isKubeJsonApiMetadata)
);
}
static isJsonApiDataList<T>(object: unknown, verifyItem:(val: unknown) => val is T): object is KubeJsonApiDataList<T> {
return (
isObject(object)
&& hasTypedProperty(object, "kind", isString)
&& hasTypedProperty(object, "apiVersion", isString)
&& hasTypedProperty(object, "metadata", KubeObject.isKubeJsonApiListMetadata)
&& hasTypedProperty(object, "items", bindPredicate(isTypedArray, verifyItem))
);
}
static stringifyLabels(labels?: { [name: string]: string }): string[] {
if (!labels) return [];
return Object.entries(labels).map(([name, value]) => `${name}=${value}`);

View File

@ -20,6 +20,7 @@ import { App } from "./components/app";
import { LensApp } from "./lens-app";
import { ThemeStore } from "./theme.store";
import { HelmRepoManager } from "../main/helm/helm-repo-manager";
import { ExtensionInstallationStateStore } from "./components/+extensions/extension-install.store";
/**
* If this is a development buid, wait a second to attach
@ -61,6 +62,7 @@ export async function bootstrap(App: AppComponent) {
const themeStore = ThemeStore.createInstance();
const hotbarStore = HotbarStore.createInstance();
ExtensionInstallationStateStore.bindIpcListeners();
HelmRepoManager.createInstance(); // initialize the manager
// preload common stores

View File

@ -1,14 +1,13 @@
import "./helm-chart-details.scss";
import React, { Component } from "react";
import { HelmChart, helmChartsApi } from "../../api/endpoints/helm-charts.api";
import { getChartDetails, HelmChart } from "../../api/endpoints/helm-charts.api";
import { observable, autorun, 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 => {
getChartDetails(repo, name, { version })
.then(result => {
this.readme = result.readme;
this.chartVersions = result.versions;
this.selectedChart = result.versions[0];
},
error => {
})
.catch(error => {
this.error = error;
});
});
@autobind()
async onVersionChange({ value: version }: SelectOption) {
async onVersionChange({ value: version }: SelectOption<string>) {
this.selectedChart = this.chartVersions.find(chart => chart.version === version);
this.readme = null;
try {
this.chartPromise?.cancel();
this.abortController?.abort();
this.abortController = new AbortController();
const { chart: { name, repo } } = this.props;
const { readme } = await (this.chartPromise = helmChartsApi.get(repo, name, version));
const { readme } = await getChartDetails(repo, name, { version, reqInit: { signal: this.abortController.signal }});
this.readme = readme;
} catch (error) {

View File

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

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) {

View File

@ -47,7 +47,8 @@ export class HorizontalPodAutoscalers extends React.Component<Props> {
[columnId.namespace]: (item: HorizontalPodAutoscaler) => item.getNs(),
[columnId.minPods]: (item: HorizontalPodAutoscaler) => item.getMinPods(),
[columnId.maxPods]: (item: HorizontalPodAutoscaler) => item.getMaxPods(),
[columnId.replicas]: (item: HorizontalPodAutoscaler) => item.getReplicas()
[columnId.replicas]: (item: HorizontalPodAutoscaler) => item.getReplicas(),
[columnId.age]: (item: HorizontalPodAutoscaler) => item.getTimeDiffFromNow(),
}}
searchFilters={[
(item: HorizontalPodAutoscaler) => item.getSearchFields()

View File

@ -1,16 +1,18 @@
import "@testing-library/jest-dom/extend-expect";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { fireEvent, render, waitFor } from "@testing-library/react";
import fse from "fs-extra";
import React from "react";
import { UserStore } from "../../../../common/user-store";
import { ExtensionDiscovery } from "../../../../extensions/extension-discovery";
import { ExtensionLoader } from "../../../../extensions/extension-loader";
import { ThemeStore } from "../../../theme.store";
import { ConfirmDialog } from "../../confirm-dialog";
import { Notifications } from "../../notifications";
import { ExtensionInstallationStateStore } from "../extension-install.store";
import { Extensions } from "../extensions";
import mockFs from "mock-fs";
jest.setTimeout(30000);
jest.mock("fs-extra");
jest.mock("../../notifications");
jest.mock("../../../../common/utils", () => ({
...jest.requireActual("../../../../common/utils"),
@ -20,37 +22,30 @@ jest.mock("../../../../common/utils", () => ({
extractTar: jest.fn(() => Promise.resolve())
}));
jest.mock("../../notifications", () => ({
ok: jest.fn(),
error: jest.fn(),
info: jest.fn()
}));
jest.mock("electron", () => {
return {
jest.mock("electron", () => ({
app: {
getVersion: () => "99.99.99",
getPath: () => "tmp",
getLocale: () => "en",
setLoginItemSettings: (): void => void 0,
}
};
});
}));
describe("Extensions", () => {
beforeEach(async () => {
mockFs({
"tmp": {}
});
ExtensionInstallationStateStore.reset();
UserStore.resetInstance();
ThemeStore.resetInstance();
await UserStore.createInstance().load();
await ThemeStore.createInstance().init();
ExtensionLoader.resetInstance();
ExtensionDiscovery.resetInstance();
Extensions.installStates.clear();
ExtensionDiscovery.createInstance().uninstallExtension = jest.fn(() => Promise.resolve());
ExtensionLoader.resetInstance();
ExtensionLoader.createInstance().addExtension({
id: "extensionId",
manifest: {
@ -64,49 +59,38 @@ describe("Extensions", () => {
});
});
afterEach(() => {
mockFs.restore();
});
it("disables uninstall and disable buttons while uninstalling", async () => {
ExtensionDiscovery.getInstance().isLoaded = true;
render(<><Extensions /><ConfirmDialog/></>);
expect(screen.getByText("Disable").closest("button")).not.toBeDisabled();
expect(screen.getByText("Uninstall").closest("button")).not.toBeDisabled();
const res = render(<><Extensions /><ConfirmDialog /></>);
fireEvent.click(screen.getByText("Uninstall"));
expect(res.getByText("Disable").closest("button")).not.toBeDisabled();
expect(res.getByText("Uninstall").closest("button")).not.toBeDisabled();
fireEvent.click(res.getByText("Uninstall"));
// Approve confirm dialog
fireEvent.click(screen.getByText("Yes"));
fireEvent.click(res.getByText("Yes"));
await waitFor(() => {
expect(ExtensionDiscovery.getInstance().uninstallExtension).toHaveBeenCalled();
expect(screen.getByText("Disable").closest("button")).toBeDisabled();
expect(screen.getByText("Uninstall").closest("button")).toBeDisabled();
});
it("displays error notification on uninstall error", () => {
ExtensionDiscovery.getInstance().isLoaded = true;
(ExtensionDiscovery.getInstance().uninstallExtension as any).mockImplementationOnce(() =>
Promise.reject()
);
render(<><Extensions /><ConfirmDialog/></>);
expect(screen.getByText("Disable").closest("button")).not.toBeDisabled();
expect(screen.getByText("Uninstall").closest("button")).not.toBeDisabled();
fireEvent.click(screen.getByText("Uninstall"));
// Approve confirm dialog
fireEvent.click(screen.getByText("Yes"));
waitFor(() => {
expect(screen.getByText("Disable").closest("button")).not.toBeDisabled();
expect(screen.getByText("Uninstall").closest("button")).not.toBeDisabled();
expect(Notifications.error).toHaveBeenCalledTimes(1);
expect(res.getByText("Disable").closest("button")).toBeDisabled();
expect(res.getByText("Uninstall").closest("button")).toBeDisabled();
}, {
timeout: 30000,
});
});
it("disables install button while installing", () => {
render(<Extensions />);
it("disables install button while installing", async () => {
const res = render(<Extensions />);
fireEvent.change(screen.getByPlaceholderText("Path or URL to an extension package", {
(fse.unlink as jest.MockedFunction<typeof fse.unlink>).mockReturnValue(Promise.resolve() as any);
fireEvent.change(res.getByPlaceholderText("Path or URL to an extension package", {
exact: false
}), {
target: {
@ -114,13 +98,8 @@ describe("Extensions", () => {
}
});
fireEvent.click(screen.getByText("Install"));
waitFor(() => {
expect(screen.getByText("Install").closest("button")).toBeDisabled();
expect(fse.move).toHaveBeenCalledWith("");
expect(Notifications.error).not.toHaveBeenCalled();
});
fireEvent.click(res.getByText("Install"));
expect(res.getByText("Install").closest("button")).toBeDisabled();
});
it("displays spinner while extensions are loading", () => {
@ -128,8 +107,11 @@ describe("Extensions", () => {
const { container } = render(<Extensions />);
expect(container.querySelector(".Spinner")).toBeInTheDocument();
});
it("does not display the spinner while extensions are not loading", async () => {
ExtensionDiscovery.getInstance().isLoaded = true;
const { container } = render(<Extensions />);
waitFor(() =>
expect(container.querySelector(".Spinner")).not.toBeInTheDocument()

View File

@ -0,0 +1,218 @@
import { action, computed, observable } from "mobx";
import logger from "../../../main/logger";
import { disposer, ExtendableDisposer } from "../../utils";
import * as uuid from "uuid";
import { broadcastMessage } from "../../../common/ipc";
import { ipcRenderer } from "electron";
export enum ExtensionInstallationState {
INSTALLING = "installing",
UNINSTALLING = "uninstalling",
IDLE = "idle",
}
const Prefix = "[ExtensionInstallationStore]";
export class ExtensionInstallationStateStore {
private static InstallingFromMainChannel = "extension-installation-state-store:install";
private static ClearInstallingFromMainChannel = "extension-installation-state-store:clear-install";
private static PreInstallIds = observable.set<string>();
private static UninstallingExtensions = observable.set<string>();
private static InstallingExtensions = observable.set<string>();
static bindIpcListeners() {
ipcRenderer
.on(ExtensionInstallationStateStore.InstallingFromMainChannel, (event, extId) => {
ExtensionInstallationStateStore.setInstalling(extId);
})
.on(ExtensionInstallationStateStore.ClearInstallingFromMainChannel, (event, extId) => {
ExtensionInstallationStateStore.clearInstalling(extId);
});
}
@action static reset() {
logger.warn(`${Prefix}: resetting, may throw errors`);
ExtensionInstallationStateStore.InstallingExtensions.clear();
ExtensionInstallationStateStore.UninstallingExtensions.clear();
ExtensionInstallationStateStore.PreInstallIds.clear();
}
/**
* Strictly transitions an extension from not installing to installing
* @param extId the ID of the extension
* @throws if state is not IDLE
*/
@action static setInstalling(extId: string): void {
logger.debug(`${Prefix}: trying to set ${extId} as installing`);
const curState = ExtensionInstallationStateStore.getInstallationState(extId);
if (curState !== ExtensionInstallationState.IDLE) {
throw new Error(`${Prefix}: cannot set ${extId} as installing. Is currently ${curState}.`);
}
ExtensionInstallationStateStore.InstallingExtensions.add(extId);
}
/**
* Broadcasts that an extension is being installed by the main process
* @param extId the ID of the extension
*/
static setInstallingFromMain(extId: string): void {
broadcastMessage(ExtensionInstallationStateStore.InstallingFromMainChannel, extId);
}
/**
* Broadcasts that an extension is no longer being installed by the main process
* @param extId the ID of the extension
*/
static clearInstallingFromMain(extId: string): void {
broadcastMessage(ExtensionInstallationStateStore.ClearInstallingFromMainChannel, extId);
}
/**
* Marks the start of a pre-install phase of an extension installation. The
* part of the installation before the tarball has been unpacked and the ID
* determined.
* @returns a disposer which should be called to mark the end of the install phase
*/
@action static startPreInstall(): ExtendableDisposer {
const preInstallStepId = uuid.v4();
logger.debug(`${Prefix}: starting a new preinstall phase: ${preInstallStepId}`);
ExtensionInstallationStateStore.PreInstallIds.add(preInstallStepId);
return disposer(() => {
ExtensionInstallationStateStore.PreInstallIds.delete(preInstallStepId);
logger.debug(`${Prefix}: ending a preinstall phase: ${preInstallStepId}`);
});
}
/**
* Strictly transitions an extension from not uninstalling to uninstalling
* @param extId the ID of the extension
* @throws if state is not IDLE
*/
@action static setUninstalling(extId: string): void {
logger.debug(`${Prefix}: trying to set ${extId} as uninstalling`);
const curState = ExtensionInstallationStateStore.getInstallationState(extId);
if (curState !== ExtensionInstallationState.IDLE) {
throw new Error(`${Prefix}: cannot set ${extId} as uninstalling. Is currently ${curState}.`);
}
ExtensionInstallationStateStore.UninstallingExtensions.add(extId);
}
/**
* Strictly clears the INSTALLING state of an extension
* @param extId The ID of the extension
* @throws if state is not INSTALLING
*/
@action static clearInstalling(extId: string): void {
logger.debug(`${Prefix}: trying to clear ${extId} as installing`);
const curState = ExtensionInstallationStateStore.getInstallationState(extId);
switch (curState) {
case ExtensionInstallationState.INSTALLING:
return void ExtensionInstallationStateStore.InstallingExtensions.delete(extId);
default:
throw new Error(`${Prefix}: cannot clear INSTALLING state for ${extId}, it is currently ${curState}`);
}
}
/**
* Strictly clears the UNINSTALLING state of an extension
* @param extId The ID of the extension
* @throws if state is not UNINSTALLING
*/
@action static clearUninstalling(extId: string): void {
logger.debug(`${Prefix}: trying to clear ${extId} as uninstalling`);
const curState = ExtensionInstallationStateStore.getInstallationState(extId);
switch (curState) {
case ExtensionInstallationState.UNINSTALLING:
return void ExtensionInstallationStateStore.UninstallingExtensions.delete(extId);
default:
throw new Error(`${Prefix}: cannot clear UNINSTALLING state for ${extId}, it is currently ${curState}`);
}
}
/**
* Returns the current state of the extension. IDLE is default value.
* @param extId The ID of the extension
*/
static getInstallationState(extId: string): ExtensionInstallationState {
if (ExtensionInstallationStateStore.InstallingExtensions.has(extId)) {
return ExtensionInstallationState.INSTALLING;
}
if (ExtensionInstallationStateStore.UninstallingExtensions.has(extId)) {
return ExtensionInstallationState.UNINSTALLING;
}
return ExtensionInstallationState.IDLE;
}
/**
* Returns true if the extension is currently INSTALLING
* @param extId The ID of the extension
*/
static isExtensionInstalling(extId: string): boolean {
return ExtensionInstallationStateStore.getInstallationState(extId) === ExtensionInstallationState.INSTALLING;
}
/**
* Returns true if the extension is currently UNINSTALLING
* @param extId The ID of the extension
*/
static isExtensionUninstalling(extId: string): boolean {
return ExtensionInstallationStateStore.getInstallationState(extId) === ExtensionInstallationState.UNINSTALLING;
}
/**
* Returns true if the extension is currently IDLE
* @param extId The ID of the extension
*/
static isExtensionIdle(extId: string): boolean {
return ExtensionInstallationStateStore.getInstallationState(extId) === ExtensionInstallationState.IDLE;
}
/**
* The current number of extensions installing
*/
@computed static get installing(): number {
return ExtensionInstallationStateStore.InstallingExtensions.size;
}
/**
* If there is at least one extension currently installing
*/
@computed static get anyInstalling(): boolean {
return ExtensionInstallationStateStore.installing > 0;
}
/**
* The current number of extensions preinstalling
*/
@computed static get preinstalling(): number {
return ExtensionInstallationStateStore.PreInstallIds.size;
}
/**
* If there is at least one extension currently downloading
*/
@computed static get anyPreinstalling(): boolean {
return ExtensionInstallationStateStore.preinstalling > 0;
}
/**
* If there is at least one installing or preinstalling step taking place
*/
@computed static get anyPreInstallingOrInstalling(): boolean {
return ExtensionInstallationStateStore.anyInstalling || ExtensionInstallationStateStore.anyPreinstalling;
}
}

View File

@ -1,15 +1,16 @@
import "./extensions.scss";
import { remote, shell } from "electron";
import fse from "fs-extra";
import { computed, makeObservable, observable, reaction } from "mobx";
import { computed, observable, reaction, when, makeObservable } from "mobx";
import { disposeOnUnmount, observer } from "mobx-react";
import os from "os";
import path from "path";
import React from "react";
import { downloadFile, extractTar, listTarEntries, readFileFromTar } from "../../../common/utils";
import { autobind, disposer, Disposer, downloadFile, downloadJson, ExtendableDisposer, extractTar, listTarEntries, noop, readFileFromTar } from "../../../common/utils";
import { docsUrl } from "../../../common/vars";
import { ExtensionDiscovery, InstalledExtension, manifestFilename } from "../../../extensions/extension-discovery";
import { ExtensionLoader } from "../../../extensions/extension-loader";
import { extensionDisplayName, LensExtensionManifest, sanitizeExtensionName } from "../../../extensions/lens-extension";
import { extensionDisplayName, LensExtensionId, LensExtensionManifest, sanitizeExtensionName } from "../../../extensions/lens-extension";
import logger from "../../../main/logger";
import { prevDefault } from "../../utils";
import { Button } from "../button";
@ -21,223 +22,127 @@ import { SubTitle } from "../layout/sub-title";
import { Notifications } from "../notifications";
import { Spinner } from "../spinner/spinner";
import { TooltipPosition } from "../tooltip";
import "./extensions.scss";
import { ExtensionInstallationState, ExtensionInstallationStateStore } from "./extension-install.store";
import URLParse from "url-parse";
import { SemVer } from "semver";
import _ from "lodash";
function getMessageFromError(error: any): string {
if (!error || typeof error !== "object") {
return "an error has occured";
}
if (error.message) {
return String(error.message);
}
if (error.err) {
return String(error.err);
}
const rawMessage = String(error);
if (rawMessage === String({})) {
return "an error has occured";
}
return rawMessage;
}
interface ExtensionInfo {
name: string;
version?: string;
requireConfirmation?: boolean;
}
interface InstallRequest {
fileName: string;
filePath?: string;
data?: Buffer;
dataP: Promise<Buffer | null>;
}
interface InstallRequestPreloaded extends InstallRequest {
interface InstallRequestValidated {
fileName: string;
data: Buffer;
}
interface InstallRequestValidated extends InstallRequestPreloaded {
id: LensExtensionId;
manifest: LensExtensionManifest;
tempFile: string; // temp system path to packed extension for unpacking
}
interface ExtensionState {
displayName: string;
// Possible states the extension can be
state: "installing" | "uninstalling";
}
async function uninstallExtension(extensionId: LensExtensionId): Promise<boolean> {
const loader = ExtensionLoader.getInstance();
const { manifest } = loader.getExtension(extensionId);
const displayName = extensionDisplayName(manifest.name, manifest.version);
@observer
export class Extensions extends React.Component {
private static supportedFormats = ["tar", "tgz"];
try {
logger.debug(`[EXTENSIONS]: trying to uninstall ${extensionId}`);
ExtensionInstallationStateStore.setUninstalling(extensionId);
private static installPathValidator: InputValidator = {
message: "Invalid URL or absolute path",
validate(value: string) {
return InputValidators.isUrl.validate(value) || InputValidators.isPath.validate(value);
}
};
await ExtensionDiscovery.getInstance().uninstallExtension(extensionId);
static installStates = observable.map<string, ExtensionState>();
// wait for the ExtensionLoader to actually uninstall the extension
await when(() => !loader.userExtensions.has(extensionId));
constructor(props: object) {
super(props);
makeObservable(this);
}
@observable search = "";
@observable installPath = "";
// True if the preliminary install steps have started, but unpackExtension has not started yet
@observable startingInstall = false;
/**
* Extensions that were removed from extensions but are still in "uninstalling" state
*/
@computed get removedUninstalling() {
return Array.from(Extensions.installStates.entries())
.filter(([id, extension]) =>
extension.state === "uninstalling"
&& !this.extensions.find(extension => extension.id === id)
)
.map(([id, extension]) => ({ ...extension, id }));
}
/**
* Extensions that were added to extensions but are still in "installing" state
*/
@computed get addedInstalling() {
return Array.from(Extensions.installStates.entries())
.filter(([id, extension]) =>
extension.state === "installing"
&& this.extensions.find(extension => extension.id === id)
)
.map(([id, extension]) => ({ ...extension, id }));
}
componentDidMount() {
disposeOnUnmount(this,
reaction(() => this.extensions, () => {
this.removedUninstalling.forEach(({ id, displayName }) => {
Notifications.ok(
<p>Extension <b>{displayName}</b> successfully uninstalled!</p>
);
Extensions.installStates.delete(id);
});
this.addedInstalling.forEach(({ id, displayName }) => {
const extension = this.extensions.find(extension => extension.id === id);
if (!extension) {
throw new Error("Extension not found");
}
Notifications.ok(
<p>Extension <b>{displayName}</b> successfully installed!</p>
);
Extensions.installStates.delete(id);
this.installPath = "";
// Enable installed extensions by default.
extension.isEnabled = true;
});
})
);
}
@computed get extensions() {
const searchText = this.search.toLowerCase();
return Array.from(ExtensionLoader.getInstance().userExtensions.values())
.filter(({ manifest: { name, description } }) => (
name.toLowerCase().includes(searchText)
|| description?.toLowerCase().includes(searchText)
));
}
get extensionsPath() {
return ExtensionDiscovery.getInstance().localFolderPath;
}
getExtensionPackageTemp(fileName = "") {
return path.join(os.tmpdir(), "lens-extensions", fileName);
}
getExtensionDestFolder(name: string) {
return path.join(this.extensionsPath, sanitizeExtensionName(name));
}
installFromSelectFileDialog = async () => {
const { dialog, BrowserWindow, app } = remote;
const { canceled, filePaths } = await dialog.showOpenDialog(BrowserWindow.getFocusedWindow(), {
defaultPath: app.getPath("downloads"),
properties: ["openFile", "multiSelections"],
message: `Select extensions to install (formats: ${Extensions.supportedFormats.join(", ")}), `,
buttonLabel: `Use configuration`,
filters: [
{ name: "tarball", extensions: Extensions.supportedFormats }
]
});
if (!canceled && filePaths.length) {
this.requestInstall(
filePaths.map(filePath => ({
fileName: path.basename(filePath),
filePath,
}))
);
}
};
installFromUrlOrPath = async () => {
const { installPath } = this;
if (!installPath) return;
this.startingInstall = true;
const fileName = path.basename(installPath);
try {
// install via url
// fixme: improve error messages for non-tar-file URLs
if (InputValidators.isUrl.validate(installPath)) {
const { promise: filePromise } = downloadFile({ url: installPath, timeout: 60000 /*1m*/ });
const data = await filePromise;
await this.requestInstall({ fileName, data });
}
// otherwise installing from system path
else if (InputValidators.isPath.validate(installPath)) {
await this.requestInstall({ fileName, filePath: installPath });
}
return true;
} catch (error) {
this.startingInstall = false;
Notifications.error(
<p>Installation has failed: <b>{String(error)}</b></p>
);
const message = getMessageFromError(error);
logger.info(`[EXTENSION-UNINSTALL]: uninstalling ${displayName} has failed: ${error}`, { error });
Notifications.error(<p>Uninstalling extension <b>{displayName}</b> has failed: <em>{message}</em></p>);
return false;
} finally {
// Remove uninstall state on uninstall failure
ExtensionInstallationStateStore.clearUninstalling(extensionId);
}
};
}
installOnDrop = (files: File[]) => {
logger.info("Install from D&D");
async function confirmUninstallExtension(extension: InstalledExtension): Promise<void> {
const displayName = extensionDisplayName(extension.manifest.name, extension.manifest.version);
const confirmed = await ConfirmDialog.confirm({
message: <p>Are you sure you want to uninstall extension <b>{displayName}</b>?</p>,
labelOk: "Yes",
labelCancel: "No",
});
return this.requestInstall(
files.map(file => ({
fileName: path.basename(file.path),
filePath: file.path,
}))
);
};
if (confirmed) {
await uninstallExtension(extension.id);
}
}
async preloadExtensions(requests: InstallRequest[], { showError = true } = {}) {
const preloadedRequests = requests.filter(request => request.data);
function getExtensionDestFolder(name: string) {
return path.join(ExtensionDiscovery.getInstance().localFolderPath, sanitizeExtensionName(name));
}
await Promise.all(
requests
.filter(request => !request.data && request.filePath)
.map(async request => {
function getExtensionPackageTemp(fileName = "") {
return path.join(os.tmpdir(), "lens-extensions", fileName);
}
async function readFileNotify(filePath: string, showError = true): Promise<Buffer | null> {
try {
const data = await fse.readFile(request.filePath);
request.data = data;
preloadedRequests.push(request);
return request;
return await fse.readFile(filePath);
} catch (error) {
if (showError) {
Notifications.error(`Error while reading "${request.filePath}": ${String(error)}`);
}
}
})
);
const message = getMessageFromError(error);
return preloadedRequests as InstallRequestPreloaded[];
logger.info(`[EXTENSION-INSTALL]: preloading ${filePath} has failed: ${message}`, { error });
Notifications.error(`Error while reading "${filePath}": ${message}`);
}
}
async validatePackage(filePath: string): Promise<LensExtensionManifest> {
return null;
}
async function validatePackage(filePath: string): Promise<LensExtensionManifest> {
const tarFiles = await listTarEntries(filePath);
// tarball from npm contains single root folder "package/*"
const firstFile = tarFiles[0];
if (!firstFile) {
if(!firstFile) {
throw new Error(`invalid extension bundle, ${manifestFilename} not found`);
}
@ -245,7 +150,7 @@ export class Extensions extends React.Component {
const packedInRootFolder = tarFiles.every(entry => entry.startsWith(rootFolder));
const manifestLocation = packedInRootFolder ? path.join(rootFolder, manifestFilename) : manifestFilename;
if (!tarFiles.includes(manifestLocation)) {
if(!tarFiles.includes(manifestLocation)) {
throw new Error(`invalid extension bundle, ${manifestFilename} not found`);
}
@ -255,119 +160,70 @@ export class Extensions extends React.Component {
parseJson: true,
});
if (!manifest.lens && !manifest.renderer) {
if (!manifest.main && !manifest.renderer) {
throw new Error(`${manifestFilename} must specify "main" and/or "renderer" fields`);
}
return manifest;
}
async createTempFilesAndValidate(requests: InstallRequestPreloaded[], { showErrors = true } = {}) {
const validatedRequests: InstallRequestValidated[] = [];
}
async function createTempFilesAndValidate({ fileName, dataP }: InstallRequest, disposer: ExtendableDisposer): Promise<InstallRequestValidated | null> {
// copy files to temp
await fse.ensureDir(this.getExtensionPackageTemp());
for (const request of requests) {
const tempFile = this.getExtensionPackageTemp(request.fileName);
await fse.writeFile(tempFile, request.data);
}
await fse.ensureDir(getExtensionPackageTemp());
// validate packages
await Promise.all(
requests.map(async req => {
const tempFile = this.getExtensionPackageTemp(req.fileName);
const tempFile = getExtensionPackageTemp(fileName);
disposer.push(() => fse.unlink(tempFile));
try {
const manifest = await this.validatePackage(tempFile);
const data = await dataP;
validatedRequests.push({
...req,
if (!data) {
return;
}
await fse.writeFile(tempFile, data);
const manifest = await validatePackage(tempFile);
const id = path.join(ExtensionDiscovery.getInstance().nodeModulesPath, manifest.name, "package.json");
return {
fileName,
data,
manifest,
tempFile,
});
id,
};
} catch (error) {
fse.unlink(tempFile).catch(() => null); // remove invalid temp package
const message = getMessageFromError(error);
if (showErrors) {
logger.info(`[EXTENSION-INSTALLATION]: installing ${fileName} has failed: ${message}`, { error });
Notifications.error(
<div className="flex column gaps">
<p>Installing <em>{req.fileName}</em> has failed, skipping.</p>
<p>Reason: <em>{String(error)}</em></p>
<p>Installing <em>{fileName}</em> has failed, skipping.</p>
<p>Reason: <em>{message}</em></p>
</div>
);
}
}
})
);
return validatedRequests;
}
return null;
}
async requestInstall(init: InstallRequest | InstallRequest[]) {
const requests = Array.isArray(init) ? init : [init];
const preloadedRequests = await this.preloadExtensions(requests);
const validatedRequests = await this.createTempFilesAndValidate(preloadedRequests);
async function unpackExtension(request: InstallRequestValidated, disposeDownloading?: Disposer) {
const { id, fileName, tempFile, manifest: { name, version } } = request;
// If there are no requests for installing, reset startingInstall state
if (validatedRequests.length === 0) {
this.startingInstall = false;
}
ExtensionInstallationStateStore.setInstalling(id);
disposeDownloading?.();
for (const install of validatedRequests) {
const { name, version, description } = install.manifest;
const extensionFolder = this.getExtensionDestFolder(name);
const folderExists = await fse.pathExists(extensionFolder);
if (!folderExists) {
// auto-install extension if not yet exists
this.unpackExtension(install);
} else {
// If we show the confirmation dialog, we stop the install spinner until user clicks ok
// and the install continues
this.startingInstall = false;
// otherwise confirmation required (re-install / update)
const removeNotification = Notifications.info(
<div className="InstallingExtensionNotification flex gaps align-center">
<div className="flex column gaps">
<p>Install extension <b>{name}@{version}</b>?</p>
<p>Description: <em>{description}</em></p>
<div className="remove-folder-warning" onClick={() => shell.openPath(extensionFolder)}>
<b>Warning:</b> <code>{extensionFolder}</code> will be removed before installation.
</div>
</div>
<Button autoFocus label="Install" onClick={() => {
removeNotification();
this.unpackExtension(install);
}}/>
</div>
);
}
}
}
async unpackExtension({ fileName, tempFile, manifest: { name, version } }: InstallRequestValidated) {
const displayName = extensionDisplayName(name, version);
const extensionId = path.join(ExtensionDiscovery.getInstance().nodeModulesPath, name, "package.json");
logger.info(`Unpacking extension ${displayName}`, { fileName, tempFile });
Extensions.installStates.set(extensionId, {
state: "installing",
displayName
});
this.startingInstall = false;
const extensionFolder = this.getExtensionDestFolder(name);
const extensionFolder = getExtensionDestFolder(name);
const unpackingTempFolder = path.join(path.dirname(tempFile), `${path.basename(tempFile)}-unpacked`);
logger.info(`Unpacking extension ${displayName}`, { fileName, tempFile });
try {
// extract to temp folder first
await fse.remove(unpackingTempFolder).catch(Function);
await fse.remove(unpackingTempFolder).catch(noop);
await fse.ensureDir(unpackingTempFolder);
await extractTar(tempFile, { cwd: unpackingTempFolder });
@ -383,78 +239,291 @@ export class Extensions extends React.Component {
await fse.ensureDir(extensionFolder);
await fse.move(unpackedRootFolder, extensionFolder, { overwrite: true });
} catch (error) {
Notifications.error(
<p>Installing extension <b>{displayName}</b> has failed: <em>{error}</em></p>
// wait for the loader has actually install it
await when(() => ExtensionLoader.getInstance().userExtensions.has(id));
// Enable installed extensions by default.
ExtensionLoader.getInstance().userExtensions.get(id).isEnabled = true;
Notifications.ok(
<p>Extension <b>{displayName}</b> successfully installed!</p>
);
} catch (error) {
const message = getMessageFromError(error);
// Remove install state on install failure
if (Extensions.installStates.get(extensionId)?.state === "installing") {
Extensions.installStates.delete(extensionId);
}
logger.info(`[EXTENSION-INSTALLATION]: installing ${request.fileName} has failed: ${message}`, { error });
Notifications.error(<p>Installing extension <b>{displayName}</b> has failed: <em>{message}</em></p>);
} finally {
// Remove install state once finished
ExtensionInstallationStateStore.clearInstalling(id);
// clean up
fse.remove(unpackingTempFolder).catch(Function);
fse.unlink(tempFile).catch(Function);
fse.remove(unpackingTempFolder).catch(noop);
fse.unlink(tempFile).catch(noop);
}
}
export async function attemptInstallByInfo({ name, version, requireConfirmation = false }: ExtensionInfo) {
const disposer = ExtensionInstallationStateStore.startPreInstall();
const registryUrl = new URLParse("https://registry.npmjs.com").set("pathname", name).toString();
const { promise } = downloadJson({ url: registryUrl });
const json = await promise.catch(console.error);
if (!json || json.error || typeof json.versions !== "object" || !json.versions) {
const message = json?.error ? `: ${json.error}` : "";
Notifications.error(`Failed to get registry information for that extension${message}`);
return disposer();
}
confirmUninstallExtension = (extension: InstalledExtension) => {
const displayName = extensionDisplayName(extension.manifest.name, extension.manifest.version);
if (version) {
if (!json.versions[version]) {
Notifications.error(<p>The <em>{name}</em> extension does not have a v{version}.</p>);
ConfirmDialog.open({
message: <p>Are you sure you want to uninstall extension <b>{displayName}</b>?</p>,
labelOk: "Yes",
labelCancel: "No",
ok: () => this.uninstallExtension(extension)
return disposer();
}
} else {
const versions = Object.keys(json.versions)
.map(version => new SemVer(version, { loose: true, includePrerelease: true }))
// ignore pre-releases for auto picking the version
.filter(version => version.prerelease.length === 0);
version = _.reduce(versions, (prev, curr) => (
prev.compareMain(curr) === -1
? curr
: prev
)).format();
}
if (requireConfirmation) {
const proceed = await ConfirmDialog.confirm({
message: <p>Are you sure you want to install <b>{name}@{version}</b>?</p>,
labelCancel: "Cancel",
labelOk: "Install",
});
};
async uninstallExtension(extension: InstalledExtension) {
const displayName = extensionDisplayName(extension.manifest.name, extension.manifest.version);
if (!proceed) {
return disposer();
}
}
const url = json.versions[version].dist.tarball;
const fileName = path.basename(url);
const { promise: dataP } = downloadFile({ url, timeout: 10 * 60 * 1000 });
return attemptInstall({ fileName, dataP }, disposer);
}
async function attemptInstall(request: InstallRequest, d?: ExtendableDisposer): Promise<void> {
const dispose = disposer(ExtensionInstallationStateStore.startPreInstall(), d);
const validatedRequest = await createTempFilesAndValidate(request, dispose);
if (!validatedRequest) {
return dispose();
}
const { name, version, description } = validatedRequest.manifest;
const curState = ExtensionInstallationStateStore.getInstallationState(validatedRequest.id);
if (curState !== ExtensionInstallationState.IDLE) {
dispose();
return Notifications.error(
<div className="flex column gaps">
<b>Extension Install Collision:</b>
<p>The <em>{name}</em> extension is currently {curState.toLowerCase()}.</p>
<p>Will not proceed with this current install request.</p>
</div>
);
}
const extensionFolder = getExtensionDestFolder(name);
const folderExists = await fse.pathExists(extensionFolder);
if (!folderExists) {
// install extension if not yet exists
await unpackExtension(validatedRequest, dispose);
} else {
const { manifest: { version: oldVersion } } = ExtensionLoader.getInstance().getExtension(validatedRequest.id);
// otherwise confirmation required (re-install / update)
const removeNotification = Notifications.info(
<div className="InstallingExtensionNotification flex gaps align-center">
<div className="flex column gaps">
<p>Install extension <b>{name}@{version}</b>?</p>
<p>Description: <em>{description}</em></p>
<div className="remove-folder-warning" onClick={() => shell.openPath(extensionFolder)}>
<b>Warning:</b> {name}@{oldVersion} will be removed before installation.
</div>
</div>
<Button autoFocus label="Install" onClick={async () => {
removeNotification();
if (await uninstallExtension(validatedRequest.id)) {
await unpackExtension(validatedRequest, dispose);
} else {
dispose();
}
}} />
</div>,
{
onClose: dispose,
}
);
}
}
async function attemptInstalls(filePaths: string[]): Promise<void> {
const promises: Promise<void>[] = [];
for (const filePath of filePaths) {
promises.push(attemptInstall({
fileName: path.basename(filePath),
dataP: readFileNotify(filePath),
}));
}
await Promise.allSettled(promises);
}
async function installOnDrop(files: File[]) {
logger.info("Install from D&D");
await attemptInstalls(files.map(({ path }) => path));
}
async function installFromInput(input: string) {
let disposer: ExtendableDisposer | undefined = undefined;
try {
Extensions.installStates.set(extension.id, {
state: "uninstalling",
displayName
// fixme: improve error messages for non-tar-file URLs
if (InputValidators.isUrl.validate(input)) {
// install via url
disposer = ExtensionInstallationStateStore.startPreInstall();
const { promise } = downloadFile({ url: input, timeout: 10 * 60 * 1000 });
const fileName = path.basename(input);
await attemptInstall({ fileName, dataP: promise }, disposer);
} else if (InputValidators.isPath.validate(input)) {
// install from system path
const fileName = path.basename(input);
await attemptInstall({ fileName, dataP: readFileNotify(input) });
} else if (InputValidators.isExtensionNameInstall.validate(input)) {
const [{ groups: { name, version }}] = [...input.matchAll(InputValidators.isExtensionNameInstallRegex)];
await attemptInstallByInfo({ name, version });
}
} catch (error) {
const message = getMessageFromError(error);
logger.info(`[EXTENSION-INSTALL]: installation has failed: ${message}`, { error, installPath: input });
Notifications.error(<p>Installation has failed: <b>{message}</b></p>);
} finally {
disposer?.();
}
}
const supportedFormats = ["tar", "tgz"];
async function installFromSelectFileDialog() {
const { dialog, BrowserWindow, app } = remote;
const { canceled, filePaths } = await dialog.showOpenDialog(BrowserWindow.getFocusedWindow(), {
defaultPath: app.getPath("downloads"),
properties: ["openFile", "multiSelections"],
message: `Select extensions to install (formats: ${supportedFormats.join(", ")}), `,
buttonLabel: "Use configuration",
filters: [
{ name: "tarball", extensions: supportedFormats }
]
});
await ExtensionDiscovery.getInstance().uninstallExtension(extension);
} catch (error) {
Notifications.error(
<p>Uninstalling extension <b>{displayName}</b> has failed: <em>{error?.message ?? ""}</em></p>
if (!canceled) {
await attemptInstalls(filePaths);
}
}
@observer
export class Extensions extends React.Component {
private static installInputValidators = [
InputValidators.isUrl,
InputValidators.isPath,
InputValidators.isExtensionNameInstall,
];
private static installInputValidator: InputValidator = {
message: "Invalid URL, absolute path, or extension name",
validate: (value: string) => (
Extensions.installInputValidators.some(({ validate }) => validate(value))
),
};
constructor(props: object) {
super(props);
makeObservable(this);
}
@observable search = "";
@observable installPath = "";
@computed get searchedForExtensions() {
const searchText = this.search.toLowerCase();
return Array.from(ExtensionLoader.getInstance().userExtensions.values())
.filter(({ manifest: { name, description }}) => (
name.toLowerCase().includes(searchText)
|| description?.toLowerCase().includes(searchText)
));
}
componentDidMount() {
// TODO: change this after upgrading to mobx6 as that versions' reactions have this functionality
let prevSize = ExtensionLoader.getInstance().userExtensions.size;
disposeOnUnmount(this, [
reaction(() => ExtensionLoader.getInstance().userExtensions.size, curSize => {
try {
if (curSize > prevSize) {
when(() => !ExtensionInstallationStateStore.anyInstalling)
.then(() => this.installPath = "");
}
} finally {
prevSize = curSize;
}
})
]);
}
renderNoExtensionsHelpText() {
if (this.search) {
return <p>No search results found</p>;
}
return (
<p>
There are no installed extensions.
See list of <a href="https://github.com/lensapp/lens-extensions/blob/main/README.md" target="_blank" rel="noreferrer">available extensions</a>.
</p>
);
// Remove uninstall state on uninstall failure
if (Extensions.installStates.get(extension.id)?.state === "uninstalling") {
Extensions.installStates.delete(extension.id);
}
}
}
renderExtensions() {
const { extensions, search } = this;
if (!extensions.length) {
renderNoExtensions() {
return (
<div className="no-extensions flex box gaps justify-center">
<Icon material="info"/>
<Icon material="info" />
<div>
{
search
? <p>No search results found</p>
: <p>There are no installed extensions. See list of <a href="https://github.com/lensapp/lens-extensions/blob/main/README.md" target="_blank" rel="noreferrer">available extensions</a>.
</p>
}
{this.renderNoExtensionsHelpText()}
</div>
</div>
);
}
return extensions.map(extension => {
@autobind()
renderExtension(extension: InstalledExtension) {
const { id, isEnabled, manifest } = extension;
const { name, description, version } = manifest;
const isUninstalling = Extensions.installStates.get(id)?.state === "uninstalling";
const isUninstalling = ExtensionInstallationStateStore.isExtensionUninstalling(id);
return (
<div key={id} className="extension flex gaps align-center">
@ -464,43 +533,53 @@ export class Extensions extends React.Component {
<p>{description}</p>
</div>
<div className="actions">
{!isEnabled && (
<Button plain active disabled={isUninstalling} onClick={() => {
extension.isEnabled = true;
}}>Enable</Button>
)}
{isEnabled && (
<Button accent disabled={isUninstalling} onClick={() => {
extension.isEnabled = false;
}}>Disable</Button>
)}
<Button plain active disabled={isUninstalling} waiting={isUninstalling} onClick={() => {
this.confirmUninstallExtension(extension);
}}>Uninstall</Button>
{
isEnabled
? <Button accent disabled={isUninstalling} onClick={() => extension.isEnabled = false}>Disable</Button>
: <Button plain active disabled={isUninstalling} onClick={() => extension.isEnabled = true}>Enable</Button>
}
<Button
plain
active
disabled={isUninstalling}
waiting={isUninstalling}
onClick={() => confirmUninstallExtension(extension)}
>
Uninstall
</Button>
</div>
</div>
);
});
}
/**
* True if at least one extension is in installing state
*/
@computed get isInstalling() {
return [...Extensions.installStates.values()].some(extension => extension.state === "installing");
renderExtensions() {
if (!ExtensionDiscovery.getInstance().isLoaded) {
return <div className="spinner-wrapper"><Spinner /></div>;
}
const { searchedForExtensions } = this;
if (!searchedForExtensions.length) {
return this.renderNoExtensions();
}
return (
<>
{...searchedForExtensions.map(this.renderExtension)}
</>
);
}
render() {
const { installPath } = this;
return (
<DropFileInput onDropFiles={this.installOnDrop}>
<DropFileInput onDropFiles={installOnDrop}>
<PageLayout showOnTop className="Extensions" contentGaps={false}>
<h2>Lens Extensions</h2>
<div>
Add new features and functionality via Lens Extensions.
Check out documentation to <a href={`${docsUrl}/latest/extensions/usage/`} target="_blank" rel="noreferrer">learn more</a> or see the list of <a
href="https://github.com/lensapp/lens-extensions/blob/main/README.md" target="_blank" rel="noreferrer">available extensions</a>.
Check out documentation to <a href={`${docsUrl}/latest/extensions/usage/`} target="_blank" rel="noreferrer">learn more</a> or see the list of <a href="https://github.com/lensapp/lens-extensions/blob/main/README.md" target="_blank" rel="noreferrer">available extensions</a>.
</div>
<div className="install-extension flex column gaps">
@ -509,19 +588,19 @@ export class Extensions extends React.Component {
<Input
className="box grow"
theme="round-black"
disabled={this.isInstalling}
placeholder={`Path or URL to an extension package (${Extensions.supportedFormats.join(", ")})`}
disabled={ExtensionInstallationStateStore.anyPreInstallingOrInstalling}
placeholder={`Name or file path or URL to an extension package (${supportedFormats.join(", ")})`}
showErrorsAsTooltip={{ preferredPositions: TooltipPosition.BOTTOM }}
validators={installPath ? Extensions.installPathValidator : undefined}
validators={installPath ? Extensions.installInputValidator : undefined}
value={installPath}
onChange={value => this.installPath = value}
onSubmit={this.installFromUrlOrPath}
onSubmit={() => installFromInput(this.installPath)}
iconLeft="link"
iconRight={
<Icon
interactive
material="folder"
onClick={prevDefault(this.installFromSelectFileDialog)}
onClick={prevDefault(installFromSelectFileDialog)}
tooltip="Browse"
/>
}
@ -530,9 +609,9 @@ export class Extensions extends React.Component {
<Button
primary
label="Install"
disabled={this.isInstalling || !Extensions.installPathValidator.validate(installPath)}
waiting={this.isInstalling}
onClick={this.installFromUrlOrPath}
disabled={ExtensionInstallationStateStore.anyPreInstallingOrInstalling || !Extensions.installInputValidator.validate(installPath)}
waiting={ExtensionInstallationStateStore.anyPreInstallingOrInstalling}
onClick={() => installFromInput(this.installPath)}
/>
<small className="hint">
<b>Pro-Tip</b>: you can also drag-n-drop tarball-file to this area
@ -546,11 +625,7 @@ export class Extensions extends React.Component {
value={this.search}
onChange={(value) => this.search = value}
/>
{
ExtensionDiscovery.getInstance().isLoaded
? this.renderExtensions()
: <div className="spinner-wrapper"><Spinner/></div>
}
{this.renderExtensions()}
</div>
</PageLayout>
</DropFileInput>

View File

@ -0,0 +1,2 @@
export * from "./welcome";
export * from "./welcome.route";

View File

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

View File

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

View File

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

View File

@ -1,2 +0,0 @@
export * from "./whats-new.route";
export * from "./whats-new";

View File

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

View File

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

View File

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

View File

@ -1,5 +1,5 @@
import "./button.scss";
import React, { ButtonHTMLAttributes, ReactNode } from "react";
import React, { ButtonHTMLAttributes } from "react";
import { cssNames } from "../../utils";
import { TooltipDecoratorProps, withTooltip } from "../tooltip";
@ -26,29 +26,22 @@ export class Button extends React.PureComponent<ButtonProps, {}> {
render() {
const {
className, waiting, label, primary, accent, plain, hidden, active, big,
round, outlined, tooltip, light, children, ...props
waiting, label, primary, accent, plain, hidden, active, big,
round, outlined, tooltip, light, children, ...btnProps
} = this.props;
const btnProps: Partial<ButtonProps> = props;
if (hidden) return null;
btnProps.className = cssNames("Button", className, {
btnProps.className = cssNames("Button", btnProps.className, {
waiting, primary, accent, plain, active, big, round, outlined, light,
});
const btnContent: ReactNode = (
<>
{label}
{children}
</>
);
// render as link
if (this.props.href) {
return (
<a {...btnProps} ref={e => this.link = e}>
{btnContent}
{label}
{children}
</a>
);
}
@ -56,7 +49,8 @@ export class Button extends React.PureComponent<ButtonProps, {}> {
// render as button
return (
<button type="button" {...btnProps} ref={e => this.button = e}>
{btnContent}
{label}
{children}
</button>
);
}

View File

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

View File

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

View File

@ -112,7 +112,6 @@ entitySettingRegistry.add([
{
apiVersions: ["entity.k8slens.dev/v1alpha1"],
kind: "KubernetesCluster",
source: "local",
title: "Metrics",
components: {
View: (props: { entity: CatalogEntity }) => {

View File

@ -11,14 +11,18 @@ import { Icon } from "../icon";
export interface ConfirmDialogProps extends Partial<DialogProps> {
}
export interface ConfirmDialogParams {
ok?: () => void;
export interface ConfirmDialogParams extends ConfirmDialogBooleanParams {
ok?: () => any | Promise<any>;
cancel?: () => any | Promise<any>;
}
export interface ConfirmDialogBooleanParams {
labelOk?: ReactNode;
labelCancel?: ReactNode;
message?: ReactNode;
message: ReactNode;
icon?: ReactNode;
okButtonProps?: Partial<ButtonProps>
cancelButtonProps?: Partial<ButtonProps>
okButtonProps?: Partial<ButtonProps>;
cancelButtonProps?: Partial<ButtonProps>;
}
@observer
@ -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() {

View File

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

View File

@ -11,7 +11,7 @@ import { Icon } from "../icon";
import { LogTabData } from "./log-tab.store";
interface Props {
tabData: LogTabData
tabData?: LogTabData
logs: string[]
save: (data: Partial<LogTabData>) => void
reload: () => void
@ -19,6 +19,11 @@ interface Props {
export const LogControls = observer((props: Props) => {
const { tabData, save, reload, logs } = props;
if (!tabData) {
return null;
}
const { showTimestamps, previous } = tabData;
const since = logs.length ? logStore.getTimestamps(logs[0]) : null;
const pod = new Pod(tabData.selectedPod);

View File

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

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

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) => {

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 {
height: 26px;
background-color: var(--layoutBackground);
position: relative;
&:before {
content: " ";
position: absolute;
bottom: 0;
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;
}
}

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

View File

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

View File

@ -47,6 +47,14 @@ export const isUrl: InputValidator = {
},
};
export const isExtensionNameInstallRegex = /^(?<name>(@[-\w]+\/)?[-\w]+)(@(?<version>\d\.\d\.\d(-\w+)?))?$/gi;
export const isExtensionNameInstall: InputValidator = {
condition: ({ type }) => type === "text",
message: () => "Not an extension name with optional version",
validate: value => value.match(isExtensionNameInstallRegex) !== null,
};
export const isPath: InputValidator = {
condition: ({ type }) => type === "text",
message: () => `This field must be a valid path`,

View File

@ -2,30 +2,11 @@ import "./kube-object-status-icon.scss";
import React from "react";
import { Icon } from "../icon";
import { KubeObject } from "../../api/kube-object";
import { cssNames, formatDuration } from "../../utils";
import { KubeObjectStatusRegistration, kubeObjectStatusRegistry } from "../../../extensions/registries/kube-object-status-registry";
import { KubeObjectStatus, KubeObjectStatusLevel } from "../../..//extensions/renderer-api/k8s-api";
import { computed, makeObservable } from "mobx";
import { KubeObject, KubeObjectStatus, KubeObjectStatusLevel } from "../../..//extensions/renderer-api/k8s-api";
import { kubeObjectStatusRegistry } from "../../../extensions/registries";
interface Props {
object: KubeObject;
}
export class KubeObjectStatusIcon extends React.Component<Props> {
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 {
function statusClassName(level: number): string {
switch (level) {
case KubeObjectStatusLevel.INFO:
return "info";
@ -33,12 +14,10 @@ export class KubeObjectStatusIcon extends React.Component<Props> {
return "warning";
case KubeObjectStatusLevel.CRITICAL:
return "error";
default:
return "";
}
}
}
statusTitle(level: number): string {
function statusTitle(level: KubeObjectStatusLevel): string {
switch (level) {
case KubeObjectStatusLevel.INFO:
return "Info";
@ -46,66 +25,83 @@ export class KubeObjectStatusIcon extends React.Component<Props> {
return "Warning";
case KubeObjectStatusLevel.CRITICAL:
return "Critical";
default:
return "";
}
}
}
getAge(timestamp: string) {
if (!timestamp) return "";
const diff = Date.now() - new Date(timestamp).getTime();
function getAge(timestamp: string) {
return timestamp
? formatDuration(Date.now() - new Date(timestamp).getTime(), true)
: "";
}
return formatDuration(diff, true);
}
interface SplitStatusesByLevel {
maxLevel: string,
criticals: KubeObjectStatus[];
warnings: KubeObjectStatus[];
infos: KubeObjectStatus[];
}
/**
* This fuction returns the class level for corresponding to the highest status level
* and the statuses split by their levels.
* @param src a list of status items
*/
function splitByLevel(src: KubeObjectStatus[]): SplitStatusesByLevel {
const parts = new Map(Object.values(KubeObjectStatusLevel).map(v => [v, []]));
src.forEach(status => parts.get(status.level).push(status));
const criticals = parts.get(KubeObjectStatusLevel.CRITICAL);
const warnings = parts.get(KubeObjectStatusLevel.WARNING);
const infos = parts.get(KubeObjectStatusLevel.INFO);
const maxLevel = statusClassName(criticals[0]?.level ?? warnings[0]?.level ?? infos[0].level);
return { maxLevel, criticals, warnings, infos };
}
interface Props {
object: KubeObject;
}
export class KubeObjectStatusIcon extends React.Component<Props> {
renderStatuses(statuses: KubeObjectStatus[], level: number) {
const filteredStatuses = statuses.filter((item) => item.level == level);
return filteredStatuses.length > 0 && (
<div className={cssNames("level", this.statusClassName(level))}>
<div className={cssNames("level", statusClassName(level))}>
<span className="title">
{this.statusTitle(level)}
{statusTitle(level)}
</span>
{ filteredStatuses.map((status, index) =>{
return (
{
filteredStatuses.map((status, index) => (
<div key={`kube-resource-status-${level}-${index}`} className={cssNames("status", "msg")}>
- {status.text} <span className="age"> · { this.getAge(status.timestamp) }</span>
- {status.text} <span className="age"> · {getAge(status.timestamp)}</span>
</div>
);
})}
))
}
</div>
);
}
render() {
const { objectStatuses} = this;
const statuses = kubeObjectStatusRegistry.getItemsForObject(this.props.object);
if (!objectStatuses.length) return null;
const sortedStatuses = objectStatuses.sort((a: KubeObjectStatus, b: KubeObjectStatus) => {
if (a.level < b.level ) {
return 1;
if (statuses.length === 0) {
return null;
}
if (a.level > b.level ) {
return -1;
}
return 0;
});
const level = this.statusClassName(sortedStatuses[0].level);
const { maxLevel, criticals, warnings, infos } = splitByLevel(statuses);
return (
<Icon
material={level}
className={cssNames("KubeObjectStatusIcon", level)}
material={maxLevel}
className={cssNames("KubeObjectStatusIcon", maxLevel)}
tooltip={{
children: (
<div className="KubeObjectStatusTooltip">
{this.renderStatuses(sortedStatuses, KubeObjectStatusLevel.CRITICAL)}
{this.renderStatuses(sortedStatuses, KubeObjectStatusLevel.WARNING)}
{this.renderStatuses(sortedStatuses, KubeObjectStatusLevel.INFO)}
{this.renderStatuses(criticals, KubeObjectStatusLevel.CRITICAL)}
{this.renderStatuses(warnings, KubeObjectStatusLevel.WARNING)}
{this.renderStatuses(infos, KubeObjectStatusLevel.INFO)}
</div>
)
}}

View File

@ -24,13 +24,11 @@ export class KubeObjectMeta extends React.Component<KubeObjectMetaProps> {
}
render() {
const object = this.props.object;
const { object } = this.props;
const {
getName, getNs, getLabels, getResourceVersion, selfLink,
getAnnotations, getFinalizers, getId, getAge,
metadata: { creationTimestamp },
getNs, getLabels, getResourceVersion, selfLink, getAnnotations,
getFinalizers, getId, getAge, getName, metadata: { creationTimestamp },
} = object;
const ownerRefs = object.getOwnerRefs();
return (
@ -39,7 +37,8 @@ export class KubeObjectMeta extends React.Component<KubeObjectMetaProps> {
{getAge(true, false)} ago ({<LocaleDate date={creationTimestamp} />})
</DrawerItem>
<DrawerItem name="Name" hidden={this.isHidden("name")}>
{getName()} <KubeObjectStatusIcon key="icon" object={object} />
{getName()}
<KubeObjectStatusIcon key="icon" object={object} />
</DrawerItem>
<DrawerItem name="Namespace" hidden={this.isHidden("namespace") || !getNs()}>
{getNs()}
@ -68,7 +67,7 @@ export class KubeObjectMeta extends React.Component<KubeObjectMetaProps> {
labels={getFinalizers()}
hidden={this.isHidden("finalizers")}
/>
{ownerRefs && ownerRefs.length > 0 &&
{ownerRefs?.length > 0 &&
<DrawerItem name="Controlled By" hidden={this.isHidden("ownerReferences")}>
{
ownerRefs.map(ref => {

View File

@ -2,7 +2,6 @@ import "./sidebar.scss";
import type { TabLayoutRoute } from "./tab-layout";
import React from "react";
import { computed, 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>

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;

View File

@ -34,12 +34,6 @@
margin-top: calc(var(--spinner-size) / -2);
}
&.centerHorizontal {
position: absolute;
left: 50%;
margin-left: calc(var(--spinner-size) / -2);
}
@keyframes rotate {
0% {
transform: rotate(0deg);

View File

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

View File

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

View File

@ -1,6 +1,6 @@
import { addClusterURL } from "../components/+add-cluster";
import { extensionsURL } from "../components/+extensions";
import { catalogURL } from "../components/+catalog";
import { attemptInstallByInfo, extensionsURL } from "../components/+extensions";
import { preferencesURL } from "../components/+preferences";
import { clusterViewURL } from "../components/cluster-manager/cluster-view.route";
import { LensProtocolRouterRenderer } from "./router";
@ -8,6 +8,7 @@ import { navigate } from "../navigation/helpers";
import { entitySettingsURL } from "../components/+entity-settings";
import { catalogEntityRegistry } from "../api/catalog-entity-registry";
import { ClusterStore } from "../../common/cluster-store";
import { EXTENSION_NAME_MATCH, EXTENSION_PUBLISHER_MATCH, LensProtocolRouter } from "../../common/protocol-handler";
export function bindProtocolAddRouteHandlers() {
LensProtocolRouterRenderer
@ -33,9 +34,6 @@ export function bindProtocolAddRouteHandlers() {
console.log("[APP-HANDLER]: catalog entity with given ID does not exist", { entityId });
}
})
.addInternalHandler("/extensions", () => {
navigate(extensionsURL());
})
// Handlers below are deprecated and only kept for backward compact purposes
.addInternalHandler("/cluster/:clusterId", ({ pathname: { clusterId } }) => {
const cluster = ClusterStore.getInstance().getById(clusterId);
@ -54,5 +52,18 @@ export function bindProtocolAddRouteHandlers() {
} else {
console.log("[APP-HANDLER]: cluster with given ID does not exist", { clusterId });
}
})
.addInternalHandler("/extensions", () => {
navigate(extensionsURL());
})
.addInternalHandler(`/extensions/install${LensProtocolRouter.ExtensionUrlSchema}`, ({ pathname, search: { version } }) => {
const name = [
pathname[EXTENSION_PUBLISHER_MATCH],
pathname[EXTENSION_NAME_MATCH],
].filter(Boolean)
.join("/");
navigate(extensionsURL());
attemptInstallByInfo({ name, version, requireConfirmation: true });
});
}

View File

@ -1,36 +0,0 @@
// Allow to cancel request for window.fetch()
export interface CancelablePromise<T> extends Promise<T> {
then<TResult1 = T, TResult2 = never>(onfulfilled?: ((value: T) => TResult1 | PromiseLike<TResult1>) | undefined | null, onrejected?: ((reason: any) => TResult2 | PromiseLike<TResult2>) | undefined | null): CancelablePromise<TResult1 | TResult2>;
catch<TResult = never>(onrejected?: ((reason: any) => TResult | PromiseLike<TResult>) | undefined | null): CancelablePromise<T | TResult>;
finally(onfinally?: (() => void) | undefined | null): CancelablePromise<T>;
cancel(): void;
}
interface WrappingFunction {
<T>(result: Promise<T>): CancelablePromise<T>;
<T>(result: T): T;
}
export function cancelableFetch(reqInfo: RequestInfo, reqInit: RequestInit = {}) {
const abortController = new AbortController();
const signal = abortController.signal;
const cancel = abortController.abort.bind(abortController);
const wrapResult: WrappingFunction = function (result: any) {
if (result instanceof Promise) {
const promise: CancelablePromise<any> = result as any;
promise.then = function (onfulfilled, onrejected) {
const data = Object.getPrototypeOf(this).then.call(this, onfulfilled, onrejected);
return wrapResult(data);
};
promise.cancel = cancel;
}
return result;
};
const req = fetch(reqInfo, { ...reqInit, signal });
return wrapResult(req);
}

View File

@ -1,671 +0,0 @@
# What's new?
Here you can find description of changes we've built into each release. While we try our best to make each upgrade automatic and as smooth as possible, there may be some cases where you might need to do something to ensure the application works smoothly. So please read through the release highlights!
## 5.0.0-alpha.2 (current version)
- Workspaces are replaced by Catalog & Hotbar
- YAML Templates in Create Resource dock tab
- Add support for viewing 'User-supplied values' of helm release
- Add ability to configure the locale timezone
## 4.2.1
- User is now notified if helm list fails
- Sorting order is now saved when switching views
- Fix: Node shells failing to open
- Fix: Tray icon is now reactive to changes
- Fix: Whole window is used for displaying workspace overview
- Fix: Workspace overview is now reactive to cluster changes
- Fix: Exported ClusterStore now enforces more invariants
## 4.2.0
- Add lens:// protocol handling with a routing mechanism
- Add common app routes to the protocol renderer router from the documentation
- New workspace overview
- New add cluster flow
- Persist Lens UI layout information between restarts.
- Notify about update after it has been downloaded
- Add persistent volumes info to storage class submenu
- Add Pod's image hash as overlay over image name
- Allow to define the path of the shell in app preferences
- Add horizontal scrolling to NamespaceSelect and NamespaceSelectFilter
- Autostart is now always in hidden mode
- Navigation menu in Preferences
- Add terminal clear shortcut for macOS
- Add the ability to hide metrics from the UI
- Add notification to user to add accessible namespaces when needed
- Change Cluster Settings button to be a menu like cluster icon menu
- Fix: Proper sorting resources by age column
- Fix: Events sorting with compact=true is broken
- Fix: Two charts refer to an arbitrary repository
- Fix: Group filtering not working on Custom Resources
- Fix: Font-size on `<code>`
- Fix: Update available notification was able to show twice
- Fix: Cluster-settings page back button navigation is broken
- Fix: Lens not clearing other KUBECONFIG env vars
- Fix: Workspace overview switching and enabled state not being stored storage
- Fix: Extension command palette loading
- Fix: Closing workspace menu after clicking on iframe
- Fix: extension global pages are never able to be visible
- Fix: recreate proxy kubeconfig if it is deleted
- Fix: Proxy should listen only on loopback device
- Fix: Block global path traversal in router
- Fix: Set initial cursor position for the editor to beginning
- Fix: Highlight sidebar's active section
## 4.1.5
- Proxy should listen only on loopback device
- Fix extension command palette loading
- Fix Lens not clearing other KUBECONFIG env vars
## 4.1.4
- Ignore clusters with invalid kubeconfig
- Render only secret name on pod details without access to secrets
- Pass Lens wslenvs to terminal session on Windows
- Prevent top-level re-rendering on cluster refresh
- Extract chart version ignoring numbers in chart name
- The select all checkbox should not select disabled items
- Fix: Pdb should have policy group
- Fix: kubectl rollout not exiting properly on Lens terminal
## 4.1.3
- Don't reset selected namespaces to defaults in case of "All namespaces" on page reload
- Fix loading all namespaces for users with limited cluster access
- Display environment variables coming from secret in pod details
- Fix deprecated helm chart filtering
- Fix RoleBindings Namespace and Bindings field not displaying the correct data
- Fix RoleBindingDetails not rendering the name of the role binding
- Fix auto update on quit with newer version
## 4.1.2
**Upgrade note:** Where have all my pods gone? Namespaced Kubernetes resources are now initially shown only for the "default" namespace. Use the namespaces selector to add more.
- Fix an issue where a cluster gets stuck on "Connecting ..." phase
- Fix an issue with auto-update
## 4.1.1
- Fix an issue where users with rights to a single namespace were seeing an empty dashboard
- Windows: use SHELL for terminal if set
- Keep highlighted table row during navigation in the details panel
## 4.1.0
**Upgrade note:** Where have all my pods gone? Namespaced Kubernetes resources are now initially shown only for the "default" namespace. Use the namespaces selector to add more.
- Change: list views default to a namespace (instead of listing resources from all namespaces)
- Command palette
- Generic logs view with Pod selector
- In-app survey extension
- Auto-update notifications and confirmation
- Possibility to add custom Helm repository through Lens
- Possibility to change visibility of common resource list columns
- Suspend / resume buttons for CronJobs
- Allow namespace to specified on role creation
- Allow for changing installation directory on Windows
- Dock tabs context menu
- Display node column in Pod list
- Unify age column output with kubectl
- Use dark colors in Dock regardless of active theme
- Improve Pod tolerations layout
- Lens metrics: scrape only lens-metrics namespace
- Lens metrics: Prometheus v2.19.3
- Update bundled kubectl to v1.18.15
- Improve how watch requests are handled
- Helm rollback window with more details
- Log more on start up
- Export PodDetailsList component to extension API
- Export Wizard components to extension API
- Export NamespaceSelect component to extension API
## 4.0.8
- Fix: extension cluster sub-menu/page periodic re-render
- Fix: app hang on boot if started from command line & oh-my-zsh prompts for auto-update
## 4.0.7
- Fix: typo in Prometheus Ingress metrics
- Fix: catch xterm.js fit error
- Fix: Windows tray icon click
- Fix: error on Kubernetes >= 1.20 on object edit
- Fix: multiline log wrapping
- Fix: prevent clusters from initializing multiple times
- Fix: show default workspace on first boot
## 4.0.6
- Don't open Lens at OS login by default
- Disable GPU acceleration by setting an env variable
- Catch HTTP Errors in case pod metrics resources do not exist or access is forbidden
- Check is persistent volume claims resource to allowed for user
- Share react-router and react-router-dom libraries to extensions
- Fix: long list cropping in sidebar
- Fix: k0s distribution detection
- Fix: Preserve line breaks when copying logs
- Fix: error on api watch on complex api versions
## 4.0.5
- Fix: add missing Kubernetes distro detectors
- Fix: improve how Workloads Overview is loaded
- Fix: race conditions on extension loader
- Fix: pod logs scrolling issues
- Fix: render node list before metrics are available
- Fix: kube-state-metrics v1.9.7
- Fix: CRD sidebar expand/collapse
- Fix: disable oh-my-zsh auto-update prompt when resolving shell environment
- Add kubectl 1.20 support to Lens Smart Terminal
- Optimise performance during cluster connect
## 4.0.4
- Fix errors on Kubernetes v1.20
- Update bundled kubectl to v1.17.15
- Fix: MacOS error on shutdown
- Fix: Kubernetes distribution detection
- Fix: error while displaying CRDs with column which type is an object
## 4.0.3
- Fix: install in-tree extensions before others
- Fix: bundle all dependencies in in-tree extensions
- Fix: display error dialog if extensions couldn't be loaded
- Fix: ensure only one app instance
## 4.0.2
We are aware some users are encountering issues and regressions from previous version. Many of these issues are something we have not seen as part of our automated or manual testing process. To make it worse, some of them are really difficult to reproduce. We want to ensure we are putting all our energy and effort trying to resolve these issues. We hope you are patient. Expect to see new patch releases still in the coming days! Fixes in this version:
- Fix: use correct apiversion for HPA details
- Fix: use correct apiversion fro CronJob details
- Fix: wrong values in node metrics
- Fix: Deployment scale button "minus"
- Fix: remove symlink on extension install and manual runtime uninstall
- Fix: logs autoscroll behaviour
- Performance fixes
## 4.0.1
- Extension install/uninstall fixes
- Fix status brick styles in pod-menu-extension
- MacOS: fix error on app start
- Performance fix: query all objects using single api call if admin and namespace list is not overridden
- Extension API fix: register a cluster page component properly to a route
## 4.0.0
- Extension API
- Improved pod logs
- Mechanism for users to specify accessible namespaces
- Tray icon
- Support networking.k8s.io/v1 for Ingress
- Add last-status information for container
- Add LoadBalancer information to Ingress view
- Add search by ip to Pod view
- Add Ready status column in the Deployment view
- Add +/- buttons in scale deployment popup screen
- Add stateful set scale slider
- Move tracker to an extension
- Ability to restart deployment
- Status bar visual fixes
- Update chart details when selecting another chart
- Use latest alpine version (3.12) for shell sessions
- Open last active cluster after switching workspaces
- Replace deprecated stable helm repository with bitnami
- Catch errors return error response when fetching chart or chart values fails
- Update EULA url
- Change add-cluster to single column layout
- Replace cluster warning event polling with watches
- Detect more Kubernetes distributions
- Performance fix when cluster has lots of namespaces
- Store more than largest kube api request amount in the event store
- Fix pod usage metrics on Kubernetes >=1.19
- Fix proxy upgrade socket timeouts
- Fix UI staleness after network issues
- Fix errors on app quit
- Fix kube-auth-proxy to accept only target cluster hostname
- Fix link to metrics stack resources
## 3.6.9
- Use Alpine 3.12 for node shell sessions
- Fix errors on app quit
- Fix kube-auth-proxy to accept only target cluster hostname
## 3.6.8
- Fix cluster connection issue when opening cluster settings for disconnected clusters
- Fetch available Helm repositories from Artifact HUB
- Check if user is cluster admin before opening cluster dashboard
- Fix issue when application is disconnecting too fast from pod shell
- Fix UI staleness after network issues
## 3.6.7
- Fix cluster dashboard opening when cluster is initially offline
## 3.6.6
- Fix labels' word boundary to cover only drawer badges
- Fix cluster dashboard opening not to start authentication proxy twice
- Fix: Refresh cluster connection status also when connection is disconnected
## 3.6.5
- Prevent drawer close when revealing secret value
- Fix app crash when CRD conditions were not present
- Add support for Stacklight prometheus metrics
- Terminal: set NO_PROXY env for localhost communication
- Fix CPU/Memory usage metrics when using prometheus operator
- Display last-applied-configuration annotation
- Fix Notifications not to block items not visually under them from being interacted with
- Fix side bar not to scroll after clicking on lower menu item
- Auto-select context if only one context is present in pasted Kubeconfig
- Fix background image of What's New page on white theme
- Reduce height on draggable-top and only render it on macos
- Download dir option is now consistent with other settings
- Allow to add the same cluster multiple times
- Convert bytes in memory chart properly
- Fix empty dashboard screen after cluster is removed and added multiple times in a row to application
- Dropdowns have pointer cursor now
- Proxy kubectl exec requests properly
- Pass always chart version information when dealing with helm commands
- Fix app crash when conditions are not yet present in CRD objects
- Fix kubeconfig generating for service account
- Update bundled Helm binary to version 3.3.4
- Fix clusters' kubeconfig paths that point to snap config dir to use current snap config path
## 3.6.4
- Fix: deleted namespace does not get auto unselected
- Get focus to dock tab (terminal & resource editor) content after resize
- Downloading kubectl binary does not block dashboard opening anymore
- Fix background image of What's New page on white theme
## 3.6.3
- Fix app crash on certain situations when opening ingress details
- Reduce app minimum size to support >= 800 x 600 resolution displays
- Fix app crash when service account has imagePullSecrets defined but the actual secret is missing
- Fix words in labels to be selectable either by hovering or double-clicking
**Known issues**
- Kubectl exec command does not work in terminal against clusters that are behind a load balancer and require Host header in request, for example Rancher clusters.
## 3.6.2
- Fix terminal connection opening
**Known issues**
- Kubectl exec command does not work in terminal against clusters that are behind a load balancer and require Host header in request, for example Rancher clusters.
## 3.6.1
- Inject Host header to k8s client requests
- Remove extra refreshEvents polling
- Fix windows installer when app directory removed manually
**Known issues**
- Kubectl exec command does not work in terminal against clusters that are behind a load balancer and require Host header in request, for example Rancher clusters.
## 3.6.0
- Allow user to configure directory where Kubectl binaries are downloaded
- Allow user to configure path to Kubectl binary, instead of using bundled Kubectl
- Allow user to select Kubeconfig from filesystem
- Show the path of the cluster's Kubeconfig in cluster settings
- Store reference to added Kubeconfig files
- Update logo
- Update Kubectl versions used with Lens
- Update Helm binary version
- Add support for PodDisruptionBudgets
- Add port-forwarding for containers in pod
- Add shortcut keys to menu items
- Improve light theme support
- Show GKE ingress IP
- Allow to remove clusters from right click
- Allow to trigger cronjobs
- Show devtools in menu
- Open last active cluster as default
- Log application logs also to log file
- Fix Dialog Esc keypress behavior
- Set new workspace name restrictions
- Fix cluster's apiUrl
- Fix: Cluster dashboard not rendered
- Fix app reload in cluster settings
- Fix proxy kubeconfig file permissions
- Move verbose log lines to silly level
- Add path to auth proxy url if present in cluster url
- Fix path validation message
- Fix: Refresh input values on cluster change
- Fix margins in cluster menu
- Restrict file permissions to only the user for pasted kubeconfigs
- Close Preferences and Cluster Setting on Esc keypress
- Fix: Update CRD api to use preferred version and implement v1 differences
- Fix: Allow to drag and drop cluster icons
- Fix: Wider version select box for Helm chart installation
- Fix: Reload only active dashboard view, not the whole app window
- Fix cluster icon margins
- Fix: Reconnect non-accessible clusters on reconnect
- Fix: Remove double copyright
- Fix: too narrow sidebar without clusters
- Fix app crash when iterating Events without 'kind' property defined
- Detect non-functional bundled kubectl
- Fix format duration rounding days error
- Handle unsupported resources properly after they've been created from editor
- Fix CRD api parsing
- Fix: allow to edit Endpoint resources
- Fix: handle status values that contains an object
- Fix: incorrect path to install/uninstall feature
- Fix: increase timeout when doing port-forward
- Fix: change manifests order for Metrics feature
- Fix: Master donut graph for memory usage only appears to show one master
- Fix: Error during creation of Kubernetes secret
- Fix: Show age of resource in seconds
- Fix: Node shell pods are pending
- Fix: Wrong created time in resource details
## 3.5.3
- Updated [EULA](https://k8slens.dev/licenses/eula.md)
## 3.5.2
- Fix application not opening properly in some cases by catching and logging error from shell sync.
## 3.5.1
- Fix kubernetes api requests to work with non-"namespaces" pathnames
- Fix: Handle invalid metrics responses properly
- Fix: Display namespace defined in kubeconfig always in the namespace selector
- Fix: Make sure that secret is defined before trying to decode
- Update Helm to 3.2.4
- Fix pasting unicode into terminal sometimes not working
- Fix: Open external links in web browser
- Fix kubectl binary version check
## 3.5.0
- Dynamic dashboard UI based on RBAC rules (hides non-accessible menus)
- Show object reference for all objects
- Unify scrollbars/paddings
- New logo
- Remove Helm release update checker
- Improve Helm release version detection
- Show owner reference on all resource details
- Fix: add arch node selector for hybrid clusters
- Fix pod shell command on Windows
- Fix app freeze after closing terminal on Windows
- Fix: use correct kubeconfig context on terminal when switching cluster
- Fix error when closing Lens on Windows
- Fix: deploy kube-state-metrics component only to amd64 nodes
- Translation correction: transit to transmit
- Remove Kontena reference from Lens logo
- Track telemetry pref changed event
- Integration tests using spectron
## 3.4.0
- Auto-detect Prometheus installation
- Allow to select Prometheus query style
- Show node events in node details
- Enable code folding in resource editor
- Improve dashboard reload
- Provide link to configMap from pod details
- Show system roles on Roles page
- Terminal dock tab improvements
- Fix port availability test
- Fix EndpointSubset.toString() to work without ports
- Return empty string if Helm release version is not detected
- Delay webview create on cluster page
- Fix no-drag css
- Fix node shell session regression
- Rebuild locales & fix translation bugs
- Show always Events title in resource details
- Fix missing spaces in container command
- Check also beta.kubernetes.io/os selector for windows pod shell
- Cache terminall shell env
- Cleanup cluster webview loading
- Update metrics feature components
- Update dashboard npm packages
## 3.3.1
- Do not timeout watch requests
- Fix pod shell error if no access to nodes
- Fix list sort by age
- Always refresh stores when object list is mounted
- Update @kubernetes/client-node to 0.11.1
## 3.3.0
- New section: endpoints
- Initial port-forward implementation for services
- Hide object-list applied filters by default
- Display emptyDir medium and size limit
- Show pod terminating status
- Fix default workspace remove
- Fix issues with crd plugins
- Fix use of bundled kubectl
- Clean up legacy references to Kontena
- Fix jobs sorting if condition is empty
- Electron 6.1.10
## 3.2.0
- Render colors in logs
- Add kubectl download mirror select to preferences
- Bundle helm3 binary
- Catch ipc errors on proxy exit
- SelfSubjectAccessReview use 'pods' resource
- Send Content-Type header on response for asset request
- Fix Helm chart version comparison
- Don't close namespace menu on select
- Change terminal fit-to-window icon
- Silence terminal websocket connection errors
- Always end watch stream if connection to kube-api ends
- Xterm v4.4.0
## 3.1.0
- Windows pod shell (powershell)
- Simplified internal architecture (improves watch & metrics stability)
- New icon
- Support `kubernetes.io/role` label for node roles
- Unlink binary download on error properly
- Electron v6.1.9
## 3.0.1
- Fix an issue with bundled kubectl
## 3.0.0
- Login / signup removed
- Prometheus fixes
- Helm v3.1.2
- Updated [EULA](https://lakendlabs.com/licenses/lens-eula.md)
## 2.7.0
- Workspaces
- Helm 3 support
- Improved cluster menu
- Snap packaging
- Add setting to allow untrusted certs for external http traffic
- Minor tweaks & bug fixes
## 2.6.4
- Minor bug fixes
## 2.6.3
- Fix kubectl download issue
- Fix terminal missing HTTPS_PROXY environment variable
- Minor bug fixes
## 2.6.2
- Minor bug fixes
## 2.6.1
- Kubernetes watch API reconnect fix
- Minor bug fixes
## 2.6.0
- More clusters supported; Improvements to cluster authentication
- User Interface for CRDs (Custom Resource Definitions)
- Cluster notifications; Display warning events counter on cluster switcher view
- Support for Microsoft Azure AKS + AAD code flow
- Minor bug fixes
## 2.5.1
- Fix cluster add problem on fresh installs
## 2.5.0
- Light theme
- Per cluster HTTP proxy setting
- Load system certificate authorities on MacOS & Windows
- Reorder clusters by dragging
- Improved in-application documentation
- Minor bug fixes
## 2.4.1
- Minor bug fixes.
## 2.4.0
- Allow to configure Prometheus address per cluster
- Allow to configure terminal working directory per cluster
- Improved new user experience: invitation code is not required anymore
- New cluster settings UI
- Fix OIDC with custom CA
- Use configured HTTP proxy for kubectl downloads
- Fix missing icons and fonts for users working offline or behind firewalls
- Minor bug fixes
## 2.3.2
- Minor bug fixes
## 2.3.1
- Minor cluster connection fixes
## 2.3.0
- Massive performance improvements
- Allow to customize cluster icons
- UI for Pod Security Policies
- Support username/password auth type in kubeconfig
- Minor bug fixes
## 2.2.2
- Minor bug fixes
## 2.2.1
- UI performance improvements
- Respect insecure-skip-tls-verification kubeconfig option
- Network timeout tweaks
## 2.2.0
- Allow to configure HTTPS proxy via preferences
- Do not send authorization headers if kubeconfig has client certificate
- Minor UI fixes
## 2.1.4
- OIDC authentication fixes
- Change the local port range from 9000-9900 to 49152-65535
- Minor UI bug fixes
- Show error details when add cluster fails
- Respect namespace defined in kubeconfig
- Notify about new kube contexts in local kubeconfig
## 2.1.1
- Minor kubeconfig auth-provider fixes.
## 2.1.0
- Don't auto-import kubeconfig
- Allow to import contexts from the default kubeconfig
- Show whats-new page if user running new version of app
- UI performance improvements & minor fixes
- Improved error messages when cluster cannot be accessed
- Improved kubeconfig validation
- Sync environment variables from login shell
- Use node affinity selectors to match OS for metrics pods
- Terminal: zsh fixes
- Terminal: override terminal initscript set KUBECONFIG with Lens provided one
- Handle network issues better
- Menu: show "About Lens" also on Linux & Windows
- Notify if cannot open port on boot
- Improve free port finding
- Sort clusters by name
## 2.0.9
- Wait shell init files are written into the disk
- Use always temp file(s) when applying resources
- Bundle server binaries
- Show errorbox on fatal boot errors
- Let app start, if already logged in, when no networking available
## 2.0.8
- Remove clusters with malformed kubeconfig when initializing clusters
- Show & accept EULA before login
- Download the correct kubectl for 32bit and check kubectl md5sums
- 32bit windows support
## 2.0.7
- Really disable invites when no more left. :)
## 2.0.6
- Remove shell outputs before shell process is started
- Catch kubeconfig load errors better
- Fix app initialization & login timeout cases
- Add Report an Issue to Window menu
- Target linux nodes only in metrics pods
## 2.0.5
- Minor bug fixes.
## 2.0.4
- Enable user invitations in menu
- Better handling for possible errors in kubeconfig authontication
- Kill backend processes on application exit
- Update dashboar UI components to v1.10.0
- Introduce "User Mode" feature for clusters
- Run login shells in embedded terminals
- Fix cluster settings page scroll issue
## 2.0.3
- Enable persistence for metrics collection only if cluster has default storage class available
- Fix cluster online checking
## 2.0.2
- AppImage Linux application packaging
- Ensure correct version of `kubectl` on terminal shell
- Better error handling for manually added cluster configrations
## 2.0.1
- Add information to request invitation
## 2.0.0
Initial release of the Lens desktop application. Basic functionality with auto-import of users local kubeconfig for cluster access.

View File

@ -3,7 +3,6 @@ import path from "path";
import webpack from "webpack";
import HtmlWebpackPlugin from "html-webpack-plugin";
import MiniCssExtractPlugin from "mini-css-extract-plugin";
import TerserPlugin from "terser-webpack-plugin";
import ForkTsCheckerPlugin from "fork-ts-checker-webpack-plugin";
import ProgressBarPlugin from "progress-bar-webpack-plugin";
import ReactRefreshWebpackPlugin from "@pmmmwh/react-refresh-webpack-plugin";
@ -59,20 +58,7 @@ export function webpackLensRenderer({ showVars = true } = {}): webpack.Configura
]
},
optimization: {
minimize: isProduction,
minimizer: [
new TerserPlugin({
cache: true,
parallel: true,
sourceMap: true,
extractComments: {
condition: "some",
banner: [
`OpenLens - Open Source Kubernetes IDE. Copyright ${new Date().getFullYear()} OpenLens Authors`
].join("\n")
}
})
],
minimize: false
},
module: {

131
yarn.lock
View File

@ -925,13 +925,6 @@
"@nodelib/fs.scandir" "2.1.3"
fastq "^1.6.0"
"@npmcli/move-file@^1.0.1":
version "1.0.1"
resolved "https://registry.yarnpkg.com/@npmcli/move-file/-/move-file-1.0.1.tgz#de103070dac0f48ce49cf6693c23af59c0f70464"
integrity sha512-Uv6h1sT+0DrblvIrolFtbvM1FgWm+/sy4B3pvLp67Zys+thcukzS5ekn7HsZFGpWP4Q3fYJCljbWQE/XivMRLw==
dependencies:
mkdirp "^1.0.4"
"@panva/asn1.js@^1.0.0":
version "1.0.0"
resolved "https://registry.yarnpkg.com/@panva/asn1.js/-/asn1.js-1.0.0.tgz#dd55ae7b8129e02049f009408b97c61ccf9032f6"
@ -1769,14 +1762,6 @@
dependencies:
tempy "*"
"@types/terser-webpack-plugin@^3.0.0":
version "3.0.0"
resolved "https://registry.yarnpkg.com/@types/terser-webpack-plugin/-/terser-webpack-plugin-3.0.0.tgz#8c5781922ce60611037b28186baf192e28780a03"
integrity sha512-K5C7izOT8rR4qiE2vfXcQNEJN4lT9cq/2qJgpMUWR2HsjDW/KVrHx2CaHuaXvaqDNsRmdELPLaxeJHiI4GjVrA==
dependencies:
"@types/webpack" "*"
terser "^4.6.13"
"@types/testing-library__jest-dom@^5.9.1":
version "5.9.5"
resolved "https://registry.yarnpkg.com/@types/testing-library__jest-dom/-/testing-library__jest-dom-5.9.5.tgz#5bf25c91ad2d7b38f264b12275e5c92a66d849b0"
@ -3296,29 +3281,6 @@ cacache@^12.0.0, cacache@^12.0.2, cacache@^12.0.3:
unique-filename "^1.1.1"
y18n "^4.0.0"
cacache@^15.0.4:
version "15.0.4"
resolved "https://registry.yarnpkg.com/cacache/-/cacache-15.0.4.tgz#b2c23cf4ac4f5ead004fb15a0efb0a20340741f1"
integrity sha512-YlnKQqTbD/6iyoJvEY3KJftjrdBYroCbxxYXzhOzsFLWlp6KX4BOlEf4mTx0cMUfVaTS3ENL2QtDWeRYoGLkkw==
dependencies:
"@npmcli/move-file" "^1.0.1"
chownr "^2.0.0"
fs-minipass "^2.0.0"
glob "^7.1.4"
infer-owner "^1.0.4"
lru-cache "^5.1.1"
minipass "^3.1.1"
minipass-collect "^1.0.2"
minipass-flush "^1.0.5"
minipass-pipeline "^1.2.2"
mkdirp "^1.0.3"
p-map "^4.0.0"
promise-inflight "^1.0.1"
rimraf "^3.0.2"
ssri "^8.0.0"
tar "^6.0.2"
unique-filename "^1.1.1"
cache-base@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/cache-base/-/cache-base-1.0.1.tgz#0a7f46416831c8b662ee36fe4e7c59d76f666ab2"
@ -5949,15 +5911,6 @@ find-cache-dir@^2.1.0:
make-dir "^2.0.0"
pkg-dir "^3.0.0"
find-cache-dir@^3.3.1:
version "3.3.1"
resolved "https://registry.yarnpkg.com/find-cache-dir/-/find-cache-dir-3.3.1.tgz#89b33fad4a4670daa94f855f7fbe31d6d84fe880"
integrity sha512-t2GDMt3oGC/v+BMwzmllWDuJF/xcDtE5j/fCGbqDD7OLuJkj0cfh1YSA5VKPvwMeLFLNDBkwOKZ2X85jGLVftQ==
dependencies:
commondir "^1.0.1"
make-dir "^3.0.2"
pkg-dir "^4.1.0"
find-npm-prefix@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/find-npm-prefix/-/find-npm-prefix-1.0.2.tgz#8d8ce2c78b3b4b9e66c8acc6a37c231eb841cfdf"
@ -9084,7 +9037,7 @@ make-dir@^2.0.0:
pify "^4.0.1"
semver "^5.6.0"
make-dir@^3.0.0, make-dir@^3.0.2, make-dir@^3.1.0:
make-dir@^3.0.0, make-dir@^3.1.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-3.1.0.tgz#415e967046b3a7f1d185277d84aa58203726a13f"
integrity sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==
@ -9417,27 +9370,6 @@ minimist@~0.0.1:
resolved "https://registry.yarnpkg.com/minimist/-/minimist-0.0.10.tgz#de3f98543dbf96082be48ad1a0c7cda836301dcf"
integrity sha1-3j+YVD2/lggr5IrRoMfNqDYwHc8=
minipass-collect@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/minipass-collect/-/minipass-collect-1.0.2.tgz#22b813bf745dc6edba2576b940022ad6edc8c617"
integrity sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA==
dependencies:
minipass "^3.0.0"
minipass-flush@^1.0.5:
version "1.0.5"
resolved "https://registry.yarnpkg.com/minipass-flush/-/minipass-flush-1.0.5.tgz#82e7135d7e89a50ffe64610a787953c4c4cbb373"
integrity sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw==
dependencies:
minipass "^3.0.0"
minipass-pipeline@^1.2.2:
version "1.2.3"
resolved "https://registry.yarnpkg.com/minipass-pipeline/-/minipass-pipeline-1.2.3.tgz#55f7839307d74859d6e8ada9c3ebe72cec216a34"
integrity sha512-cFOknTvng5vqnwOpDsZTWhNll6Jf8o2x+/diplafmxpuIymAjzoOolZG0VvQf3V2HgqzJNhnuKHYp2BqDgz8IQ==
dependencies:
minipass "^3.0.0"
minipass@^2.3.5, minipass@^2.6.0, minipass@^2.8.6, minipass@^2.9.0:
version "2.9.0"
resolved "https://registry.yarnpkg.com/minipass/-/minipass-2.9.0.tgz#e713762e7d3e32fed803115cf93e04bca9fcc9a6"
@ -9446,7 +9378,7 @@ minipass@^2.3.5, minipass@^2.6.0, minipass@^2.8.6, minipass@^2.9.0:
safe-buffer "^5.1.2"
yallist "^3.0.0"
minipass@^3.0.0, minipass@^3.1.1:
minipass@^3.0.0:
version "3.1.3"
resolved "https://registry.yarnpkg.com/minipass/-/minipass-3.1.3.tgz#7d42ff1f39635482e15f9cdb53184deebd5815fd"
integrity sha512-Mgd2GdMVzY+x3IJ+oHnVM+KG3lA5c8tnabyJKmHSaG2kAGpudxuOf8ToDkhumF7UzME7DecbQE9uOZhNm7PuJg==
@ -9460,14 +9392,6 @@ minizlib@^1.2.1:
dependencies:
minipass "^2.9.0"
minizlib@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/minizlib/-/minizlib-2.1.0.tgz#fd52c645301ef09a63a2c209697c294c6ce02cf3"
integrity sha512-EzTZN/fjSvifSX0SlqUERCN39o6T40AMarPbv0MrarSFtIITCBh7bi+dU8nxGFHuqs9jdIAeoYoKuQAAASsPPA==
dependencies:
minipass "^3.0.0"
yallist "^4.0.0"
minizlib@^2.1.1:
version "2.1.2"
resolved "https://registry.yarnpkg.com/minizlib/-/minizlib-2.1.2.tgz#e90d3466ba209b932451508a11ce3d3632145931"
@ -9505,7 +9429,7 @@ mkdirp-classic@^0.5.2:
resolved "https://registry.yarnpkg.com/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz#fa10c9115cc6d8865be221ba47ee9bed78601113"
integrity sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==
mkdirp@1.x, mkdirp@^1.0.3, mkdirp@^1.0.4:
mkdirp@1.x, mkdirp@^1.0.3:
version "1.0.4"
resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e"
integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==
@ -10620,7 +10544,7 @@ p-limit@^1.1.0:
dependencies:
p-try "^1.0.0"
p-limit@^2.0.0, p-limit@^2.2.0, p-limit@^2.3.0:
p-limit@^2.0.0, p-limit@^2.2.0:
version "2.3.0"
resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-2.3.0.tgz#3dd33c647a214fdfffd835933eb086da0dc21db1"
integrity sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==
@ -10660,13 +10584,6 @@ p-map@^2.0.0:
resolved "https://registry.yarnpkg.com/p-map/-/p-map-2.1.0.tgz#310928feef9c9ecc65b68b17693018a665cea175"
integrity sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw==
p-map@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/p-map/-/p-map-4.0.0.tgz#bb2f95a5eda2ec168ec9274e06a747c3e2904d2b"
integrity sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==
dependencies:
aggregate-error "^3.0.0"
p-retry@^3.0.1:
version "3.0.1"
resolved "https://registry.yarnpkg.com/p-retry/-/p-retry-3.0.1.tgz#316b4c8893e2c8dc1cfa891f406c4b422bebf328"
@ -11031,7 +10948,7 @@ pkg-dir@^3.0.0:
dependencies:
find-up "^3.0.0"
pkg-dir@^4.1.0, pkg-dir@^4.2.0:
pkg-dir@^4.2.0:
version "4.2.0"
resolved "https://registry.yarnpkg.com/pkg-dir/-/pkg-dir-4.2.0.tgz#f099133df7ede422e81d1d8448270eeb3e4261f3"
integrity sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==
@ -12969,13 +12886,6 @@ ssri@^6.0.0, ssri@^6.0.1:
dependencies:
figgy-pudding "^3.5.1"
ssri@^8.0.0:
version "8.0.0"
resolved "https://registry.yarnpkg.com/ssri/-/ssri-8.0.0.tgz#79ca74e21f8ceaeddfcb4b90143c458b8d988808"
integrity sha512-aq/pz989nxVYwn16Tsbj1TqFpD5LLrQxHf5zaHuieFV+R0Bbr4y8qUsOA45hXT/N4/9UNXTarBjnjVmjSOVaAA==
dependencies:
minipass "^3.1.1"
stack-trace@0.0.x:
version "0.0.10"
resolved "https://registry.yarnpkg.com/stack-trace/-/stack-trace-0.0.10.tgz#547c70b347e8d32b4e108ea1a2a159e5fdde19c0"
@ -13438,18 +13348,6 @@ tar@^4.4.10, tar@^4.4.12, tar@^4.4.13:
safe-buffer "^5.1.2"
yallist "^3.0.3"
tar@^6.0.2:
version "6.0.2"
resolved "https://registry.yarnpkg.com/tar/-/tar-6.0.2.tgz#5df17813468a6264ff14f766886c622b84ae2f39"
integrity sha512-Glo3jkRtPcvpDlAs/0+hozav78yoXKFr+c4wgw62NNMO3oo4AaJdCo21Uu7lcwr55h39W2XD1LMERc64wtbItg==
dependencies:
chownr "^2.0.0"
fs-minipass "^2.0.0"
minipass "^3.0.0"
minizlib "^2.1.0"
mkdirp "^1.0.3"
yallist "^4.0.0"
tar@^6.0.5:
version "6.0.5"
resolved "https://registry.yarnpkg.com/tar/-/tar-6.0.5.tgz#bde815086e10b39f1dcd298e89d596e1535e200f"
@ -13528,22 +13426,7 @@ terser-webpack-plugin@^1.4.3:
webpack-sources "^1.4.0"
worker-farm "^1.7.0"
terser-webpack-plugin@^3.0.3:
version "3.0.3"
resolved "https://registry.yarnpkg.com/terser-webpack-plugin/-/terser-webpack-plugin-3.0.3.tgz#23bda2687b197f878a743373b9411d917adc2e45"
integrity sha512-bZFnotuIKq5Rqzrs+qIwFzGdKdffV9epG5vDSEbYzvKAhPeR5RbbrQysfPgbIIMhNAQtZD2hGwBfSKUXjXZZZw==
dependencies:
cacache "^15.0.4"
find-cache-dir "^3.3.1"
jest-worker "^26.0.0"
p-limit "^2.3.0"
schema-utils "^2.6.6"
serialize-javascript "^3.1.0"
source-map "^0.6.1"
terser "^4.6.13"
webpack-sources "^1.4.3"
terser@^4.1.2, terser@^4.6.13, terser@^4.6.3:
terser@^4.1.2, terser@^4.6.3:
version "4.7.0"
resolved "https://registry.yarnpkg.com/terser/-/terser-4.7.0.tgz#15852cf1a08e3256a80428e865a2fa893ffba006"
integrity sha512-Lfb0RiZcjRDXCC3OSHJpEkxJ9Qeqs6mp2v4jf2MHfy8vGERmVDuvjXdd/EnP5Deme5F2yBRBymKmKHCBg2echw==
@ -14540,7 +14423,7 @@ webpack-node-externals@^1.7.2:
resolved "https://registry.yarnpkg.com/webpack-node-externals/-/webpack-node-externals-1.7.2.tgz#6e1ee79ac67c070402ba700ef033a9b8d52ac4e3"
integrity sha512-ajerHZ+BJKeCLviLUUmnyd5B4RavLF76uv3cs6KNuO8W+HuQaEs0y0L7o40NQxdPy5w0pcv8Ew7yPUAQG0UdCg==
webpack-sources@^1.1.0, webpack-sources@^1.4.0, webpack-sources@^1.4.1, webpack-sources@^1.4.3:
webpack-sources@^1.1.0, webpack-sources@^1.4.0, webpack-sources@^1.4.1:
version "1.4.3"
resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-1.4.3.tgz#eedd8ec0b928fbf1cbfe994e22d2d890f330a933"
integrity sha512-lgTS3Xhv1lCOKo7SA5TjKXMjpSM4sBjNV5+q2bqesbSPs5FjGmU6jjtBSkX9b4qW87vDIsCIlUPOEhbZrMdjeQ==