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

Merge branch 'master' of github.com:lensapp/lens into feature/protocol-handler

Signed-off-by: Sebastian Malton <sebastian@malton.name>
This commit is contained in:
Sebastian Malton 2021-02-09 11:09:36 -05:00
commit 42928a845f
63 changed files with 864 additions and 660 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 23 KiB

BIN
build/icons/512x512@2x.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

View File

@ -43,6 +43,7 @@ You can use theme-based CSS Variables to style an extension according to the act
## Button Colors ## Button Colors
- `--buttonPrimaryBackground`: button background color for primary actions. - `--buttonPrimaryBackground`: button background color for primary actions.
- `--buttonDefaultBackground`: default button background color. - `--buttonDefaultBackground`: default button background color.
- `--buttonLightBackground`: light button background color.
- `--buttonAccentBackground`: accent button background color. - `--buttonAccentBackground`: accent button background color.
- `--buttonDisabledBackground`: disabled button background color. - `--buttonDisabledBackground`: disabled button background color.

View File

@ -2,7 +2,7 @@
"name": "kontena-lens", "name": "kontena-lens",
"productName": "Lens", "productName": "Lens",
"description": "Lens - The Kubernetes IDE", "description": "Lens - The Kubernetes IDE",
"version": "4.1.0-alpha.2", "version": "4.1.0-beta.1",
"main": "static/build/main.js", "main": "static/build/main.js",
"copyright": "© 2020, Mirantis, Inc.", "copyright": "© 2020, Mirantis, Inc.",
"license": "MIT", "license": "MIT",
@ -16,7 +16,7 @@
"dev-run": "nodemon --watch static/build/main.js --exec \"electron --inspect .\"", "dev-run": "nodemon --watch static/build/main.js --exec \"electron --inspect .\"",
"dev:main": "yarn run compile:main --watch", "dev:main": "yarn run compile:main --watch",
"dev:renderer": "yarn run webpack-dev-server --config webpack.renderer.ts", "dev:renderer": "yarn run webpack-dev-server --config webpack.renderer.ts",
"dev:extension-types": "yarn run compile:extension-types --watch", "dev:extension-types": "yarn run compile:extension-types --watch --progress",
"compile": "env NODE_ENV=production concurrently yarn:compile:*", "compile": "env NODE_ENV=production concurrently yarn:compile:*",
"compile:main": "yarn run webpack --config webpack.main.ts", "compile:main": "yarn run webpack --config webpack.main.ts",
"compile:renderer": "yarn run webpack --config webpack.renderer.ts", "compile:renderer": "yarn run webpack --config webpack.renderer.ts",
@ -197,6 +197,7 @@
"abort-controller": "^3.0.0", "abort-controller": "^3.0.0",
"array-move": "^3.0.0", "array-move": "^3.0.0",
"await-lock": "^2.1.0", "await-lock": "^2.1.0",
"byline": "^5.0.0",
"chalk": "^4.1.0", "chalk": "^4.1.0",
"chokidar": "^3.4.3", "chokidar": "^3.4.3",
"command-exists": "1.2.9", "command-exists": "1.2.9",
@ -230,6 +231,7 @@
"react": "^17.0.1", "react": "^17.0.1",
"react-dom": "^17.0.1", "react-dom": "^17.0.1",
"react-router": "^5.2.0", "react-router": "^5.2.0",
"readable-web-to-node-stream": "^3.0.1",
"request": "^2.88.2", "request": "^2.88.2",
"request-promise-native": "^1.0.8", "request-promise-native": "^1.0.8",
"semver": "^7.3.2", "semver": "^7.3.2",
@ -252,6 +254,7 @@
"@pmmmwh/react-refresh-webpack-plugin": "^0.4.3", "@pmmmwh/react-refresh-webpack-plugin": "^0.4.3",
"@testing-library/jest-dom": "^5.11.5", "@testing-library/jest-dom": "^5.11.5",
"@testing-library/react": "^11.1.0", "@testing-library/react": "^11.1.0",
"@types/byline": "^4.2.32",
"@types/chart.js": "^2.9.21", "@types/chart.js": "^2.9.21",
"@types/circular-dependency-plugin": "^5.0.1", "@types/circular-dependency-plugin": "^5.0.1",
"@types/color": "^3.0.1", "@types/color": "^3.0.1",

View File

@ -0,0 +1,126 @@
import { EventEmitter } from "events";
import { onCorrect, onceCorrect } from "../type-enforced-ipc";
describe("type enforced ipc tests", () => {
describe("onCorrect tests", () => {
it("should call the handler if the args are valid", () => {
let called = false;
const source = new EventEmitter();
const listener = () => called = true;
const verifier = (args: unknown[]): args is [] => true;
const channel = "foobar";
onCorrect({ source, listener, verifier, channel });
source.emit(channel);
expect(called).toBe(true);
});
it("should not call the handler if the args are not valid", () => {
let called = false;
const source = new EventEmitter();
const listener = () => called = true;
const verifier = (args: unknown[]): args is [] => false;
const channel = "foobar";
onCorrect({ source, listener, verifier, channel });
source.emit(channel);
expect(called).toBe(false);
});
it("should call the handler twice if the args are valid on two emits", () => {
let called = 0;
const source = new EventEmitter();
const listener = () => called += 1;
const verifier = (args: unknown[]): args is [] => true;
const channel = "foobar";
onCorrect({ source, listener, verifier, channel });
source.emit(channel);
source.emit(channel);
expect(called).toBe(2);
});
it("should call the handler twice if the args are [valid, invalid, valid]", () => {
let called = 0;
const source = new EventEmitter();
const listener = () => called += 1;
const results = [true, false, true];
const verifier = (args: unknown[]): args is [] => results.pop();
const channel = "foobar";
onCorrect({ source, listener, verifier, channel });
source.emit(channel);
source.emit(channel);
source.emit(channel);
expect(called).toBe(2);
});
});
describe("onceCorrect tests", () => {
it("should call the handler if the args are valid", () => {
let called = false;
const source = new EventEmitter();
const listener = () => called = true;
const verifier = (args: unknown[]): args is [] => true;
const channel = "foobar";
onceCorrect({ source, listener, verifier, channel });
source.emit(channel);
expect(called).toBe(true);
});
it("should not call the handler if the args are not valid", () => {
let called = false;
const source = new EventEmitter();
const listener = () => called = true;
const verifier = (args: unknown[]): args is [] => false;
const channel = "foobar";
onceCorrect({ source, listener, verifier, channel });
source.emit(channel);
expect(called).toBe(false);
});
it("should call the handler only once even if args are valid multiple times", () => {
let called = 0;
const source = new EventEmitter();
const listener = () => called += 1;
const verifier = (args: unknown[]): args is [] => true;
const channel = "foobar";
onceCorrect({ source, listener, verifier, channel });
source.emit(channel);
source.emit(channel);
expect(called).toBe(1);
});
it("should call the handler on only the first valid set of args", () => {
let called = "";
let verifierCalled = 0;
const source = new EventEmitter();
const listener = (info: any, arg: string) => called = arg;
const verifier = (args: unknown[]): args is [string] => (++verifierCalled) % 3 === 0;
const channel = "foobar";
onceCorrect({ source, listener, verifier, channel });
source.emit(channel, {}, "a");
source.emit(channel, {}, "b");
source.emit(channel, {}, "c");
source.emit(channel, {}, "d");
source.emit(channel, {}, "e");
source.emit(channel, {}, "f");
source.emit(channel, {}, "g");
source.emit(channel, {}, "h");
source.emit(channel, {}, "i");
expect(called).toBe("c");
});
});
});

3
src/common/ipc/index.ts Normal file
View File

@ -0,0 +1,3 @@
export * from "./ipc";
export * from "./update-available";
export * from "./type-enforced-ipc";

View File

@ -4,12 +4,12 @@
import { ipcMain, ipcRenderer, webContents, remote } from "electron"; import { ipcMain, ipcRenderer, webContents, remote } from "electron";
import { toJS } from "mobx"; import { toJS } from "mobx";
import logger from "../main/logger"; import logger from "../../main/logger";
import { ClusterFrameInfo, clusterFrameMap } from "./cluster-frames"; import { ClusterFrameInfo, clusterFrameMap } from "../cluster-frames";
const subFramesChannel = "ipc:get-sub-frames"; const subFramesChannel = "ipc:get-sub-frames";
export function handleRequest(channel: string, listener: (...args: any[]) => any) { export function handleRequest(channel: string, listener: (event: Electron.IpcMainInvokeEvent, ...args: any[]) => any) {
ipcMain.handle(channel, listener); ipcMain.handle(channel, listener);
} }

View File

@ -0,0 +1,71 @@
import { EventEmitter } from "events";
import logger from "../../main/logger";
export type HandlerEvent<EM extends EventEmitter> = Parameters<Parameters<EM["on"]>[1]>[0];
export type ListVerifier<T extends any[]> = (args: unknown[]) => args is T;
export type Rest<T> = T extends [any, ...infer R] ? R : [];
/**
* Adds a listener to `source` that waits for the first IPC message with the correct
* argument data is sent.
* @param channel The channel to be listened on
* @param listener The function for the channel to be called if the args of the correct type
* @param verifier The function to be called to verify that the args are the correct type
*/
export function onceCorrect<
EM extends EventEmitter,
L extends (event: HandlerEvent<EM>, ...args: any[]) => any
>({
source,
channel,
listener,
verifier,
}: {
source: EM,
channel: string | symbol,
listener: L,
verifier: ListVerifier<Rest<Parameters<L>>>,
}): void {
function handler(event: HandlerEvent<EM>, ...args: unknown[]): void {
if (verifier(args)) {
source.removeListener(channel, handler); // remove immediately
(async () => (listener(event, ...args)))() // might return a promise, or throw, or reject
.catch((error: any) => logger.error("[IPC]: channel once handler threw error", { channel, error }));
} else {
logger.error("[IPC]: channel was emitted with invalid data", { channel, args });
}
}
source.on(channel, handler);
}
/**
* Adds a listener to `source` that checks to verify the arguments before calling the handler.
* @param channel The channel to be listened on
* @param listener The function for the channel to be called if the args of the correct type
* @param verifier The function to be called to verify that the args are the correct type
*/
export function onCorrect<
EM extends EventEmitter,
L extends (event: HandlerEvent<EM>, ...args: any[]) => any
>({
source,
channel,
listener,
verifier,
}: {
source: EM,
channel: string | symbol,
listener: L,
verifier: ListVerifier<Rest<Parameters<L>>>,
}): void {
source.on(channel, (event, ...args: unknown[]) => {
if (verifier(args)) {
(async () => (listener(event, ...args)))() // might return a promise, or throw, or reject
.catch(error => logger.error("[IPC]: channel on handler threw error", { channel, error }));
} else {
logger.error("[IPC]: channel was emitted with invalid data", { channel, args });
}
});
}

View File

@ -0,0 +1,48 @@
import { UpdateInfo } from "electron-updater";
export const UpdateAvailableChannel = "update-available";
export const AutoUpdateLogPrefix = "[UPDATE-CHECKER]";
/**
* [<back-channel>, <update-info>]
*/
export type UpdateAvailableFromMain = [string, UpdateInfo];
export function areArgsUpdateAvailableFromMain(args: unknown[]): args is UpdateAvailableFromMain {
if (args.length !== 2) {
return false;
}
if (typeof args[0] !== "string") {
return false;
}
if (typeof args[1] !== "object" || args[1] === null) {
// TODO: improve this checking
return false;
}
return true;
}
export type BackchannelArg = {
doUpdate: false;
} | {
doUpdate: true;
now: boolean;
};
export type UpdateAvailableToBackchannel = [BackchannelArg];
export function areArgsUpdateAvailableToBackchannel(args: unknown[]): args is UpdateAvailableToBackchannel {
if (args.length !== 1) {
return false;
}
if (typeof args[0] !== "object" || args[0] === null) {
// TODO: improve this checking
return false;
}
return true;
}

View File

@ -2,8 +2,10 @@ import { AbortController } from "abort-controller";
/** /**
* Return a promise that will be resolved after at least `timeout` ms have * Return a promise that will be resolved after at least `timeout` ms have
* passed * passed. If `failFast` is provided then the promise is also resolved if it has
* been aborted.
* @param timeout The number of milliseconds before resolving * @param timeout The number of milliseconds before resolving
* @param failFast An abort controller instance to cause the delay to short-circuit
*/ */
export function delay(timeout = 1000, failFast?: AbortController): Promise<void> { export function delay(timeout = 1000, failFast?: AbortController): Promise<void> {
return new Promise(resolve => { return new Promise(resolve => {

View File

@ -10,7 +10,6 @@ export * from "./cloneJson";
export * from "./delay"; export * from "./delay";
export * from "./debouncePromise"; export * from "./debouncePromise";
export * from "./defineGlobal"; export * from "./defineGlobal";
export * from "./delay";
export * from "./getRandId"; export * from "./getRandId";
export * from "./splitArray"; export * from "./splitArray";
export * from "./saveToAppFiles"; export * from "./saveToAppFiles";

View File

@ -1,20 +1,78 @@
import { autoUpdater } from "electron-updater"; import { autoUpdater, UpdateInfo } from "electron-updater";
import logger from "./logger"; import logger from "./logger";
import { isDevelopment, isTestEnv } from "../common/vars";
import { delay } from "../common/utils";
import { areArgsUpdateAvailableToBackchannel, AutoUpdateLogPrefix, broadcastMessage, onceCorrect, UpdateAvailableChannel, UpdateAvailableToBackchannel } from "../common/ipc";
import { ipcMain } from "electron";
export class AppUpdater { function handleAutoUpdateBackChannel(event: Electron.IpcMainEvent, ...[arg]: UpdateAvailableToBackchannel) {
static readonly defaultUpdateIntervalMs = 1000 * 60 * 60 * 24; // once a day if (arg.doUpdate) {
if (arg.now) {
static checkForUpdates() { logger.info(`${AutoUpdateLogPrefix}: User chose to update now`);
return autoUpdater.checkForUpdatesAndNotify(); autoUpdater.downloadUpdate()
} .then(() => autoUpdater.quitAndInstall())
.catch(error => logger.error(`${AutoUpdateLogPrefix}: Failed to download or install update`, { error }));
constructor(protected updateInterval = AppUpdater.defaultUpdateIntervalMs) { } else {
autoUpdater.logger = logger; logger.info(`${AutoUpdateLogPrefix}: User chose to update on quit`);
} autoUpdater.autoInstallOnAppQuit = true;
autoUpdater.downloadUpdate()
public start() { .catch(error => logger.error(`${AutoUpdateLogPrefix}: Failed to download update`, { error }));
setInterval(AppUpdater.checkForUpdates, this.updateInterval); }
} else {
return AppUpdater.checkForUpdates(); logger.info(`${AutoUpdateLogPrefix}: User chose not to update`);
}
}
/**
* starts the automatic update checking
* @param interval milliseconds between interval to check on, defaults to 24h
*/
export function startUpdateChecking(interval = 1000 * 60 * 60 * 24): void {
if (isDevelopment || isTestEnv) {
return;
}
autoUpdater.logger = logger;
autoUpdater.autoDownload = false;
autoUpdater.autoInstallOnAppQuit = false;
autoUpdater
.on("update-available", (args: UpdateInfo) => {
try {
const backchannel = `auto-update:${args.version}`;
ipcMain.removeAllListeners(backchannel); // only one handler should be present
// make sure that the handler is in place before broadcasting (prevent race-condition)
onceCorrect({
source: ipcMain,
channel: backchannel,
listener: handleAutoUpdateBackChannel,
verifier: areArgsUpdateAvailableToBackchannel,
});
logger.info(`${AutoUpdateLogPrefix}: broadcasting update available`, { backchannel, version: args.version });
broadcastMessage(UpdateAvailableChannel, backchannel, args);
} catch (error) {
logger.error(`${AutoUpdateLogPrefix}: broadcasting failed`, { error });
}
});
async function helper() {
while (true) {
await checkForUpdates();
await delay(interval);
}
}
helper();
}
export async function checkForUpdates(): Promise<void> {
try {
logger.info(`📡 Checking for app updates`);
await autoUpdater.checkForUpdates();
} catch (error) {
logger.error(`${AutoUpdateLogPrefix}: failed with an error`, { error: String(error) });
} }
} }

View File

@ -10,7 +10,6 @@ import path from "path";
import { LensProxy } from "./lens-proxy"; import { LensProxy } from "./lens-proxy";
import { WindowManager } from "./window-manager"; import { WindowManager } from "./window-manager";
import { ClusterManager } from "./cluster-manager"; import { ClusterManager } from "./cluster-manager";
import { AppUpdater } from "./app-updater";
import { shellSync } from "./shell-sync"; import { shellSync } from "./shell-sync";
import { getFreePort } from "./port"; import { getFreePort } from "./port";
import { mangleProxyEnv } from "./proxy-env"; import { mangleProxyEnv } from "./proxy-env";
@ -29,6 +28,7 @@ import { filesystemProvisionerStore } from "./extension-filesystem";
import { LensProtocolRouterMain } from "./protocol-handler"; import { LensProtocolRouterMain } from "./protocol-handler";
import { getAppVersion, getAppVersionFromProxyServer } from "../common/utils"; import { getAppVersion, getAppVersionFromProxyServer } from "../common/utils";
import { bindBroadcastHandlers } from "../common/ipc"; import { bindBroadcastHandlers } from "../common/ipc";
import { startUpdateChecking } from "./app-updater";
const workingDir = path.join(app.getPath("appData"), appName); const workingDir = path.join(app.getPath("appData"), appName);
let proxyPort: number; let proxyPort: number;
@ -90,11 +90,6 @@ app.on("ready", async () => {
app.exit(); app.exit();
}); });
logger.info(`📡 Checking for app updates`);
const updater = new AppUpdater();
updater.start();
registerFileProtocol("static", __static); registerFileProtocol("static", __static);
await installDeveloperTools(); await installDeveloperTools();
@ -152,6 +147,7 @@ app.on("ready", async () => {
logger.info("🖥️ Starting WindowManager"); logger.info("🖥️ Starting WindowManager");
windowManager = WindowManager.getInstance<WindowManager>(proxyPort); windowManager = WindowManager.getInstance<WindowManager>(proxyPort);
windowManager.whenLoaded.then(() => { windowManager.whenLoaded.then(() => {
startUpdateChecking();
LensProtocolRouterMain LensProtocolRouterMain
.getInstance<LensProtocolRouterMain>() .getInstance<LensProtocolRouterMain>()
.rendererLoaded = true; .rendererLoaded = true;

View File

@ -194,7 +194,8 @@ export class LensProxy {
if (proxyTarget) { if (proxyTarget) {
// allow to fetch apis in "clusterId.localhost:port" from "localhost:port" // allow to fetch apis in "clusterId.localhost:port" from "localhost:port"
res.setHeader("Access-Control-Allow-Origin", this.origin); // this should be safe because we have already validated cluster uuid
res.setHeader("Access-Control-Allow-Origin", "*");
return proxy.web(req, res, proxyTarget); return proxy.web(req, res, proxyTarget);
} }

View File

@ -5,7 +5,7 @@ import path from "path";
import { readFile } from "fs-extra"; import { readFile } from "fs-extra";
import { Cluster } from "./cluster"; import { Cluster } from "./cluster";
import { apiPrefix, appName, publicPath, isDevelopment, webpackDevServerPort } from "../common/vars"; import { apiPrefix, appName, publicPath, isDevelopment, webpackDevServerPort } from "../common/vars";
import { helmRoute, kubeconfigRoute, metricsRoute, portForwardRoute, resourceApplierRoute, watchRoute, versionRoute } from "./routes"; import { helmRoute, kubeconfigRoute, metricsRoute, portForwardRoute, resourceApplierRoute, versionRoute } from "./routes";
import logger from "./logger"; import logger from "./logger";
export interface RouterRequestOpts { export interface RouterRequestOpts {
@ -146,9 +146,6 @@ export class Router {
this.router.add({ method: "get", path: "/version"}, versionRoute.getVersion.bind(versionRoute)); this.router.add({ method: "get", path: "/version"}, versionRoute.getVersion.bind(versionRoute));
this.router.add({ method: "get", path: `${apiPrefix}/kubeconfig/service-account/{namespace}/{account}` }, kubeconfigRoute.routeServiceAccountRoute.bind(kubeconfigRoute)); this.router.add({ method: "get", path: `${apiPrefix}/kubeconfig/service-account/{namespace}/{account}` }, kubeconfigRoute.routeServiceAccountRoute.bind(kubeconfigRoute));
// Watch API
this.router.add({ method: "post", path: `${apiPrefix}/watch` }, watchRoute.routeWatch.bind(watchRoute));
// Metrics API // Metrics API
this.router.add({ method: "post", path: `${apiPrefix}/metrics` }, metricsRoute.routeMetrics.bind(metricsRoute)); this.router.add({ method: "post", path: `${apiPrefix}/metrics` }, metricsRoute.routeMetrics.bind(metricsRoute));

View File

@ -1,7 +1,6 @@
export * from "./kubeconfig-route"; export * from "./kubeconfig-route";
export * from "./metrics-route"; export * from "./metrics-route";
export * from "./port-forward-route"; export * from "./port-forward-route";
export * from "./watch-route";
export * from "./helm-route"; export * from "./helm-route";
export * from "./resource-applier-route"; export * from "./resource-applier-route";
export * from "./version-route"; export * from "./version-route";

View File

@ -1,162 +0,0 @@
import type { KubeJsonApiData, KubeJsonApiError } from "../../renderer/api/kube-json-api";
import plimit from "p-limit";
import { delay } from "../../common/utils";
import { LensApiRequest } from "../router";
import { LensApi } from "../lens-api";
import { KubeConfig, Watch } from "@kubernetes/client-node";
import { ServerResponse } from "http";
import { Request } from "request";
import logger from "../logger";
export interface IKubeWatchEvent<T = KubeJsonApiData | KubeJsonApiError> {
type: "ADDED" | "MODIFIED" | "DELETED" | "ERROR" | "STREAM_END";
object?: T;
}
export interface IKubeWatchEventStreamEnd extends IKubeWatchEvent {
type: "STREAM_END";
url: string;
status: number;
}
export interface IWatchRoutePayload {
apis: string[]; // kube-api url list for subscribing to watch events
}
class ApiWatcher {
private apiUrl: string;
private response: ServerResponse;
private watchRequest: Request;
private watch: Watch;
private processor: NodeJS.Timeout;
private eventBuffer: any[] = [];
constructor(apiUrl: string, kubeConfig: KubeConfig, response: ServerResponse) {
this.apiUrl = apiUrl;
this.watch = new Watch(kubeConfig);
this.response = response;
}
public async start() {
if (this.processor) {
clearInterval(this.processor);
}
this.processor = setInterval(() => {
if (this.response.finished) return;
const events = this.eventBuffer.splice(0);
events.map(event => this.sendEvent(event));
this.response.flushHeaders();
}, 50);
this.watchRequest = await this.watch.watch(this.apiUrl, {}, this.watchHandler.bind(this), this.doneHandler.bind(this));
}
public stop() {
if (!this.watchRequest) {
return;
}
if (this.processor) {
clearInterval(this.processor);
}
logger.debug(`Stopping watcher for api: ${this.apiUrl}`);
try {
this.watchRequest.abort();
const event: IKubeWatchEventStreamEnd = {
type: "STREAM_END",
url: this.apiUrl,
status: 410,
};
this.sendEvent(event);
logger.debug("watch aborted");
} catch (error) {
logger.error(`Watch abort errored:${error}`);
}
}
private watchHandler(phase: string, obj: any) {
this.eventBuffer.push({
type: phase,
object: obj
});
}
private doneHandler(error: Error) {
if (error) logger.warn(`watch ended: ${error.toString()}`);
this.watchRequest.abort();
}
private sendEvent(evt: IKubeWatchEvent) {
this.response.write(`${JSON.stringify(evt)}\n`);
}
}
class WatchRoute extends LensApi {
private response: ServerResponse;
private setResponse(response: ServerResponse) {
// clean up previous connection and stop all corresponding watch-api requests
// otherwise it happens only by request timeout or something else..
this.response?.destroy();
this.response = response;
}
public async routeWatch(request: LensApiRequest<IWatchRoutePayload>) {
const { response, cluster, payload: { apis } = {} } = request;
if (!apis?.length) {
this.respondJson(response, {
message: "watch apis list is empty"
}, 400);
return;
}
this.setResponse(response);
response.setHeader("Content-Type", "application/json");
response.setHeader("Cache-Control", "no-cache");
response.setHeader("Connection", "keep-alive");
logger.debug(`watch using kubeconfig:${JSON.stringify(cluster.getProxyKubeconfig(), null, 2)}`);
// limit concurrent k8s requests to avoid possible ECONNRESET-error
const requests = plimit(5);
const watchers = new Map<string, ApiWatcher>();
let isWatchRequestEnded = false;
apis.forEach(apiUrl => {
const watcher = new ApiWatcher(apiUrl, cluster.getProxyKubeconfig(), response);
watchers.set(apiUrl, watcher);
requests(async () => {
if (isWatchRequestEnded) return;
await watcher.start();
await delay(100);
});
});
function onRequestEnd() {
if (isWatchRequestEnded) return;
isWatchRequestEnded = true;
requests.clearQueue();
watchers.forEach(watcher => watcher.stop());
watchers.clear();
}
request.raw.req.on("end", () => {
logger.info("Watch request end");
onRequestEnd();
});
request.raw.req.on("close", () => {
logger.info("Watch request close");
onRequestEnd();
});
}
}
export const watchRoute = new WatchRoute();

View File

@ -1,9 +1,9 @@
import path from "path"; import path from "path";
import packageInfo from "../../package.json"; import packageInfo from "../../package.json";
import { dialog, Menu, NativeImage, Tray } from "electron"; import { Menu, NativeImage, Tray } from "electron";
import { autorun } from "mobx"; import { autorun } from "mobx";
import { showAbout } from "./menu"; import { showAbout } from "./menu";
import { AppUpdater } from "./app-updater"; import { checkForUpdates } from "./app-updater";
import { WindowManager } from "./window-manager"; import { WindowManager } from "./window-manager";
import { clusterStore } from "../common/cluster-store"; import { clusterStore } from "../common/cluster-store";
import { workspaceStore } from "../common/workspace-store"; import { workspaceStore } from "../common/workspace-store";
@ -102,16 +102,8 @@ function createTrayMenu(windowManager: WindowManager): Menu {
{ {
label: "Check for updates", label: "Check for updates",
async click() { async click() {
const result = await AppUpdater.checkForUpdates(); await checkForUpdates();
await windowManager.ensureMainWindow();
if (!result) {
const browserWindow = await windowManager.ensureMainWindow();
dialog.showMessageBoxSync(browserWindow, {
message: "No updates available",
type: "info",
});
}
}, },
}, },
{ {

View File

@ -7,7 +7,7 @@ import { KubeApi, parseKubeApi } from "./kube-api";
@autobind() @autobind()
export class ApiManager { export class ApiManager {
private apis = observable.map<string, KubeApi>(); private apis = observable.map<string, KubeApi>();
private stores = observable.map<KubeApi, KubeObjectStore>(); private stores = observable.map<string, KubeObjectStore>();
getApi(pathOrCallback: string | ((api: KubeApi) => boolean)) { getApi(pathOrCallback: string | ((api: KubeApi) => boolean)) {
if (typeof pathOrCallback === "string") { if (typeof pathOrCallback === "string") {
@ -46,12 +46,12 @@ export class ApiManager {
@action @action
registerStore(store: KubeObjectStore, apis: KubeApi[] = [store.api]) { registerStore(store: KubeObjectStore, apis: KubeApi[] = [store.api]) {
apis.forEach(api => { apis.forEach(api => {
this.stores.set(api, store); this.stores.set(api.apiBase, store);
}); });
} }
getStore<S extends KubeObjectStore>(api: string | KubeApi): S { getStore<S extends KubeObjectStore>(api: string | KubeApi): S {
return this.stores.get(this.resolveApi(api)) as S; return this.stores.get(this.resolveApi(api)?.apiBase) as S;
} }
} }

View File

@ -57,6 +57,7 @@ export interface IReleaseRevision {
updated: string; updated: string;
status: string; status: string;
chart: string; chart: string;
app_version: string;
description: string; description: string;
} }

View File

@ -3,7 +3,7 @@
import { stringify } from "querystring"; import { stringify } from "querystring";
import { EventEmitter } from "../../common/event-emitter"; import { EventEmitter } from "../../common/event-emitter";
import { cancelableFetch } from "../utils/cancelableFetch"; import { cancelableFetch } from "../utils/cancelableFetch";
import { randomBytes } from "crypto";
export interface JsonApiData { export interface JsonApiData {
} }
@ -55,6 +55,34 @@ export class JsonApi<D = JsonApiData, P extends JsonApiParams = JsonApiParams> {
return this.request<T>(path, params, { ...reqInit, method: "get" }); return this.request<T>(path, params, { ...reqInit, method: "get" });
} }
getResponse(path: string, params?: P, init: RequestInit = {}): Promise<Response> {
const reqPath = `${this.config.apiBase}${path}`;
const subdomain = randomBytes(2).toString("hex");
let reqUrl = `http://${subdomain}.${window.location.host}${reqPath}`; // hack around browser connection limits (chromium allows 6 per domain)
const reqInit: RequestInit = { ...init };
const { query } = params || {} as P;
if (!reqInit.method) {
reqInit.method = "get";
}
if (query) {
const queryString = stringify(query);
reqUrl += (reqUrl.includes("?") ? "&" : "?") + queryString;
}
const infoLog: JsonApiLog = {
method: reqInit.method.toUpperCase(),
reqUrl: reqPath,
reqInit,
};
this.writeLog({ ...infoLog });
return fetch(reqUrl, reqInit);
}
post<T = D>(path: string, params?: P, reqInit: RequestInit = {}) { post<T = D>(path: string, params?: P, reqInit: RequestInit = {}) {
return this.request<T>(path, params, { ...reqInit, method: "post" }); return this.request<T>(path, params, { ...reqInit, method: "post" });
} }

View File

@ -9,7 +9,9 @@ import { apiKube } from "./index";
import { createKubeApiURL, parseKubeApi } from "./kube-api-parse"; import { createKubeApiURL, parseKubeApi } from "./kube-api-parse";
import { KubeJsonApi, KubeJsonApiData, KubeJsonApiDataList } from "./kube-json-api"; import { KubeJsonApi, KubeJsonApiData, KubeJsonApiDataList } from "./kube-json-api";
import { IKubeObjectConstructor, KubeObject } from "./kube-object"; import { IKubeObjectConstructor, KubeObject } from "./kube-object";
import { kubeWatchApi } from "./kube-watch-api"; import byline from "byline";
import { ReadableWebToNodeStream } from "readable-web-to-node-stream";
import { IKubeWatchEvent } from "./kube-watch-api";
export interface IKubeApiOptions<T extends KubeObject> { export interface IKubeApiOptions<T extends KubeObject> {
/** /**
@ -91,6 +93,12 @@ export function ensureObjectSelfLink(api: KubeApi, object: KubeJsonApiData) {
} }
} }
type KubeApiWatchOptions = {
namespace: string;
callback?: (data: IKubeWatchEvent) => void;
abortController?: AbortController
};
export class KubeApi<T extends KubeObject = any> { export class KubeApi<T extends KubeObject = any> {
readonly kind: string; readonly kind: string;
readonly apiBase: string; readonly apiBase: string;
@ -104,6 +112,7 @@ export class KubeApi<T extends KubeObject = any> {
public objectConstructor: IKubeObjectConstructor<T>; public objectConstructor: IKubeObjectConstructor<T>;
protected request: KubeJsonApi; protected request: KubeJsonApi;
protected resourceVersions = new Map<string, string>(); protected resourceVersions = new Map<string, string>();
protected watchDisposer: () => void;
constructor(protected options: IKubeApiOptions<T>) { constructor(protected options: IKubeApiOptions<T>) {
const { const {
@ -357,8 +366,88 @@ export class KubeApi<T extends KubeObject = any> {
}); });
} }
watch(): () => void { watch(opts: KubeApiWatchOptions = { namespace: "" }): () => void {
return kubeWatchApi.subscribeApi(this); if (!opts.abortController) {
opts.abortController = new AbortController();
}
const { abortController, namespace, callback } = opts;
const watchUrl = this.getWatchUrl(namespace);
const responsePromise = this.request.getResponse(watchUrl, null, {
signal: abortController.signal
});
responsePromise.then((response) => {
if (!response.ok && !abortController.signal.aborted) {
if (response.status === 410) { // resourceVersion has gone
setTimeout(() => {
this.refreshResourceVersion().then(() => {
this.watch({...opts, abortController});
});
}, 1000);
} else if (response.status >= 500) { // k8s is having hard time
setTimeout(() => {
this.watch({...opts, abortController});
}, 5000);
}
return;
}
const nodeStream = new ReadableWebToNodeStream(response.body);
const stream = byline(nodeStream);
stream.on("data", (line) => {
try {
const event: IKubeWatchEvent = JSON.parse(line);
this.modifyWatchEvent(event);
if (callback) {
callback(event);
}
} catch (ignore) {
// ignore parse errors
}
});
stream.on("close", () => {
setTimeout(() => {
if (!abortController.signal.aborted) this.watch({...opts, namespace, callback});
}, 1000);
});
}, (error) => {
if (error instanceof DOMException) return; // AbortController rejects, we can ignore it
console.error("watch rejected", error);
}).catch((error) => {
console.error("watch error", error);
});
const disposer = () => {
abortController.abort();
};
return disposer;
}
protected modifyWatchEvent(event: IKubeWatchEvent) {
switch (event.type) {
case "ADDED":
case "DELETED":
case "MODIFIED": {
ensureObjectSelfLink(this, event.object);
const { namespace, resourceVersion } = event.object.metadata;
this.setResourceVersion(namespace, resourceVersion);
this.setResourceVersion("", resourceVersion);
break;
}
}
} }
} }

View File

@ -1,143 +1,63 @@
// Kubernetes watch-api client // Kubernetes watch-api client
// API: https://developer.mozilla.org/en-US/docs/Web/API/Streams_API/Using_readable_streams // API: https://developer.mozilla.org/en-US/docs/Web/API/Streams_API/Using_readable_streams
import type { Cluster } from "../../main/cluster";
import type { IKubeWatchEvent, IKubeWatchEventStreamEnd, IWatchRoutePayload } from "../../main/routes/watch-route";
import type { KubeObject } from "./kube-object";
import type { KubeObjectStore } from "../kube-object.store"; import type { KubeObjectStore } from "../kube-object.store";
import type { ClusterContext } from "../components/context";
import plimit from "p-limit"; import plimit from "p-limit";
import debounce from "lodash/debounce"; import { comparer, IReactionDisposer, observable, reaction, when } from "mobx";
import { autorun, comparer, computed, IReactionDisposer, observable, reaction } from "mobx"; import { autobind, noop } from "../utils";
import { autobind, EventEmitter, noop } from "../utils"; import { KubeApi } from "./kube-api";
import { ensureObjectSelfLink, KubeApi, parseKubeApi } from "./kube-api"; import { KubeJsonApiData } from "./kube-json-api";
import { KubeJsonApiData, KubeJsonApiError } from "./kube-json-api"; import { isDebugging, isProduction } from "../../common/vars";
import { apiPrefix, isDebugging, isProduction } from "../../common/vars";
import { apiManager } from "./api-manager";
export { IKubeWatchEvent, IKubeWatchEventStreamEnd }; export interface IKubeWatchEvent<T = KubeJsonApiData> {
type: "ADDED" | "MODIFIED" | "DELETED";
export interface IKubeWatchMessage<T extends KubeObject = any> { object?: T;
namespace?: string;
data?: IKubeWatchEvent<KubeJsonApiData>
error?: IKubeWatchEvent<KubeJsonApiError>;
api?: KubeApi<T>;
store?: KubeObjectStore<T>;
} }
export interface IKubeWatchSubscribeStoreOptions { export interface IKubeWatchSubscribeStoreOptions {
namespaces?: string[]; // default: all accessible namespaces
preload?: boolean; // preload store items, default: true preload?: boolean; // preload store items, default: true
waitUntilLoaded?: boolean; // subscribe only after loading all stores, default: true waitUntilLoaded?: boolean; // subscribe only after loading all stores, default: true
loadOnce?: boolean; // check store.isLoaded to skip loading if done already, default: false loadOnce?: boolean; // check store.isLoaded to skip loading if done already, default: false
} }
export interface IKubeWatchReconnectOptions {
reconnectAttempts: number;
timeout: number;
}
export interface IKubeWatchLog { export interface IKubeWatchLog {
message: string | Error; message: string | string[] | Error;
meta?: object; meta?: object;
cssStyle?: string;
} }
@autobind() @autobind()
export class KubeWatchApi { export class KubeWatchApi {
private requestId = 0; @observable context: ClusterContext = null;
private reader: ReadableStreamReader<string>;
public onMessage = new EventEmitter<[IKubeWatchMessage]>();
@observable.ref private cluster: Cluster;
@observable.ref private namespaces: string[] = [];
@observable subscribers = observable.map<KubeApi, number>(); @observable subscribers = observable.map<KubeApi, number>();
@observable isConnected = false; @observable isConnected = false;
@computed get isReady(): boolean { contextReady = when(() => Boolean(this.context));
return Boolean(this.cluster && this.namespaces);
constructor() {
this.init();
} }
@computed get isActive(): boolean { private async init() {
return this.apis.length > 0; await this.contextReady;
}
@computed get apis(): string[] {
if (!this.isReady) {
return [];
}
return Array.from(this.subscribers.keys()).map(api => {
if (!this.isAllowedApi(api)) {
return [];
}
// TODO: optimize - check when all namespaces are selected and then request all in one
if (api.isNamespaced && !this.cluster.isGlobalWatchEnabled) {
return this.namespaces.map(namespace => api.getWatchUrl(namespace));
}
return api.getWatchUrl();
}).flat();
}
async init({ getCluster, getNamespaces }: {
getCluster: () => Cluster,
getNamespaces: () => string[],
}): Promise<void> {
autorun(() => {
this.cluster = getCluster();
this.namespaces = getNamespaces();
});
this.bindAutoConnect();
}
private bindAutoConnect() {
const connect = debounce(() => this.connect(), 1000);
reaction(() => this.apis, connect, {
fireImmediately: true,
equals: comparer.structural,
});
window.addEventListener("online", () => this.connect());
window.addEventListener("offline", () => this.disconnect());
setInterval(() => this.connectionCheck(), 60000 * 5); // every 5m
}
getSubscribersCount(api: KubeApi) {
return this.subscribers.get(api) || 0;
} }
isAllowedApi(api: KubeApi): boolean { isAllowedApi(api: KubeApi): boolean {
return Boolean(this?.cluster.isAllowedResource(api.kind)); return Boolean(this.context?.cluster.isAllowedResource(api.kind));
} }
subscribeApi(api: KubeApi | KubeApi[]): () => void { preloadStores(stores: KubeObjectStore[], opts: { namespaces?: string[], loadOnce?: boolean } = {}) {
const apis: KubeApi[] = [api].flat();
apis.forEach(api => {
if (!this.isAllowedApi(api)) return; // skip
this.subscribers.set(api, this.getSubscribersCount(api) + 1);
});
return () => {
apis.forEach(api => {
const count = this.getSubscribersCount(api) - 1;
if (count <= 0) this.subscribers.delete(api);
else this.subscribers.set(api, count);
});
};
}
preloadStores(stores: KubeObjectStore[], { loadOnce = false } = {}) {
const limitRequests = plimit(1); // load stores one by one to allow quick skipping when fast clicking btw pages const limitRequests = plimit(1); // load stores one by one to allow quick skipping when fast clicking btw pages
const preloading: Promise<any>[] = []; const preloading: Promise<any>[] = [];
for (const store of stores) { for (const store of stores) {
preloading.push(limitRequests(async () => { preloading.push(limitRequests(async () => {
if (store.isLoaded && loadOnce) return; // skip if (store.isLoaded && opts.loadOnce) return; // skip
return store.loadAll(this.namespaces); return store.loadAll({ namespaces: opts.namespaces });
})); }));
} }
@ -147,19 +67,22 @@ export class KubeWatchApi {
}; };
} }
subscribeStores(stores: KubeObjectStore[], options: IKubeWatchSubscribeStoreOptions = {}): () => void { subscribeStores(stores: KubeObjectStore[], opts: IKubeWatchSubscribeStoreOptions = {}): () => void {
const { preload = true, waitUntilLoaded = true, loadOnce = false } = options; const { preload = true, waitUntilLoaded = true, loadOnce = false, } = opts;
const apis = new Set(stores.map(store => store.getSubscribeApis()).flat()); const subscribingNamespaces = opts.namespaces ?? this.context?.allNamespaces ?? [];
const unsubscribeList: (() => void)[] = []; const unsubscribeList: Function[] = [];
let isUnsubscribed = false; let isUnsubscribed = false;
const load = () => this.preloadStores(stores, { loadOnce }); const load = (namespaces = subscribingNamespaces) => this.preloadStores(stores, { namespaces, loadOnce });
let preloading = preload && load(); let preloading = preload && load();
let cancelReloading: IReactionDisposer = noop; let cancelReloading: IReactionDisposer = noop;
const subscribe = () => { const subscribe = () => {
if (isUnsubscribed) return; if (isUnsubscribed) return;
apis.forEach(api => unsubscribeList.push(this.subscribeApi(api)));
stores.forEach((store) => {
unsubscribeList.push(store.subscribe());
});
}; };
if (preloading) { if (preloading) {
@ -167,17 +90,20 @@ export class KubeWatchApi {
preloading.loading.then(subscribe, error => { preloading.loading.then(subscribe, error => {
this.log({ this.log({
message: new Error("Loading stores has failed"), message: new Error("Loading stores has failed"),
meta: { stores, error, options }, meta: { stores, error, options: opts },
}); });
}); });
} else { } else {
subscribe(); subscribe();
} }
// reload when context namespaces changes // reload stores only for context namespaces change
cancelReloading = reaction(() => this.namespaces, () => { cancelReloading = reaction(() => this.context?.contextNamespaces, namespaces => {
preloading?.cancelLoading(); preloading?.cancelLoading();
preloading = load(); unsubscribeList.forEach(unsubscribe => unsubscribe());
unsubscribeList.length = 0;
preloading = load(namespaces);
preloading.loading.then(subscribe);
}, { }, {
equals: comparer.shallow, equals: comparer.shallow,
}); });
@ -190,184 +116,25 @@ export class KubeWatchApi {
cancelReloading(); cancelReloading();
preloading?.cancelLoading(); preloading?.cancelLoading();
unsubscribeList.forEach(unsubscribe => unsubscribe()); unsubscribeList.forEach(unsubscribe => unsubscribe());
unsubscribeList.length = 0;
}; };
} }
protected async connectionCheck() { protected log({ message, cssStyle = "", meta = {} }: IKubeWatchLog) {
if (!this.isConnected) {
this.log({ message: "Offline: reconnecting.." });
await this.connect();
}
this.log({
message: `Connection check: ${this.isConnected ? "online" : "offline"}`,
meta: { connected: this.isConnected },
});
}
protected async connect(apis = this.apis) {
this.disconnect(); // close active connections first
if (!navigator.onLine || !apis.length) {
this.isConnected = false;
return;
}
this.log({
message: "Connecting",
meta: { apis }
});
try {
const requestId = ++this.requestId;
const abortController = new AbortController();
const request = await fetch(`${apiPrefix}/watch`, {
method: "POST",
body: JSON.stringify({ apis } as IWatchRoutePayload),
signal: abortController.signal,
headers: {
"content-type": "application/json"
}
});
// request above is stale since new request-id has been issued
if (this.requestId !== requestId) {
abortController.abort();
return;
}
let jsonBuffer = "";
const stream = request.body.pipeThrough(new TextDecoderStream());
const reader = stream.getReader();
this.isConnected = true;
this.reader = reader;
while (true) {
const { done, value } = await reader.read();
if (done) break; // exit
const events = (jsonBuffer + value).split("\n");
jsonBuffer = this.processBuffer(events);
}
} catch (error) {
this.log({ message: error });
} finally {
this.isConnected = false;
}
}
protected disconnect() {
this.reader?.cancel();
this.reader = null;
this.isConnected = false;
}
// process received stream events, returns unprocessed buffer chunk if any
protected processBuffer(events: string[]): string {
for (const json of events) {
try {
const kubeEvent: IKubeWatchEvent = JSON.parse(json);
const message = this.getMessage(kubeEvent);
if (!this.namespaces.includes(message.namespace)) {
continue; // skip updates from non-watching resources context
}
this.onMessage.emit(message);
} catch (error) {
return json;
}
}
return "";
}
protected getMessage(event: IKubeWatchEvent): IKubeWatchMessage {
const message: IKubeWatchMessage = {};
switch (event.type) {
case "ADDED":
case "DELETED":
case "MODIFIED": {
const data = event as IKubeWatchEvent<KubeJsonApiData>;
const api = apiManager.getApiByKind(data.object.kind, data.object.apiVersion);
message.data = data;
if (api) {
ensureObjectSelfLink(api, data.object);
const { namespace, resourceVersion } = data.object.metadata;
api.setResourceVersion(namespace, resourceVersion);
api.setResourceVersion("", resourceVersion);
message.api = api;
message.store = apiManager.getStore(api);
message.namespace = namespace;
}
break;
}
case "ERROR":
message.error = event as IKubeWatchEvent<KubeJsonApiError>;
break;
case "STREAM_END": {
this.onServerStreamEnd(event as IKubeWatchEventStreamEnd, {
reconnectAttempts: 5,
timeout: 1000,
});
break;
}
}
return message;
}
protected async onServerStreamEnd(event: IKubeWatchEventStreamEnd, opts?: IKubeWatchReconnectOptions) {
const { apiBase, namespace } = parseKubeApi(event.url);
const api = apiManager.getApi(apiBase);
if (!api) return;
try {
await api.refreshResourceVersion({ namespace });
this.connect();
} catch (error) {
this.log({
message: new Error(`Failed to connect on single stream end: ${error}`),
meta: { event, error },
});
if (this.isActive && opts?.reconnectAttempts > 0) {
opts.reconnectAttempts--;
setTimeout(() => this.onServerStreamEnd(event, opts), opts.timeout); // repeat event
}
}
}
protected log({ message, meta = {} }: IKubeWatchLog) {
if (isProduction && !isDebugging) { if (isProduction && !isDebugging) {
return; return;
} }
const logMessage = `%c[KUBE-WATCH-API]: ${String(message).toUpperCase()}`; const logInfo = [`%c[KUBE-WATCH-API]:`, `font-weight: bold; ${cssStyle}`, message].flat().map(String);
const isError = message instanceof Error; const logMeta = {
const textStyle = `font-weight: bold;`; time: new Date().toLocaleString(),
const time = new Date().toLocaleString(); ...meta,
};
if (isError) { if (message instanceof Error) {
console.error(logMessage, textStyle, { time, ...meta }); console.error(...logInfo, logMeta);
} else { } else {
console.info(logMessage, textStyle, { time, ...meta }); console.info(...logInfo, logMeta);
} }
} }
} }

View File

@ -77,7 +77,8 @@ export class ReleaseRollbackDialog extends React.Component<Props> {
themeName="light" themeName="light"
value={revision} value={revision}
options={revisions} options={revisions}
formatOptionLabel={({ value }: SelectOption<IReleaseRevision>) => `${value.revision} - ${value.chart}`} formatOptionLabel={({ value }: SelectOption<IReleaseRevision>) => `${value.revision} - ${value.chart}
- ${value.app_version}, updated: ${new Date(value.updated).toLocaleString()}`}
onChange={({ value }: SelectOption<IReleaseRevision>) => this.revision = value} onChange={({ value }: SelectOption<IReleaseRevision>) => this.revision = value}
/> />
</div> </div>

View File

@ -73,8 +73,8 @@ export class ReleaseStore extends ItemStore<HelmRelease> {
} }
} }
async loadSelectedNamespaces(): Promise<void> { async loadFromContextNamespaces(): Promise<void> {
return this.loadAll(namespaceStore.getContextNamespaces()); return this.loadAll(namespaceStore.contextNamespaces);
} }
async loadItems(namespaces: string[]) { async loadItems(namespaces: string[]) {
@ -86,7 +86,7 @@ export class ReleaseStore extends ItemStore<HelmRelease> {
async create(payload: IReleaseCreatePayload) { async create(payload: IReleaseCreatePayload) {
const response = await helmReleasesApi.create(payload); const response = await helmReleasesApi.create(payload);
if (this.isLoaded) this.loadSelectedNamespaces(); if (this.isLoaded) this.loadFromContextNamespaces();
return response; return response;
} }
@ -94,7 +94,7 @@ export class ReleaseStore extends ItemStore<HelmRelease> {
async update(name: string, namespace: string, payload: IReleaseUpdatePayload) { async update(name: string, namespace: string, payload: IReleaseUpdatePayload) {
const response = await helmReleasesApi.update(name, namespace, payload); const response = await helmReleasesApi.update(name, namespace, payload);
if (this.isLoaded) this.loadSelectedNamespaces(); if (this.isLoaded) this.loadFromContextNamespaces();
return response; return response;
} }
@ -102,7 +102,7 @@ export class ReleaseStore extends ItemStore<HelmRelease> {
async rollback(name: string, namespace: string, revision: number) { async rollback(name: string, namespace: string, revision: number) {
const response = await helmReleasesApi.rollback(name, namespace, revision); const response = await helmReleasesApi.rollback(name, namespace, revision);
if (this.isLoaded) this.loadSelectedNamespaces(); if (this.isLoaded) this.loadFromContextNamespaces();
return response; return response;
} }

View File

@ -30,7 +30,7 @@ export class CrdResources extends React.Component<Props> {
const { store } = this; const { store } = this;
if (store && !store.isLoading && !store.isLoaded) { if (store && !store.isLoading && !store.isLoaded) {
store.loadSelectedNamespaces(); store.reloadAll();
} }
}) })
]); ]);
@ -97,7 +97,7 @@ export class CrdResources extends React.Component<Props> {
...extraColumns.map((column) => { ...extraColumns.map((column) => {
let value = jsonPath.value(crdInstance, parseJsonPath(column.jsonPath.slice(1))); let value = jsonPath.value(crdInstance, parseJsonPath(column.jsonPath.slice(1)));
if (Array.isArray(value) || typeof value === "object") { if (Array.isArray(value) || typeof value === "object") {
value = JSON.stringify(value); value = JSON.stringify(value);
} }

View File

@ -14,7 +14,7 @@ export interface KubeEventDetailsProps {
@observer @observer
export class KubeEventDetails extends React.Component<KubeEventDetailsProps> { export class KubeEventDetails extends React.Component<KubeEventDetailsProps> {
async componentDidMount() { async componentDidMount() {
eventStore.loadSelectedNamespaces(); eventStore.reloadAll();
} }
render() { render() {

View File

@ -32,8 +32,8 @@ export class NamespaceDetails extends React.Component<Props> {
} }
componentDidMount() { componentDidMount() {
resourceQuotaStore.loadSelectedNamespaces(); resourceQuotaStore.reloadAll();
limitRangeStore.loadSelectedNamespaces(); limitRangeStore.reloadAll();
} }
render() { render() {

View File

@ -82,7 +82,7 @@ export class NamespaceSelect extends React.Component<Props> {
@observer @observer
export class NamespaceSelectFilter extends React.Component { export class NamespaceSelectFilter extends React.Component {
@computed get placeholder(): React.ReactNode { @computed get placeholder(): React.ReactNode {
const namespaces = namespaceStore.getContextNamespaces(); const namespaces = namespaceStore.contextNamespaces;
switch (namespaces.length) { switch (namespaces.length) {
case 0: case 0:

View File

@ -1,10 +1,9 @@
import { action, comparer, computed, IReactionDisposer, IReactionOptions, observable, reaction, toJS, when } from "mobx"; import { action, comparer, computed, IReactionDisposer, IReactionOptions, observable, reaction } from "mobx";
import { autobind, createStorage } from "../../utils"; import { autobind, createStorage } from "../../utils";
import { KubeObjectStore, KubeObjectStoreLoadingParams } from "../../kube-object.store"; import { KubeObjectStore, KubeObjectStoreLoadingParams } from "../../kube-object.store";
import { Namespace, namespacesApi } from "../../api/endpoints/namespaces.api"; import { Namespace, namespacesApi } from "../../api/endpoints/namespaces.api";
import { createPageParam } from "../../navigation"; import { createPageParam } from "../../navigation";
import { apiManager } from "../../api/api-manager"; import { apiManager } from "../../api/api-manager";
import { clusterStore, getHostedCluster } from "../../../common/cluster-store";
const storage = createStorage<string[]>("context_namespaces", []); const storage = createStorage<string[]>("context_namespaces", []);
@ -35,9 +34,6 @@ export class NamespaceStore extends KubeObjectStore<Namespace> {
api = namespacesApi; api = namespacesApi;
@observable private contextNs = observable.set<string>(); @observable private contextNs = observable.set<string>();
@observable isReady = false;
whenReady = when(() => this.isReady);
constructor() { constructor() {
super(); super();
@ -45,15 +41,11 @@ export class NamespaceStore extends KubeObjectStore<Namespace> {
} }
private async init() { private async init() {
await clusterStore.whenLoaded; await this.contextReady;
if (!getHostedCluster()) return;
await getHostedCluster().whenReady; // wait for cluster-state from main
this.setContext(this.initialNamespaces); this.setContext(this.initialNamespaces);
this.autoLoadAllowedNamespaces(); this.autoLoadAllowedNamespaces();
this.autoUpdateUrlAndLocalStorage(); this.autoUpdateUrlAndLocalStorage();
this.isReady = true;
} }
public onContextChange(callback: (contextNamespaces: string[]) => void, opts: IReactionOptions = {}): IReactionDisposer { public onContextChange(callback: (contextNamespaces: string[]) => void, opts: IReactionOptions = {}): IReactionDisposer {
@ -73,16 +65,12 @@ export class NamespaceStore extends KubeObjectStore<Namespace> {
} }
private autoLoadAllowedNamespaces(): IReactionDisposer { private autoLoadAllowedNamespaces(): IReactionDisposer {
return reaction(() => this.allowedNamespaces, namespaces => this.loadAll(namespaces), { return reaction(() => this.allowedNamespaces, namespaces => this.loadAll({ namespaces }), {
fireImmediately: true, fireImmediately: true,
equals: comparer.shallow, equals: comparer.shallow,
}); });
} }
@computed get allowedNamespaces(): string[] {
return toJS(getHostedCluster().allowedNamespaces);
}
@computed @computed
private get initialNamespaces(): string[] { private get initialNamespaces(): string[] {
const namespaces = new Set(this.allowedNamespaces); const namespaces = new Set(this.allowedNamespaces);
@ -103,27 +91,26 @@ export class NamespaceStore extends KubeObjectStore<Namespace> {
return []; return [];
} }
getContextNamespaces(): string[] { @computed get allowedNamespaces(): string[] {
return Array.from(new Set([
...(this.context?.allNamespaces ?? []), // allowed namespaces from cluster (main), updating every 30s
...this.items.map(item => item.getName()), // loaded namespaces from k8s api
].flat()));
}
@computed get contextNamespaces(): string[] {
const namespaces = Array.from(this.contextNs); const namespaces = Array.from(this.contextNs);
// show all namespaces when nothing selected
if (!namespaces.length) { if (!namespaces.length) {
// return actual namespaces list since "allowedNamespaces" updating every 30s in cluster and thus might be stale return this.allowedNamespaces; // show all namespaces when nothing selected
if (this.isLoaded) {
return this.items.map(namespace => namespace.getName());
}
return this.allowedNamespaces;
} }
return namespaces; return namespaces;
} }
getSubscribeApis() { getSubscribeApis() {
const { accessibleNamespaces } = getHostedCluster();
// if user has given static list of namespaces let's not start watches because watch adds stuff that's not wanted // if user has given static list of namespaces let's not start watches because watch adds stuff that's not wanted
if (accessibleNamespaces.length > 0) { if (this.context?.cluster.accessibleNamespaces.length > 0) {
return []; return [];
} }

View File

@ -29,7 +29,7 @@ export class NodeDetails extends React.Component<Props> {
}); });
async componentDidMount() { async componentDidMount() {
podsStore.loadSelectedNamespaces(); podsStore.reloadAll();
} }
componentWillUnmount() { componentWillUnmount() {

View File

@ -7,7 +7,7 @@ import { Dialog, DialogProps } from "../dialog";
import { Wizard, WizardStep } from "../wizard"; import { Wizard, WizardStep } from "../wizard";
import { Select, SelectOption } from "../select"; import { Select, SelectOption } from "../select";
import { SubTitle } from "../layout/sub-title"; import { SubTitle } from "../layout/sub-title";
import { IRoleBindingSubject, RoleBinding, ServiceAccount, Role } from "../../api/endpoints"; import { IRoleBindingSubject, Role, RoleBinding, ServiceAccount } from "../../api/endpoints";
import { Icon } from "../icon"; import { Icon } from "../icon";
import { Input } from "../input"; import { Input } from "../input";
import { NamespaceSelect } from "../+namespaces/namespace-select"; import { NamespaceSelect } from "../+namespaces/namespace-select";
@ -19,6 +19,7 @@ import { namespaceStore } from "../+namespaces/namespace.store";
import { serviceAccountsStore } from "../+user-management-service-accounts/service-accounts.store"; import { serviceAccountsStore } from "../+user-management-service-accounts/service-accounts.store";
import { roleBindingsStore } from "./role-bindings.store"; import { roleBindingsStore } from "./role-bindings.store";
import { showDetails } from "../kube-object"; import { showDetails } from "../kube-object";
import { KubeObjectStore } from "../../kube-object.store";
interface BindingSelectOption extends SelectOption { interface BindingSelectOption extends SelectOption {
value: string; // binding name value: string; // binding name
@ -73,14 +74,14 @@ export class AddRoleBindingDialog extends React.Component<Props> {
}; };
async loadData() { async loadData() {
const stores = [ const stores: KubeObjectStore[] = [
namespaceStore, namespaceStore,
rolesStore, rolesStore,
serviceAccountsStore, serviceAccountsStore,
]; ];
this.isLoading = true; this.isLoading = true;
await Promise.all(stores.map(store => store.loadSelectedNamespaces())); await Promise.all(stores.map(store => store.reloadAll()));
this.isLoading = false; this.isLoading = false;
} }
@ -136,8 +137,7 @@ export class AddRoleBindingDialog extends React.Component<Props> {
roleBinding: this.roleBinding, roleBinding: this.roleBinding,
addSubjects: subjects, addSubjects: subjects,
}); });
} } else {
else {
const name = useRoleForBindingName ? selectedRole.getName() : bindingName; const name = useRoleForBindingName ? selectedRole.getName() : bindingName;
roleBinding = await roleBindingsStore.create({ name, namespace }, { roleBinding = await roleBindingsStore.create({ name, namespace }, {
@ -265,7 +265,7 @@ export class AddRoleBindingDialog extends React.Component<Props> {
</h5> </h5>
); );
const disableNext = this.isLoading || !selectedRole || !selectedBindings.length; const disableNext = this.isLoading || !selectedRole || !selectedBindings.length;
const nextLabel = isEditing ? "Update" : "Create"; const nextLabel = isEditing ? "Update" : "Create";
return ( return (
<Dialog <Dialog

View File

@ -20,7 +20,7 @@ interface Props extends KubeObjectDetailsProps<CronJob> {
@observer @observer
export class CronJobDetails extends React.Component<Props> { export class CronJobDetails extends React.Component<Props> {
async componentDidMount() { async componentDidMount() {
jobStore.loadSelectedNamespaces(); jobStore.reloadAll();
} }
render() { render() {

View File

@ -30,7 +30,7 @@ export class DaemonSetDetails extends React.Component<Props> {
}); });
componentDidMount() { componentDidMount() {
podsStore.loadSelectedNamespaces(); podsStore.reloadAll();
} }
componentWillUnmount() { componentWillUnmount() {

View File

@ -31,7 +31,7 @@ export class DeploymentDetails extends React.Component<Props> {
}); });
componentDidMount() { componentDidMount() {
podsStore.loadSelectedNamespaces(); podsStore.reloadAll();
} }
componentWillUnmount() { componentWillUnmount() {

View File

@ -25,7 +25,7 @@ interface Props extends KubeObjectDetailsProps<Job> {
@observer @observer
export class JobDetails extends React.Component<Props> { export class JobDetails extends React.Component<Props> {
async componentDidMount() { async componentDidMount() {
podsStore.loadSelectedNamespaces(); podsStore.reloadAll();
} }
render() { render() {

View File

@ -6,10 +6,9 @@ import { OverviewWorkloadStatus } from "./overview-workload-status";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { workloadURL, workloadStores } from "../+workloads"; import { workloadURL, workloadStores } from "../+workloads";
import { namespaceStore } from "../+namespaces/namespace.store"; import { namespaceStore } from "../+namespaces/namespace.store";
import { PageFiltersList } from "../item-object-list/page-filters-list";
import { NamespaceSelectFilter } from "../+namespaces/namespace-select"; import { NamespaceSelectFilter } from "../+namespaces/namespace-select";
import { isAllowedResource, KubeResource } from "../../../common/rbac"; import { isAllowedResource, KubeResource } from "../../../common/rbac";
import { ResourceNames } from "../../../renderer/utils/rbac"; import { ResourceNames } from "../../utils/rbac";
import { autobind } from "../../utils"; import { autobind } from "../../utils";
const resources: KubeResource[] = [ const resources: KubeResource[] = [
@ -27,7 +26,7 @@ export class OverviewStatuses extends React.Component {
@autobind() @autobind()
renderWorkload(resource: KubeResource): React.ReactElement { renderWorkload(resource: KubeResource): React.ReactElement {
const store = workloadStores[resource]; const store = workloadStores[resource];
const items = store.getAllByNs(namespaceStore.getContextNamespaces()); const items = store.getAllByNs(namespaceStore.contextNamespaces);
return ( return (
<div className="workload" key={resource}> <div className="workload" key={resource}>
@ -50,7 +49,6 @@ export class OverviewStatuses extends React.Component {
<h5 className="box grow">Overview</h5> <h5 className="box grow">Overview</h5>
<NamespaceSelectFilter /> <NamespaceSelectFilter />
</div> </div>
<PageFiltersList />
<div className="workloads"> <div className="workloads">
{workloads} {workloads}
</div> </div>

View File

@ -16,6 +16,7 @@ import { cronJobStore } from "../+workloads-cronjobs/cronjob.store";
import { Events } from "../+events"; import { Events } from "../+events";
import { isAllowedResource } from "../../../common/rbac"; import { isAllowedResource } from "../../../common/rbac";
import { kubeWatchApi } from "../../api/kube-watch-api"; import { kubeWatchApi } from "../../api/kube-watch-api";
import { clusterContext } from "../context";
interface Props extends RouteComponentProps<IWorkloadsOverviewRouteParams> { interface Props extends RouteComponentProps<IWorkloadsOverviewRouteParams> {
} }
@ -29,6 +30,7 @@ export class WorkloadsOverview extends React.Component<Props> {
jobStore, cronJobStore, eventStore, jobStore, cronJobStore, eventStore,
], { ], {
preload: true, preload: true,
namespaces: clusterContext.contextNamespaces,
}), }),
]); ]);
} }

View File

@ -29,7 +29,7 @@ export class ReplicaSetDetails extends React.Component<Props> {
}); });
async componentDidMount() { async componentDidMount() {
podsStore.loadSelectedNamespaces(); podsStore.reloadAll();
} }
componentWillUnmount() { componentWillUnmount() {

View File

@ -30,7 +30,7 @@ export class StatefulSetDetails extends React.Component<Props> {
}); });
componentDidMount() { componentDidMount() {
podsStore.loadSelectedNamespaces(); podsStore.reloadAll();
} }
componentWillUnmount() { componentWillUnmount() {

View File

@ -43,12 +43,13 @@ import { ClusterPageMenuRegistration, clusterPageMenuRegistry } from "../../exte
import { TabLayout, TabLayoutRoute } from "./layout/tab-layout"; import { TabLayout, TabLayoutRoute } from "./layout/tab-layout";
import { StatefulSetScaleDialog } from "./+workloads-statefulsets/statefulset-scale-dialog"; import { StatefulSetScaleDialog } from "./+workloads-statefulsets/statefulset-scale-dialog";
import { eventStore } from "./+events/event.store"; import { eventStore } from "./+events/event.store";
import { namespaceStore } from "./+namespaces/namespace.store";
import { nodesStore } from "./+nodes/nodes.store"; import { nodesStore } from "./+nodes/nodes.store";
import { podsStore } from "./+workloads-pods/pods.store"; import { podsStore } from "./+workloads-pods/pods.store";
import { kubeWatchApi } from "../api/kube-watch-api"; import { kubeWatchApi } from "../api/kube-watch-api";
import { ReplicaSetScaleDialog } from "./+workloads-replicasets/replicaset-scale-dialog"; import { ReplicaSetScaleDialog } from "./+workloads-replicasets/replicaset-scale-dialog";
import { CommandContainer } from "./command-palette/command-container"; import { CommandContainer } from "./command-palette/command-container";
import { KubeObjectStore } from "../kube-object.store";
import { clusterContext } from "./context";
@observer @observer
export class App extends React.Component { export class App extends React.Component {
@ -76,11 +77,9 @@ export class App extends React.Component {
}); });
whatInput.ask(); // Start to monitor user input device whatInput.ask(); // Start to monitor user input device
await namespaceStore.whenReady; // Setup hosted cluster context
await kubeWatchApi.init({ KubeObjectStore.defaultContext = clusterContext;
getCluster: getHostedCluster, kubeWatchApi.context = clusterContext;
getNamespaces: namespaceStore.getContextNamespaces,
});
} }
componentDidMount() { componentDidMount() {
@ -163,9 +162,9 @@ export class App extends React.Component {
const tabRoutes = this.getTabLayoutRoutes(menu); const tabRoutes = this.getTabLayoutRoutes(menu);
if (tabRoutes.length > 0) { if (tabRoutes.length > 0) {
const pageComponent = () => <TabLayout tabs={tabRoutes} />; const pageComponent = () => <TabLayout tabs={tabRoutes}/>;
route = <Route key={`extension-tab-layout-route-${index}`} component={pageComponent} path={tabRoutes.map((tab) => tab.routePath)} />; route = <Route key={`extension-tab-layout-route-${index}`} component={pageComponent} path={tabRoutes.map((tab) => tab.routePath)}/>;
this.extensionRoutes.set(menu, route); this.extensionRoutes.set(menu, route);
} else { } else {
const page = clusterPageRegistry.getByPageTarget(menu.target); const page = clusterPageRegistry.getByPageTarget(menu.target);
@ -229,7 +228,7 @@ export class App extends React.Component {
<StatefulSetScaleDialog/> <StatefulSetScaleDialog/>
<ReplicaSetScaleDialog/> <ReplicaSetScaleDialog/>
<CronJobTriggerDialog/> <CronJobTriggerDialog/>
<CommandContainer cluster={cluster} /> <CommandContainer cluster={cluster}/>
</ErrorBoundary> </ErrorBoundary>
</Router> </Router>
); );

View File

@ -21,10 +21,16 @@
&.primary { &.primary {
background: $buttonPrimaryBackground; background: $buttonPrimaryBackground;
} }
&.accent { &.accent {
background: $buttonAccentBackground; background: $buttonAccentBackground;
} }
&.light {
background-color: $buttonLightBackground;
color: #505050;
}
&.plain { &.plain {
color: inherit; color: inherit;
background: transparent; background: transparent;

View File

@ -8,6 +8,7 @@ export interface ButtonProps extends ButtonHTMLAttributes<any>, TooltipDecorator
waiting?: boolean; waiting?: boolean;
primary?: boolean; primary?: boolean;
accent?: boolean; accent?: boolean;
light?: boolean;
plain?: boolean; plain?: boolean;
outlined?: boolean; outlined?: boolean;
hidden?: boolean; hidden?: boolean;
@ -24,13 +25,16 @@ export class Button extends React.PureComponent<ButtonProps, {}> {
private button: HTMLButtonElement; private button: HTMLButtonElement;
render() { render() {
const { className, waiting, label, primary, accent, plain, hidden, active, big, round, outlined, tooltip, children, ...props } = this.props; const {
const btnProps = props as Partial<ButtonProps>; className, waiting, label, primary, accent, plain, hidden, active, big,
round, outlined, tooltip, light, children, ...props
} = this.props;
const btnProps: Partial<ButtonProps> = props;
if (hidden) return null; if (hidden) return null;
btnProps.className = cssNames("Button", className, { btnProps.className = cssNames("Button", className, {
waiting, primary, accent, plain, active, big, round, outlined waiting, primary, accent, plain, active, big, round, outlined, light,
}); });
const btnContent: ReactNode = ( const btnContent: ReactNode = (

View File

@ -0,0 +1,23 @@
import type { Cluster } from "../../main/cluster";
import { getHostedCluster } from "../../common/cluster-store";
import { namespaceStore } from "./+namespaces/namespace.store";
export interface ClusterContext {
cluster?: Cluster;
allNamespaces?: string[]; // available / allowed namespaces from cluster.ts
contextNamespaces?: string[]; // selected by user (see: namespace-select.tsx)
}
export const clusterContext: ClusterContext = {
get cluster(): Cluster | null {
return getHostedCluster();
},
get allNamespaces(): string[] {
return this.cluster?.allowedNamespaces ?? [];
},
get contextNamespaces(): string[] {
return namespaceStore.contextNamespaces ?? [];
},
};

View File

@ -146,11 +146,10 @@ export class Dialog extends React.PureComponent<DialogProps, DialogState> {
{dialog} {dialog}
</Animate> </Animate>
); );
} } else if (!this.isOpen) {
else if (!this.isOpen) {
return null; return null;
} }
return createPortal(dialog, document.body); return createPortal(dialog, document.body) as React.ReactPortal;
} }
} }

View File

@ -80,7 +80,7 @@ export class UpgradeChartStore extends DockTabStore<IChartUpgradeData> {
const values = this.values.getData(tabId); const values = this.values.getData(tabId);
await Promise.all([ await Promise.all([
!releaseStore.isLoaded && releaseStore.loadSelectedNamespaces(), !releaseStore.isLoaded && releaseStore.loadFromContextNamespaces(),
!values && this.loadValues(tabId) !values && this.loadValues(tabId)
]); ]);
} }

View File

@ -38,6 +38,7 @@ interface IHeaderPlaceholders {
export interface ItemListLayoutProps<T extends ItemObject = ItemObject> { export interface ItemListLayoutProps<T extends ItemObject = ItemObject> {
tableId?: string; tableId?: string;
className: IClassName; className: IClassName;
items?: T[];
store: ItemStore<T>; store: ItemStore<T>;
dependentStores?: ItemStore[]; dependentStores?: ItemStore[];
preloadStores?: boolean; preloadStores?: boolean;
@ -138,7 +139,8 @@ export class ItemListLayout extends React.Component<ItemListLayoutProps> {
const { store, dependentStores } = this.props; const { store, dependentStores } = this.props;
const stores = Array.from(new Set([store, ...dependentStores])); const stores = Array.from(new Set([store, ...dependentStores]));
stores.forEach(store => store.loadAll(namespaceStore.getContextNamespaces())); // load context namespaces by default (see also: `<NamespaceSelectFilter/>`)
stores.forEach(store => store.loadAll(namespaceStore.contextNamespaces));
} }
private filterCallbacks: { [type: string]: ItemsFilter } = { private filterCallbacks: { [type: string]: ItemsFilter } = {
@ -179,11 +181,7 @@ export class ItemListLayout extends React.Component<ItemListLayoutProps> {
@computed get filters() { @computed get filters() {
let { activeFilters } = pageFilters; let { activeFilters } = pageFilters;
const { isClusterScoped, isSearchable, searchFilters } = this.props; const { isSearchable, searchFilters } = this.props;
if (isClusterScoped) {
activeFilters = activeFilters.filter(({ type }) => type !== FilterType.NAMESPACE);
}
if (!(isSearchable && searchFilters)) { if (!(isSearchable && searchFilters)) {
activeFilters = activeFilters.filter(({ type }) => type !== FilterType.SEARCH); activeFilters = activeFilters.filter(({ type }) => type !== FilterType.SEARCH);
@ -217,7 +215,9 @@ export class ItemListLayout extends React.Component<ItemListLayoutProps> {
} }
}); });
return this.applyFilters(filterItems, allItems); const items = this.props.items ?? allItems;
return this.applyFilters(filterItems, items);
} }
@autobind() @autobind()
@ -337,8 +337,8 @@ export class ItemListLayout extends React.Component<ItemListLayoutProps> {
} }
renderInfo() { renderInfo() {
const { allItems, items, isReady, userSettings, filters } = this; const { items, isReady, userSettings, filters } = this;
const allItemsCount = allItems.length; const allItemsCount = this.props.store.getTotalCount();
const itemsCount = items.length; const itemsCount = items.length;
const isFiltered = isReady && filters.length > 0; const isFiltered = isReady && filters.length > 0;

View File

@ -1,6 +1,5 @@
import { computed, observable, reaction } from "mobx"; import { computed, observable, reaction } from "mobx";
import { autobind } from "../../utils"; import { autobind } from "../../utils";
import { namespaceStore } from "../+namespaces/namespace.store";
import { searchUrlParam } from "../input/search-input-url"; import { searchUrlParam } from "../input/search-input-url";
export enum FilterType { export enum FilterType {
@ -24,32 +23,6 @@ export class PageFiltersStore {
constructor() { constructor() {
this.syncWithGlobalSearch(); this.syncWithGlobalSearch();
this.syncWithContextNamespace();
}
protected syncWithContextNamespace() {
const disposers = [
reaction(() => this.getValues(FilterType.NAMESPACE), filteredNs => {
if (filteredNs.length !== namespaceStore.getContextNamespaces().length) {
namespaceStore.setContext(filteredNs);
}
}),
namespaceStore.onContextChange(namespaces => {
const filteredNs = this.getValues(FilterType.NAMESPACE);
const isChanged = namespaces.length !== filteredNs.length;
if (isChanged) {
this.filters.replace([
...this.filters.filter(({ type }) => type !== FilterType.NAMESPACE),
...namespaces.map(ns => ({ type: FilterType.NAMESPACE, value: ns })),
]);
}
}, {
fireImmediately: true
})
];
return () => disposers.forEach(dispose => dispose());
} }
protected syncWithGlobalSearch() { protected syncWithGlobalSearch() {

View File

@ -8,6 +8,7 @@ import { KubeObjectStore } from "../../kube-object.store";
import { KubeObjectMenu } from "./kube-object-menu"; import { KubeObjectMenu } from "./kube-object-menu";
import { kubeSelectedUrlParam, showDetails } from "./kube-object-details"; import { kubeSelectedUrlParam, showDetails } from "./kube-object-details";
import { kubeWatchApi } from "../../api/kube-watch-api"; import { kubeWatchApi } from "../../api/kube-watch-api";
import { clusterContext } from "../context";
export interface KubeObjectListLayoutProps extends ItemListLayoutProps { export interface KubeObjectListLayoutProps extends ItemListLayoutProps {
store: KubeObjectStore; store: KubeObjectStore;
@ -26,7 +27,8 @@ export class KubeObjectListLayout extends React.Component<KubeObjectListLayoutPr
disposeOnUnmount(this, [ disposeOnUnmount(this, [
kubeWatchApi.subscribeStores(stores, { kubeWatchApi.subscribeStores(stores, {
preload: true preload: true,
namespaces: clusterContext.contextNamespaces,
}) })
]); ]);
} }
@ -40,12 +42,14 @@ export class KubeObjectListLayout extends React.Component<KubeObjectListLayoutPr
}; };
render() { render() {
const items = this.props.store.contextItems;
const { className, ...layoutProps } = this.props; const { className, ...layoutProps } = this.props;
return ( return (
<ItemListLayout <ItemListLayout
{...layoutProps} {...layoutProps}
className={cssNames("KubeObjectListLayout", className)} className={cssNames("KubeObjectListLayout", className)}
items={items}
preloadStores={false} // loading handled in kubeWatchApi.subscribeStores() preloadStores={false} // loading handled in kubeWatchApi.subscribeStores()
detailsItem={this.selectedItem} detailsItem={this.selectedItem}
onDetails={this.onDetails} onDetails={this.onDetails}

View File

@ -40,7 +40,7 @@ interface Props {
@observer @observer
export class Sidebar extends React.Component<Props> { export class Sidebar extends React.Component<Props> {
async componentDidMount() { async componentDidMount() {
crdStore.loadSelectedNamespaces(); crdStore.reloadAll();
} }
renderCustomResources() { renderCustomResources() {

View File

@ -42,5 +42,9 @@
box-shadow: 0 0 20px $boxShadow; box-shadow: 0 0 20px $boxShadow;
} }
} }
.close {
margin-top: -2px;
}
} }
} }

View File

@ -18,6 +18,7 @@ export interface Notification {
message: NotificationMessage; message: NotificationMessage;
status?: NotificationStatus; status?: NotificationStatus;
timeout?: number; // auto-hiding timeout in milliseconds, 0 = no hide timeout?: number; // auto-hiding timeout in milliseconds, 0 = no hide
onClose?(): void; // additonal logic on when the notification times out or is closed by the "x"
} }
@autobind() @autobind()

View File

@ -72,23 +72,26 @@ export class Notifications extends React.Component {
return ( return (
<div className="Notifications flex column align-flex-end" ref={e => this.elem = e}> <div className="Notifications flex column align-flex-end" ref={e => this.elem = e}>
{notifications.map(notification => { {notifications.map(notification => {
const { id, status } = notification; const { id, status, onClose } = notification;
const msgText = this.getMessage(notification); const msgText = this.getMessage(notification);
return ( return (
<Animate key={id}> <Animate key={id}>
<div <div
className={cssNames("notification flex align-center", status)} className={cssNames("notification flex", status)}
onMouseLeave={() => addAutoHideTimer(id)} onMouseLeave={() => addAutoHideTimer(id)}
onMouseEnter={() => removeAutoHideTimer(id)}> onMouseEnter={() => removeAutoHideTimer(id)}>
<div className="box center"> <div className="box">
<Icon material="info_outline"/> <Icon material="info_outline"/>
</div> </div>
<div className="message box grow">{msgText}</div> <div className="message box grow">{msgText}</div>
<div className="box center"> <div className="box">
<Icon <Icon
material="close" className="close" material="close" className="close"
onClick={prevDefault(() => remove(id))} onClick={prevDefault(() => {
remove(id);
onClose?.();
})}
/> />
</div> </div>
</div> </div>

View File

@ -0,0 +1,61 @@
import React from "react";
import { ipcRenderer, IpcRendererEvent } from "electron";
import { areArgsUpdateAvailableFromMain, UpdateAvailableChannel, onCorrect, UpdateAvailableFromMain, BackchannelArg } from "../../common/ipc";
import { Notifications, notificationsStore } from "../components/notifications";
import { Button } from "../components/button";
import { isMac } from "../../common/vars";
import * as uuid from "uuid";
function sendToBackchannel(backchannel: string, notificationId: string, data: BackchannelArg): void {
notificationsStore.remove(notificationId);
ipcRenderer.send(backchannel, data);
}
function RenderYesButtons(props: { backchannel: string, notificationId: string }) {
if (isMac) {
/**
* auto-updater's "installOnQuit" is not applicable for macOS as per their docs.
*
* See: https://github.com/electron-userland/electron-builder/blob/master/packages/electron-updater/src/AppUpdater.ts#L27-L32
*/
return <Button light label="Yes" onClick={() => sendToBackchannel(props.backchannel, props.notificationId, { doUpdate: true, now: true })} />;
}
return (
<>
<Button light label="Yes, now" onClick={() => sendToBackchannel(props.backchannel, props.notificationId, { doUpdate: true, now: true })} />
<Button active outlined label="Yes, later" onClick={() => sendToBackchannel(props.backchannel, props.notificationId, { doUpdate: true, now: false })} />
</>
);
}
function UpdateAvailableHandler(event: IpcRendererEvent, ...[backchannel, updateInfo]: UpdateAvailableFromMain): void {
const notificationId = uuid.v4();
Notifications.info(
(
<>
<b>Update Available</b>
<p>Version {updateInfo.version} of Lens IDE is now available. Would you like to update?</p>
<div className="flex gaps row align-left box grow">
<RenderYesButtons backchannel={backchannel} notificationId={notificationId} />
<Button active outlined label="No" onClick={() => sendToBackchannel(backchannel, notificationId, { doUpdate: false })} />
</div>
</>
), {
id: notificationId,
onClose() {
sendToBackchannel(backchannel, notificationId, { doUpdate: false });
}
}
);
}
export function registerIpcHandlers() {
onCorrect({
source: ipcRenderer,
channel: UpdateAvailableChannel,
listener: UpdateAvailableHandler,
verifier: areArgsUpdateAvailableFromMain,
});
}

View File

@ -9,7 +9,7 @@ export interface ItemObject {
@autobind() @autobind()
export abstract class ItemStore<T extends ItemObject = ItemObject> { export abstract class ItemStore<T extends ItemObject = ItemObject> {
abstract loadAll(...args: any[]): Promise<void>; abstract loadAll(...args: any[]): Promise<void | T[]>;
protected defaultSorting = (item: T) => item.getName(); protected defaultSorting = (item: T) => item.getName();
@ -22,11 +22,23 @@ export abstract class ItemStore<T extends ItemObject = ItemObject> {
return this.items.filter(item => this.selectedItemsIds.get(item.getId())); return this.items.filter(item => this.selectedItemsIds.get(item.getId()));
} }
public getItems(): T[] {
return this.items.toJS();
}
public getTotalCount(): number {
return this.items.length;
}
getByName(name: string, ...args: any[]): T; getByName(name: string, ...args: any[]): T;
getByName(name: string): T { getByName(name: string): T {
return this.items.find(item => item.getName() === name); return this.items.find(item => item.getName() === name);
} }
getIndexById(id: string): number {
return this.items.findIndex(item => item.getId() === id);
}
@action @action
protected sortItems(items: T[] = this.items, sorting?: ((item: T) => any)[], order?: "asc" | "desc"): T[] { protected sortItems(items: T[] = this.items, sorting?: ((item: T) => any)[], order?: "asc" | "desc"): T[] {
return orderBy(items, sorting || this.defaultSorting, order); return orderBy(items, sorting || this.defaultSorting, order);

View File

@ -1,8 +1,9 @@
import type { Cluster } from "../main/cluster"; import type { ClusterContext } from "./components/context";
import { action, observable, reaction } from "mobx";
import { action, computed, observable, reaction, when } from "mobx";
import { autobind } from "./utils"; import { autobind } from "./utils";
import { KubeObject } from "./api/kube-object"; import { KubeObject } from "./api/kube-object";
import { IKubeWatchEvent, IKubeWatchMessage, kubeWatchApi } from "./api/kube-watch-api"; import { IKubeWatchEvent } from "./api/kube-watch-api";
import { ItemStore } from "./item.store"; import { ItemStore } from "./item.store";
import { apiManager } from "./api/api-manager"; import { apiManager } from "./api/api-manager";
import { IKubeApiQueryParams, KubeApi, parseKubeApi } from "./api/kube-api"; import { IKubeApiQueryParams, KubeApi, parseKubeApi } from "./api/kube-api";
@ -15,15 +16,38 @@ export interface KubeObjectStoreLoadingParams {
@autobind() @autobind()
export abstract class KubeObjectStore<T extends KubeObject = any> extends ItemStore<T> { export abstract class KubeObjectStore<T extends KubeObject = any> extends ItemStore<T> {
@observable static defaultContext: ClusterContext; // TODO: support multiple cluster contexts
abstract api: KubeApi<T>; abstract api: KubeApi<T>;
public readonly limit?: number; public readonly limit?: number;
public readonly bufferSize: number = 50000; public readonly bufferSize: number = 50000;
private loadedNamespaces: string[] = [];
contextReady = when(() => Boolean(this.context));
constructor() { constructor() {
super(); super();
this.bindWatchEventsUpdater(); this.bindWatchEventsUpdater();
} }
get context(): ClusterContext {
return KubeObjectStore.defaultContext;
}
@computed get contextItems(): T[] {
const namespaces = this.context?.contextNamespaces ?? [];
return this.items.filter(item => {
const itemNamespace = item.getNs();
return !itemNamespace /* cluster-wide */ || namespaces.includes(itemNamespace);
});
}
getTotalCount(): number {
return this.contextItems.length;
}
get query(): IKubeApiQueryParams { get query(): IKubeApiQueryParams {
const { limit } = this; const { limit } = this;
@ -79,23 +103,25 @@ export abstract class KubeObjectStore<T extends KubeObject = any> extends ItemSt
} }
} }
protected async resolveCluster(): Promise<Cluster> {
const { getHostedCluster } = await import("../common/cluster-store");
return getHostedCluster();
}
protected async loadItems({ namespaces, api }: KubeObjectStoreLoadingParams): Promise<T[]> { protected async loadItems({ namespaces, api }: KubeObjectStoreLoadingParams): Promise<T[]> {
const cluster = await this.resolveCluster(); if (this.context?.cluster.isAllowedResource(api.kind)) {
if (!api.isNamespaced) {
return api.list({}, this.query);
}
if (cluster.isAllowedResource(api.kind)) { const isLoadingAll = this.context.allNamespaces.every(ns => namespaces.includes(ns));
if (api.isNamespaced) {
return Promise if (isLoadingAll) {
this.loadedNamespaces = [];
return api.list({}, this.query);
} else {
this.loadedNamespaces = namespaces;
return Promise // load resources per namespace
.all(namespaces.map(namespace => api.list({ namespace }))) .all(namespaces.map(namespace => api.list({ namespace })))
.then(items => items.flat()); .then(items => items.flat());
} }
return api.list({}, this.query);
} }
return []; return [];
@ -106,24 +132,25 @@ export abstract class KubeObjectStore<T extends KubeObject = any> extends ItemSt
} }
@action @action
async loadAll(namespaces: string[] = []): Promise<void> { async loadAll(options: { namespaces?: string[], merge?: boolean } = {}): Promise<void | T[]> {
await this.contextReady;
this.isLoading = true; this.isLoading = true;
try { try {
if (!namespaces.length) { const {
const { namespaceStore } = await import("./components/+namespaces/namespace.store"); namespaces = this.context.allNamespaces, // load all namespaces by default
merge = true, // merge loaded items or return as result
} = options;
// load all available namespaces by default const items = await this.loadItems({ namespaces, api: this.api });
namespaces.push(...namespaceStore.allowedNamespaces);
}
let items = await this.loadItems({ namespaces, api: this.api });
items = this.filterItemsOnLoad(items);
items = this.sortItems(items);
this.items.replace(items);
this.isLoaded = true; this.isLoaded = true;
if (merge) {
this.mergeItems(items, { replace: false });
} else {
return items;
}
} catch (error) { } catch (error) {
console.error("Loading store items failed", { error, store: this }); console.error("Loading store items failed", { error, store: this });
this.resetOnError(error); this.resetOnError(error);
@ -132,10 +159,36 @@ export abstract class KubeObjectStore<T extends KubeObject = any> extends ItemSt
} }
} }
async loadSelectedNamespaces(): Promise<void> { @action
const { namespaceStore } = await import("./components/+namespaces/namespace.store"); reloadAll(opts: { force?: boolean, namespaces?: string[], merge?: boolean } = {}) {
const { force = false, ...loadingOptions } = opts;
return this.loadAll(namespaceStore.getContextNamespaces()); if (this.isLoading || (this.isLoaded && !force)) {
return;
}
return this.loadAll(loadingOptions);
}
@action
protected mergeItems(partialItems: T[], { replace = false, updateStore = true, sort = true, filter = true } = {}): T[] {
let items = partialItems;
// update existing items
if (!replace) {
const partialIds = partialItems.map(item => item.getId());
items = [
...this.items.filter(existingItem => !partialIds.includes(existingItem.getId())),
...partialItems,
];
}
if (filter) items = this.filterItemsOnLoad(items);
if (sort) items = this.sortItems(items);
if (updateStore) this.items.replace(items);
return items;
} }
protected resetOnError(error: any) { protected resetOnError(error: any) {
@ -204,12 +257,7 @@ export abstract class KubeObjectStore<T extends KubeObject = any> extends ItemSt
protected eventsBuffer = observable.array<IKubeWatchEvent<KubeJsonApiData>>([], { deep: false }); protected eventsBuffer = observable.array<IKubeWatchEvent<KubeJsonApiData>>([], { deep: false });
protected bindWatchEventsUpdater(delay = 1000) { protected bindWatchEventsUpdater(delay = 1000) {
kubeWatchApi.onMessage.addListener(({ store, data }: IKubeWatchMessage<T>) => { reaction(() => this.eventsBuffer.length, this.updateFromEventsBuffer, {
if (!this.isLoaded || store !== this) return;
this.eventsBuffer.push(data);
});
reaction(() => this.eventsBuffer.length > 0, this.updateFromEventsBuffer, {
delay delay
}); });
} }
@ -219,7 +267,31 @@ export abstract class KubeObjectStore<T extends KubeObject = any> extends ItemSt
} }
subscribe(apis = this.getSubscribeApis()) { subscribe(apis = this.getSubscribeApis()) {
return kubeWatchApi.subscribeApi(apis); let disposers: {(): void}[] = [];
const callback = (data: IKubeWatchEvent) => {
if (!this.isLoaded) return;
this.eventsBuffer.push(data);
};
if (this.context.cluster?.isGlobalWatchEnabled && this.loadedNamespaces.length === 0) {
disposers = apis.map(api => api.watch({
namespace: "",
callback: (data) => callback(data),
}));
} else {
apis.map(api => {
this.loadedNamespaces.forEach((namespace) => {
disposers.push(api.watch({
namespace,
callback: (data) => callback(data)
}));
});
});
}
return () => disposers.forEach(dispose => dispose());
} }
@action @action
@ -239,7 +311,7 @@ export abstract class KubeObjectStore<T extends KubeObject = any> extends ItemSt
if (!item) { if (!item) {
items.push(newItem); items.push(newItem);
} else { } else {
items.splice(index, 1, newItem); items[index] = newItem;
} }
break; break;
case "DELETED": case "DELETED":

View File

@ -13,6 +13,7 @@ import { extensionLoader } from "../extensions/extension-loader";
import { broadcastMessage } from "../common/ipc"; import { broadcastMessage } from "../common/ipc";
import { CommandContainer } from "./components/command-palette/command-container"; import { CommandContainer } from "./components/command-palette/command-container";
import { LensProtocolRouterRenderer } from "./protocol-handler/router"; import { LensProtocolRouterRenderer } from "./protocol-handler/router";
import { registerIpcHandlers } from "./ipc";
@observer @observer
export class LensApp extends React.Component { export class LensApp extends React.Component {
@ -25,6 +26,8 @@ export class LensApp extends React.Component {
window.addEventListener("online", () => { window.addEventListener("online", () => {
broadcastMessage("network:online"); broadcastMessage("network:online");
}); });
registerIpcHandlers();
} }
render() { render() {

View File

@ -25,6 +25,7 @@
"sidebarSubmenuActiveColor": "#ffffff", "sidebarSubmenuActiveColor": "#ffffff",
"buttonPrimaryBackground": "#3d90ce", "buttonPrimaryBackground": "#3d90ce",
"buttonDefaultBackground": "#414448", "buttonDefaultBackground": "#414448",
"buttonLightBackground": "#f1f1f1",
"buttonAccentBackground": "#e85555", "buttonAccentBackground": "#e85555",
"buttonDisabledBackground": "#808080", "buttonDisabledBackground": "#808080",
"tableBgcStripe": "#2a2d33", "tableBgcStripe": "#2a2d33",

View File

@ -26,6 +26,7 @@
"sidebarBackground": "#e8e8e8", "sidebarBackground": "#e8e8e8",
"buttonPrimaryBackground": "#3d90ce", "buttonPrimaryBackground": "#3d90ce",
"buttonDefaultBackground": "#414448", "buttonDefaultBackground": "#414448",
"buttonLightBackground": "#f1f1f1",
"buttonAccentBackground": "#e85555", "buttonAccentBackground": "#e85555",
"buttonDisabledBackground": "#808080", "buttonDisabledBackground": "#808080",
"tableBgcStripe": "#f8f8f8", "tableBgcStripe": "#f8f8f8",

View File

@ -34,6 +34,7 @@ $sidebarBackground: var(--sidebarBackground);
// Elements // Elements
$buttonPrimaryBackground: var(--buttonPrimaryBackground); $buttonPrimaryBackground: var(--buttonPrimaryBackground);
$buttonDefaultBackground: var(--buttonDefaultBackground); $buttonDefaultBackground: var(--buttonDefaultBackground);
$buttonLightBackground: var(--buttonLightBackground);
$buttonAccentBackground: var(--buttonAccentBackground); $buttonAccentBackground: var(--buttonAccentBackground);
$buttonDisabledBackground: var(--buttonDisabledBackground); $buttonDisabledBackground: var(--buttonDisabledBackground);

View File

@ -2,11 +2,12 @@
Here you can find description of changes we've built into each release. While we try our best to make each upgrade automatic and as smooth as possible, there may be some cases where you might need to do something to ensure the application works smoothly. So please read through the release highlights! Here you can find description of changes we've built into each release. While we try our best to make each upgrade automatic and as smooth as possible, there may be some cases where you might need to do something to ensure the application works smoothly. So please read through the release highlights!
## 4.1.0-alpha.2 (current version) ## 4.1.0-beta.1 (current version)
- Change: list views default to a namespace (insted of listing resources from all namespaces) - Change: list views default to a namespace (insted of listing resources from all namespaces)
- Command palette - Command palette
- Generic logs view with Pod selector - Generic logs view with Pod selector
- In-app survey extension
- Possibility to add custom Helm repository through Lens - Possibility to add custom Helm repository through Lens
- Possibility to change visibility of common resource list columns - Possibility to change visibility of common resource list columns
- Suspend / resume buttons for CronJobs - Suspend / resume buttons for CronJobs
@ -21,6 +22,8 @@ Here you can find description of changes we've built into each release. While we
- Lens metrics: Prometheus v2.19.3 - Lens metrics: Prometheus v2.19.3
- Update bundled kubectl to v1.18.15 - Update bundled kubectl to v1.18.15
- Improve how watch requests are handled - Improve how watch requests are handled
- Helm rollback window with more details
- Log more on start up
- Export PodDetailsList component to extension API - Export PodDetailsList component to extension API
- Export Wizard components to extension API - Export Wizard components to extension API
- Export NamespaceSelect component to extension API - Export NamespaceSelect component to extension API

View File

@ -1,9 +1,9 @@
import path from "path"; import path from "path";
import webpack from "webpack"; import webpack from "webpack";
import { sassCommonVars } from "./src/common/vars"; import { sassCommonVars, isDevelopment } from "./src/common/vars";
export default function (): webpack.Configuration { export default function generateExtensionTypes(): webpack.Configuration {
const entry = "./src/extensions/extension-api.ts"; const entry = "./src/extensions/extension-api.ts";
const outDir = "./src/extensions/npm/extensions/dist"; const outDir = "./src/extensions/npm/extensions/dist";
@ -22,6 +22,10 @@ export default function (): webpack.Configuration {
// e.g. require('@k8slens/extensions') // e.g. require('@k8slens/extensions')
libraryTarget: "commonjs" libraryTarget: "commonjs"
}, },
cache: isDevelopment,
optimization: {
minimize: false, // speed up types compilation
},
module: { module: {
rules: [ rules: [
{ {

View File

@ -1079,6 +1079,13 @@
resolved "https://registry.yarnpkg.com/@types/boom/-/boom-7.3.0.tgz#33280c5552d4cfabc21b8b7e0f6d29292decd985" resolved "https://registry.yarnpkg.com/@types/boom/-/boom-7.3.0.tgz#33280c5552d4cfabc21b8b7e0f6d29292decd985"
integrity sha512-PH7bfkt1nu4pnlxz+Ws+wwJJF1HE12W3ia+Iace2JT7q56DLH3hbyjOJyNHJYRxk3PkKaC36fHfHKyeG1rMgCA== integrity sha512-PH7bfkt1nu4pnlxz+Ws+wwJJF1HE12W3ia+Iace2JT7q56DLH3hbyjOJyNHJYRxk3PkKaC36fHfHKyeG1rMgCA==
"@types/byline@^4.2.32":
version "4.2.32"
resolved "https://registry.yarnpkg.com/@types/byline/-/byline-4.2.32.tgz#9d35ec15968056118548412ee24c2c3026c997dc"
integrity sha512-qtlm/J6XOO9p+Ep/ZB5+mCFEDhzWDDHWU4a1eReN7lkPZXW9rkloq2jcAhvKKmlO5tL2GSvKROb+PTsNVhBiyQ==
dependencies:
"@types/node" "*"
"@types/caseless@*": "@types/caseless@*":
version "0.12.2" version "0.12.2"
resolved "https://registry.yarnpkg.com/@types/caseless/-/caseless-0.12.2.tgz#f65d3d6389e01eeb458bd54dc8f52b95a9463bc8" resolved "https://registry.yarnpkg.com/@types/caseless/-/caseless-0.12.2.tgz#f65d3d6389e01eeb458bd54dc8f52b95a9463bc8"
@ -1608,6 +1615,14 @@
"@types/prop-types" "*" "@types/prop-types" "*"
csstype "^3.0.2" csstype "^3.0.2"
"@types/readable-stream@^2.3.9":
version "2.3.9"
resolved "https://registry.yarnpkg.com/@types/readable-stream/-/readable-stream-2.3.9.tgz#40a8349e6ace3afd2dd1b6d8e9b02945de4566a9"
integrity sha512-sqsgQqFT7HmQz/V5jH1O0fvQQnXAJO46Gg9LRO/JPfjmVmGUlcx831TZZO3Y3HtWhIkzf3kTsNT0Z0kzIhIvZw==
dependencies:
"@types/node" "*"
safe-buffer "*"
"@types/relateurl@*": "@types/relateurl@*":
version "0.2.28" version "0.2.28"
resolved "https://registry.yarnpkg.com/@types/relateurl/-/relateurl-0.2.28.tgz#6bda7db8653fa62643f5ee69e9f69c11a392e3a6" resolved "https://registry.yarnpkg.com/@types/relateurl/-/relateurl-0.2.28.tgz#6bda7db8653fa62643f5ee69e9f69c11a392e3a6"
@ -11474,6 +11489,14 @@ readable-stream@~1.1.10:
isarray "0.0.1" isarray "0.0.1"
string_decoder "~0.10.x" string_decoder "~0.10.x"
readable-web-to-node-stream@^3.0.1:
version "3.0.1"
resolved "https://registry.yarnpkg.com/readable-web-to-node-stream/-/readable-web-to-node-stream-3.0.1.tgz#3f619b1bc5dd73a4cfe5c5f9b4f6faba55dff845"
integrity sha512-4zDC6CvjUyusN7V0QLsXVB7pJCD9+vtrM9bYDRv6uBQ+SKfx36rp5AFNPRgh9auKRul/a1iFZJYXcCbwRL+SaA==
dependencies:
"@types/readable-stream" "^2.3.9"
readable-stream "^3.6.0"
readdir-scoped-modules@^1.0.0, readdir-scoped-modules@^1.1.0: readdir-scoped-modules@^1.0.0, readdir-scoped-modules@^1.1.0:
version "1.1.0" version "1.1.0"
resolved "https://registry.yarnpkg.com/readdir-scoped-modules/-/readdir-scoped-modules-1.1.0.tgz#8d45407b4f870a0dcaebc0e28670d18e74514309" resolved "https://registry.yarnpkg.com/readdir-scoped-modules/-/readdir-scoped-modules-1.1.0.tgz#8d45407b4f870a0dcaebc0e28670d18e74514309"
@ -11892,16 +11915,16 @@ rxjs@^6.5.2:
dependencies: dependencies:
tslib "^1.9.0" tslib "^1.9.0"
safe-buffer@*, safe-buffer@>=5.1.0, safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@^5.1.1, safe-buffer@^5.1.2, safe-buffer@^5.2.0, safe-buffer@~5.2.0:
version "5.2.1"
resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6"
integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==
safe-buffer@5.1.2, safe-buffer@~5.1.0, safe-buffer@~5.1.1: safe-buffer@5.1.2, safe-buffer@~5.1.0, safe-buffer@~5.1.1:
version "5.1.2" version "5.1.2"
resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d" resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d"
integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g== integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==
safe-buffer@>=5.1.0, safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@^5.1.1, safe-buffer@^5.1.2, safe-buffer@^5.2.0, safe-buffer@~5.2.0:
version "5.2.1"
resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6"
integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==
safe-regex@^1.1.0: safe-regex@^1.1.0:
version "1.1.0" version "1.1.0"
resolved "https://registry.yarnpkg.com/safe-regex/-/safe-regex-1.1.0.tgz#40a3669f3b077d1e943d44629e157dd48023bf2e" resolved "https://registry.yarnpkg.com/safe-regex/-/safe-regex-1.1.0.tgz#40a3669f3b077d1e943d44629e157dd48023bf2e"