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