diff --git a/build/icons/512x512.png b/build/icons/512x512.png index 2c953f6efd..e08b9f5b15 100644 Binary files a/build/icons/512x512.png and b/build/icons/512x512.png differ diff --git a/build/icons/512x512@2x.png b/build/icons/512x512@2x.png new file mode 100644 index 0000000000..da0aa8ae80 Binary files /dev/null and b/build/icons/512x512@2x.png differ diff --git a/docs/extensions/capabilities/color-reference.md b/docs/extensions/capabilities/color-reference.md index 660e0fe067..6a38ba861c 100644 --- a/docs/extensions/capabilities/color-reference.md +++ b/docs/extensions/capabilities/color-reference.md @@ -43,6 +43,7 @@ You can use theme-based CSS Variables to style an extension according to the act ## Button Colors - `--buttonPrimaryBackground`: button background color for primary actions. - `--buttonDefaultBackground`: default button background color. +- `--buttonLightBackground`: light button background color. - `--buttonAccentBackground`: accent button background color. - `--buttonDisabledBackground`: disabled button background color. diff --git a/package.json b/package.json index 8f21621fc7..8ee3c9726a 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "kontena-lens", "productName": "Lens", "description": "Lens - The Kubernetes IDE", - "version": "4.1.0-alpha.2", + "version": "4.1.0-beta.1", "main": "static/build/main.js", "copyright": "© 2020, Mirantis, Inc.", "license": "MIT", @@ -16,7 +16,7 @@ "dev-run": "nodemon --watch static/build/main.js --exec \"electron --inspect .\"", "dev:main": "yarn run compile:main --watch", "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:main": "yarn run webpack --config webpack.main.ts", "compile:renderer": "yarn run webpack --config webpack.renderer.ts", @@ -197,6 +197,7 @@ "abort-controller": "^3.0.0", "array-move": "^3.0.0", "await-lock": "^2.1.0", + "byline": "^5.0.0", "chalk": "^4.1.0", "chokidar": "^3.4.3", "command-exists": "1.2.9", @@ -230,6 +231,7 @@ "react": "^17.0.1", "react-dom": "^17.0.1", "react-router": "^5.2.0", + "readable-web-to-node-stream": "^3.0.1", "request": "^2.88.2", "request-promise-native": "^1.0.8", "semver": "^7.3.2", @@ -252,6 +254,7 @@ "@pmmmwh/react-refresh-webpack-plugin": "^0.4.3", "@testing-library/jest-dom": "^5.11.5", "@testing-library/react": "^11.1.0", + "@types/byline": "^4.2.32", "@types/chart.js": "^2.9.21", "@types/circular-dependency-plugin": "^5.0.1", "@types/color": "^3.0.1", diff --git a/src/common/ipc/__tests__/type-enforced-ipc.test.ts b/src/common/ipc/__tests__/type-enforced-ipc.test.ts new file mode 100644 index 0000000000..4e20249392 --- /dev/null +++ b/src/common/ipc/__tests__/type-enforced-ipc.test.ts @@ -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"); + }); + }); +}); diff --git a/src/common/ipc/index.ts b/src/common/ipc/index.ts new file mode 100644 index 0000000000..a34890472e --- /dev/null +++ b/src/common/ipc/index.ts @@ -0,0 +1,3 @@ +export * from "./ipc"; +export * from "./update-available"; +export * from "./type-enforced-ipc"; diff --git a/src/common/ipc.ts b/src/common/ipc/ipc.ts similarity index 90% rename from src/common/ipc.ts rename to src/common/ipc/ipc.ts index 3b1cd6333d..b104b31f4a 100644 --- a/src/common/ipc.ts +++ b/src/common/ipc/ipc.ts @@ -4,12 +4,12 @@ import { ipcMain, ipcRenderer, webContents, remote } from "electron"; import { toJS } from "mobx"; -import logger from "../main/logger"; -import { ClusterFrameInfo, clusterFrameMap } from "./cluster-frames"; +import logger from "../../main/logger"; +import { ClusterFrameInfo, clusterFrameMap } from "../cluster-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); } diff --git a/src/common/ipc/type-enforced-ipc.ts b/src/common/ipc/type-enforced-ipc.ts new file mode 100644 index 0000000000..be54992008 --- /dev/null +++ b/src/common/ipc/type-enforced-ipc.ts @@ -0,0 +1,71 @@ +import { EventEmitter } from "events"; +import logger from "../../main/logger"; + +export type HandlerEvent = Parameters[1]>[0]; +export type ListVerifier = (args: unknown[]) => args is T; +export type Rest = 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, ...args: any[]) => any +>({ + source, + channel, + listener, + verifier, +}: { + source: EM, + channel: string | symbol, + listener: L, + verifier: ListVerifier>>, +}): void { + function handler(event: HandlerEvent, ...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, ...args: any[]) => any +>({ + source, + channel, + listener, + verifier, +}: { + source: EM, + channel: string | symbol, + listener: L, + verifier: ListVerifier>>, +}): 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 }); + } + }); +} diff --git a/src/common/ipc/update-available/index.ts b/src/common/ipc/update-available/index.ts new file mode 100644 index 0000000000..1e3fcf1268 --- /dev/null +++ b/src/common/ipc/update-available/index.ts @@ -0,0 +1,48 @@ +import { UpdateInfo } from "electron-updater"; + +export const UpdateAvailableChannel = "update-available"; +export const AutoUpdateLogPrefix = "[UPDATE-CHECKER]"; + +/** + * [, ] + */ +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; +} diff --git a/src/common/utils/delay.ts b/src/common/utils/delay.ts index 493e5c63c7..f19839538a 100644 --- a/src/common/utils/delay.ts +++ b/src/common/utils/delay.ts @@ -2,8 +2,10 @@ import { AbortController } from "abort-controller"; /** * 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 failFast An abort controller instance to cause the delay to short-circuit */ export function delay(timeout = 1000, failFast?: AbortController): Promise { return new Promise(resolve => { diff --git a/src/common/utils/index.ts b/src/common/utils/index.ts index 2bdd20b844..6f26bab2da 100644 --- a/src/common/utils/index.ts +++ b/src/common/utils/index.ts @@ -10,7 +10,6 @@ export * from "./cloneJson"; export * from "./delay"; export * from "./debouncePromise"; export * from "./defineGlobal"; -export * from "./delay"; export * from "./getRandId"; export * from "./splitArray"; export * from "./saveToAppFiles"; diff --git a/src/main/app-updater.ts b/src/main/app-updater.ts index dd9ed97e69..618f714b49 100644 --- a/src/main/app-updater.ts +++ b/src/main/app-updater.ts @@ -1,20 +1,78 @@ -import { autoUpdater } from "electron-updater"; +import { autoUpdater, UpdateInfo } from "electron-updater"; 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 { - static readonly defaultUpdateIntervalMs = 1000 * 60 * 60 * 24; // once a day - - static checkForUpdates() { - return autoUpdater.checkForUpdatesAndNotify(); - } - - constructor(protected updateInterval = AppUpdater.defaultUpdateIntervalMs) { - autoUpdater.logger = logger; - } - - public start() { - setInterval(AppUpdater.checkForUpdates, this.updateInterval); - - return AppUpdater.checkForUpdates(); +function handleAutoUpdateBackChannel(event: Electron.IpcMainEvent, ...[arg]: UpdateAvailableToBackchannel) { + if (arg.doUpdate) { + if (arg.now) { + logger.info(`${AutoUpdateLogPrefix}: User chose to update now`); + autoUpdater.downloadUpdate() + .then(() => autoUpdater.quitAndInstall()) + .catch(error => logger.error(`${AutoUpdateLogPrefix}: Failed to download or install update`, { error })); + } else { + logger.info(`${AutoUpdateLogPrefix}: User chose to update on quit`); + autoUpdater.autoInstallOnAppQuit = true; + autoUpdater.downloadUpdate() + .catch(error => logger.error(`${AutoUpdateLogPrefix}: Failed to download update`, { error })); + } + } else { + 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 { + try { + logger.info(`📡 Checking for app updates`); + + await autoUpdater.checkForUpdates(); + } catch (error) { + logger.error(`${AutoUpdateLogPrefix}: failed with an error`, { error: String(error) }); } } diff --git a/src/main/index.ts b/src/main/index.ts index fa88d5fb74..01e31e1eb0 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -10,7 +10,6 @@ import path from "path"; import { LensProxy } from "./lens-proxy"; import { WindowManager } from "./window-manager"; import { ClusterManager } from "./cluster-manager"; -import { AppUpdater } from "./app-updater"; import { shellSync } from "./shell-sync"; import { getFreePort } from "./port"; import { mangleProxyEnv } from "./proxy-env"; @@ -29,6 +28,7 @@ import { filesystemProvisionerStore } from "./extension-filesystem"; import { LensProtocolRouterMain } from "./protocol-handler"; import { getAppVersion, getAppVersionFromProxyServer } from "../common/utils"; import { bindBroadcastHandlers } from "../common/ipc"; +import { startUpdateChecking } from "./app-updater"; const workingDir = path.join(app.getPath("appData"), appName); let proxyPort: number; @@ -90,11 +90,6 @@ app.on("ready", async () => { app.exit(); }); - logger.info(`📡 Checking for app updates`); - const updater = new AppUpdater(); - - updater.start(); - registerFileProtocol("static", __static); await installDeveloperTools(); @@ -152,6 +147,7 @@ app.on("ready", async () => { logger.info("🖥️ Starting WindowManager"); windowManager = WindowManager.getInstance(proxyPort); windowManager.whenLoaded.then(() => { + startUpdateChecking(); LensProtocolRouterMain .getInstance() .rendererLoaded = true; diff --git a/src/main/lens-proxy.ts b/src/main/lens-proxy.ts index c58f3eb2e4..177e4d11d2 100644 --- a/src/main/lens-proxy.ts +++ b/src/main/lens-proxy.ts @@ -194,7 +194,8 @@ export class LensProxy { if (proxyTarget) { // 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); } diff --git a/src/main/router.ts b/src/main/router.ts index 875bd319b5..bb49aacdab 100644 --- a/src/main/router.ts +++ b/src/main/router.ts @@ -5,7 +5,7 @@ import path from "path"; import { readFile } from "fs-extra"; import { Cluster } from "./cluster"; 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"; 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: `${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 this.router.add({ method: "post", path: `${apiPrefix}/metrics` }, metricsRoute.routeMetrics.bind(metricsRoute)); diff --git a/src/main/routes/index.ts b/src/main/routes/index.ts index c2fd222631..c194d8f8b2 100644 --- a/src/main/routes/index.ts +++ b/src/main/routes/index.ts @@ -1,7 +1,6 @@ export * from "./kubeconfig-route"; export * from "./metrics-route"; export * from "./port-forward-route"; -export * from "./watch-route"; export * from "./helm-route"; export * from "./resource-applier-route"; export * from "./version-route"; diff --git a/src/main/routes/watch-route.ts b/src/main/routes/watch-route.ts deleted file mode 100644 index 2c86314908..0000000000 --- a/src/main/routes/watch-route.ts +++ /dev/null @@ -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 { - 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) { - 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(); - 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(); diff --git a/src/main/tray.ts b/src/main/tray.ts index 44a22d27bf..3d6d2dd624 100644 --- a/src/main/tray.ts +++ b/src/main/tray.ts @@ -1,9 +1,9 @@ import path from "path"; import packageInfo from "../../package.json"; -import { dialog, Menu, NativeImage, Tray } from "electron"; +import { Menu, NativeImage, Tray } from "electron"; import { autorun } from "mobx"; import { showAbout } from "./menu"; -import { AppUpdater } from "./app-updater"; +import { checkForUpdates } from "./app-updater"; import { WindowManager } from "./window-manager"; import { clusterStore } from "../common/cluster-store"; import { workspaceStore } from "../common/workspace-store"; @@ -102,16 +102,8 @@ function createTrayMenu(windowManager: WindowManager): Menu { { label: "Check for updates", async click() { - const result = await AppUpdater.checkForUpdates(); - - if (!result) { - const browserWindow = await windowManager.ensureMainWindow(); - - dialog.showMessageBoxSync(browserWindow, { - message: "No updates available", - type: "info", - }); - } + await checkForUpdates(); + await windowManager.ensureMainWindow(); }, }, { diff --git a/src/renderer/api/api-manager.ts b/src/renderer/api/api-manager.ts index 47500adf79..a802851f65 100644 --- a/src/renderer/api/api-manager.ts +++ b/src/renderer/api/api-manager.ts @@ -7,7 +7,7 @@ import { KubeApi, parseKubeApi } from "./kube-api"; @autobind() export class ApiManager { private apis = observable.map(); - private stores = observable.map(); + private stores = observable.map(); getApi(pathOrCallback: string | ((api: KubeApi) => boolean)) { if (typeof pathOrCallback === "string") { @@ -46,12 +46,12 @@ export class ApiManager { @action registerStore(store: KubeObjectStore, apis: KubeApi[] = [store.api]) { apis.forEach(api => { - this.stores.set(api, store); + this.stores.set(api.apiBase, store); }); } getStore(api: string | KubeApi): S { - return this.stores.get(this.resolveApi(api)) as S; + return this.stores.get(this.resolveApi(api)?.apiBase) as S; } } diff --git a/src/renderer/api/endpoints/helm-releases.api.ts b/src/renderer/api/endpoints/helm-releases.api.ts index 84e095721b..6831c508cb 100644 --- a/src/renderer/api/endpoints/helm-releases.api.ts +++ b/src/renderer/api/endpoints/helm-releases.api.ts @@ -57,6 +57,7 @@ export interface IReleaseRevision { updated: string; status: string; chart: string; + app_version: string; description: string; } diff --git a/src/renderer/api/json-api.ts b/src/renderer/api/json-api.ts index 49c2cb1a28..df12b08ab7 100644 --- a/src/renderer/api/json-api.ts +++ b/src/renderer/api/json-api.ts @@ -3,7 +3,7 @@ import { stringify } from "querystring"; import { EventEmitter } from "../../common/event-emitter"; import { cancelableFetch } from "../utils/cancelableFetch"; - +import { randomBytes } from "crypto"; export interface JsonApiData { } @@ -55,6 +55,34 @@ export class JsonApi { return this.request(path, params, { ...reqInit, method: "get" }); } + getResponse(path: string, params?: P, init: RequestInit = {}): Promise { + 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(path: string, params?: P, reqInit: RequestInit = {}) { return this.request(path, params, { ...reqInit, method: "post" }); } diff --git a/src/renderer/api/kube-api.ts b/src/renderer/api/kube-api.ts index e62603b14f..0be497a8f1 100644 --- a/src/renderer/api/kube-api.ts +++ b/src/renderer/api/kube-api.ts @@ -9,7 +9,9 @@ import { apiKube } from "./index"; import { createKubeApiURL, parseKubeApi } from "./kube-api-parse"; import { KubeJsonApi, KubeJsonApiData, KubeJsonApiDataList } from "./kube-json-api"; 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 { /** @@ -91,6 +93,12 @@ export function ensureObjectSelfLink(api: KubeApi, object: KubeJsonApiData) { } } +type KubeApiWatchOptions = { + namespace: string; + callback?: (data: IKubeWatchEvent) => void; + abortController?: AbortController +}; + export class KubeApi { readonly kind: string; readonly apiBase: string; @@ -104,6 +112,7 @@ export class KubeApi { public objectConstructor: IKubeObjectConstructor; protected request: KubeJsonApi; protected resourceVersions = new Map(); + protected watchDisposer: () => void; constructor(protected options: IKubeApiOptions) { const { @@ -357,8 +366,88 @@ export class KubeApi { }); } - watch(): () => void { - return kubeWatchApi.subscribeApi(this); + watch(opts: KubeApiWatchOptions = { namespace: "" }): () => void { + 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; + } + } } } diff --git a/src/renderer/api/kube-watch-api.ts b/src/renderer/api/kube-watch-api.ts index 06cab9c06c..5523c5c5a9 100644 --- a/src/renderer/api/kube-watch-api.ts +++ b/src/renderer/api/kube-watch-api.ts @@ -1,143 +1,63 @@ // Kubernetes watch-api client // 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 { ClusterContext } from "../components/context"; import plimit from "p-limit"; -import debounce from "lodash/debounce"; -import { autorun, comparer, computed, IReactionDisposer, observable, reaction } from "mobx"; -import { autobind, EventEmitter, noop } from "../utils"; -import { ensureObjectSelfLink, KubeApi, parseKubeApi } from "./kube-api"; -import { KubeJsonApiData, KubeJsonApiError } from "./kube-json-api"; -import { apiPrefix, isDebugging, isProduction } from "../../common/vars"; -import { apiManager } from "./api-manager"; +import { comparer, IReactionDisposer, observable, reaction, when } from "mobx"; +import { autobind, noop } from "../utils"; +import { KubeApi } from "./kube-api"; +import { KubeJsonApiData } from "./kube-json-api"; +import { isDebugging, isProduction } from "../../common/vars"; -export { IKubeWatchEvent, IKubeWatchEventStreamEnd }; - -export interface IKubeWatchMessage { - namespace?: string; - data?: IKubeWatchEvent - error?: IKubeWatchEvent; - api?: KubeApi; - store?: KubeObjectStore; +export interface IKubeWatchEvent { + type: "ADDED" | "MODIFIED" | "DELETED"; + object?: T; } export interface IKubeWatchSubscribeStoreOptions { + namespaces?: string[]; // default: all accessible namespaces preload?: boolean; // preload store items, 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 } -export interface IKubeWatchReconnectOptions { - reconnectAttempts: number; - timeout: number; -} - export interface IKubeWatchLog { - message: string | Error; + message: string | string[] | Error; meta?: object; + cssStyle?: string; } @autobind() export class KubeWatchApi { - private requestId = 0; - private reader: ReadableStreamReader; - public onMessage = new EventEmitter<[IKubeWatchMessage]>(); - - @observable.ref private cluster: Cluster; - @observable.ref private namespaces: string[] = []; + @observable context: ClusterContext = null; @observable subscribers = observable.map(); @observable isConnected = false; - @computed get isReady(): boolean { - return Boolean(this.cluster && this.namespaces); + contextReady = when(() => Boolean(this.context)); + + constructor() { + this.init(); } - @computed get isActive(): boolean { - return this.apis.length > 0; - } - - @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 { - 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; + private async init() { + await this.contextReady; } isAllowedApi(api: KubeApi): boolean { - return Boolean(this?.cluster.isAllowedResource(api.kind)); + return Boolean(this.context?.cluster.isAllowedResource(api.kind)); } - subscribeApi(api: KubeApi | KubeApi[]): () => void { - 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 } = {}) { + preloadStores(stores: KubeObjectStore[], opts: { namespaces?: string[], loadOnce?: boolean } = {}) { const limitRequests = plimit(1); // load stores one by one to allow quick skipping when fast clicking btw pages const preloading: Promise[] = []; for (const store of stores) { 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 { - const { preload = true, waitUntilLoaded = true, loadOnce = false } = options; - const apis = new Set(stores.map(store => store.getSubscribeApis()).flat()); - const unsubscribeList: (() => void)[] = []; + subscribeStores(stores: KubeObjectStore[], opts: IKubeWatchSubscribeStoreOptions = {}): () => void { + const { preload = true, waitUntilLoaded = true, loadOnce = false, } = opts; + const subscribingNamespaces = opts.namespaces ?? this.context?.allNamespaces ?? []; + const unsubscribeList: Function[] = []; let isUnsubscribed = false; - const load = () => this.preloadStores(stores, { loadOnce }); + const load = (namespaces = subscribingNamespaces) => this.preloadStores(stores, { namespaces, loadOnce }); let preloading = preload && load(); let cancelReloading: IReactionDisposer = noop; const subscribe = () => { if (isUnsubscribed) return; - apis.forEach(api => unsubscribeList.push(this.subscribeApi(api))); + + stores.forEach((store) => { + unsubscribeList.push(store.subscribe()); + }); }; if (preloading) { @@ -167,17 +90,20 @@ export class KubeWatchApi { preloading.loading.then(subscribe, error => { this.log({ message: new Error("Loading stores has failed"), - meta: { stores, error, options }, + meta: { stores, error, options: opts }, }); }); } else { subscribe(); } - // reload when context namespaces changes - cancelReloading = reaction(() => this.namespaces, () => { + // reload stores only for context namespaces change + cancelReloading = reaction(() => this.context?.contextNamespaces, namespaces => { preloading?.cancelLoading(); - preloading = load(); + unsubscribeList.forEach(unsubscribe => unsubscribe()); + unsubscribeList.length = 0; + preloading = load(namespaces); + preloading.loading.then(subscribe); }, { equals: comparer.shallow, }); @@ -190,184 +116,25 @@ export class KubeWatchApi { cancelReloading(); preloading?.cancelLoading(); unsubscribeList.forEach(unsubscribe => unsubscribe()); + unsubscribeList.length = 0; }; } - protected async connectionCheck() { - 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; - 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; - 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) { + protected log({ message, cssStyle = "", meta = {} }: IKubeWatchLog) { if (isProduction && !isDebugging) { return; } - const logMessage = `%c[KUBE-WATCH-API]: ${String(message).toUpperCase()}`; - const isError = message instanceof Error; - const textStyle = `font-weight: bold;`; - const time = new Date().toLocaleString(); + const logInfo = [`%c[KUBE-WATCH-API]:`, `font-weight: bold; ${cssStyle}`, message].flat().map(String); + const logMeta = { + time: new Date().toLocaleString(), + ...meta, + }; - if (isError) { - console.error(logMessage, textStyle, { time, ...meta }); + if (message instanceof Error) { + console.error(...logInfo, logMeta); } else { - console.info(logMessage, textStyle, { time, ...meta }); + console.info(...logInfo, logMeta); } } } diff --git a/src/renderer/components/+apps-releases/release-rollback-dialog.tsx b/src/renderer/components/+apps-releases/release-rollback-dialog.tsx index bca0b23c4f..4aef4ec356 100644 --- a/src/renderer/components/+apps-releases/release-rollback-dialog.tsx +++ b/src/renderer/components/+apps-releases/release-rollback-dialog.tsx @@ -77,7 +77,8 @@ export class ReleaseRollbackDialog extends React.Component { themeName="light" value={revision} options={revisions} - formatOptionLabel={({ value }: SelectOption) => `${value.revision} - ${value.chart}`} + formatOptionLabel={({ value }: SelectOption) => `${value.revision} - ${value.chart} + - ${value.app_version}, updated: ${new Date(value.updated).toLocaleString()}`} onChange={({ value }: SelectOption) => this.revision = value} /> diff --git a/src/renderer/components/+apps-releases/release.store.ts b/src/renderer/components/+apps-releases/release.store.ts index 0ca6f45b39..9548e494f7 100644 --- a/src/renderer/components/+apps-releases/release.store.ts +++ b/src/renderer/components/+apps-releases/release.store.ts @@ -73,8 +73,8 @@ export class ReleaseStore extends ItemStore { } } - async loadSelectedNamespaces(): Promise { - return this.loadAll(namespaceStore.getContextNamespaces()); + async loadFromContextNamespaces(): Promise { + return this.loadAll(namespaceStore.contextNamespaces); } async loadItems(namespaces: string[]) { @@ -86,7 +86,7 @@ export class ReleaseStore extends ItemStore { async create(payload: IReleaseCreatePayload) { const response = await helmReleasesApi.create(payload); - if (this.isLoaded) this.loadSelectedNamespaces(); + if (this.isLoaded) this.loadFromContextNamespaces(); return response; } @@ -94,7 +94,7 @@ export class ReleaseStore extends ItemStore { async update(name: string, namespace: string, payload: IReleaseUpdatePayload) { const response = await helmReleasesApi.update(name, namespace, payload); - if (this.isLoaded) this.loadSelectedNamespaces(); + if (this.isLoaded) this.loadFromContextNamespaces(); return response; } @@ -102,7 +102,7 @@ export class ReleaseStore extends ItemStore { async rollback(name: string, namespace: string, revision: number) { const response = await helmReleasesApi.rollback(name, namespace, revision); - if (this.isLoaded) this.loadSelectedNamespaces(); + if (this.isLoaded) this.loadFromContextNamespaces(); return response; } diff --git a/src/renderer/components/+custom-resources/crd-resources.tsx b/src/renderer/components/+custom-resources/crd-resources.tsx index 2bae92b8d4..afc6dd87c2 100644 --- a/src/renderer/components/+custom-resources/crd-resources.tsx +++ b/src/renderer/components/+custom-resources/crd-resources.tsx @@ -30,7 +30,7 @@ export class CrdResources extends React.Component { const { store } = this; if (store && !store.isLoading && !store.isLoaded) { - store.loadSelectedNamespaces(); + store.reloadAll(); } }) ]); @@ -97,7 +97,7 @@ export class CrdResources extends React.Component { ...extraColumns.map((column) => { 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); } diff --git a/src/renderer/components/+events/kube-event-details.tsx b/src/renderer/components/+events/kube-event-details.tsx index 60821d416d..264b99f11b 100644 --- a/src/renderer/components/+events/kube-event-details.tsx +++ b/src/renderer/components/+events/kube-event-details.tsx @@ -14,7 +14,7 @@ export interface KubeEventDetailsProps { @observer export class KubeEventDetails extends React.Component { async componentDidMount() { - eventStore.loadSelectedNamespaces(); + eventStore.reloadAll(); } render() { diff --git a/src/renderer/components/+namespaces/namespace-details.tsx b/src/renderer/components/+namespaces/namespace-details.tsx index e7397b6a5e..2ad6d7d0da 100644 --- a/src/renderer/components/+namespaces/namespace-details.tsx +++ b/src/renderer/components/+namespaces/namespace-details.tsx @@ -32,8 +32,8 @@ export class NamespaceDetails extends React.Component { } componentDidMount() { - resourceQuotaStore.loadSelectedNamespaces(); - limitRangeStore.loadSelectedNamespaces(); + resourceQuotaStore.reloadAll(); + limitRangeStore.reloadAll(); } render() { diff --git a/src/renderer/components/+namespaces/namespace-select.tsx b/src/renderer/components/+namespaces/namespace-select.tsx index c3d26ba192..27fbb8a311 100644 --- a/src/renderer/components/+namespaces/namespace-select.tsx +++ b/src/renderer/components/+namespaces/namespace-select.tsx @@ -82,7 +82,7 @@ export class NamespaceSelect extends React.Component { @observer export class NamespaceSelectFilter extends React.Component { @computed get placeholder(): React.ReactNode { - const namespaces = namespaceStore.getContextNamespaces(); + const namespaces = namespaceStore.contextNamespaces; switch (namespaces.length) { case 0: diff --git a/src/renderer/components/+namespaces/namespace.store.ts b/src/renderer/components/+namespaces/namespace.store.ts index 51f7606e8b..1f928fe2f3 100644 --- a/src/renderer/components/+namespaces/namespace.store.ts +++ b/src/renderer/components/+namespaces/namespace.store.ts @@ -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 { KubeObjectStore, KubeObjectStoreLoadingParams } from "../../kube-object.store"; import { Namespace, namespacesApi } from "../../api/endpoints/namespaces.api"; import { createPageParam } from "../../navigation"; import { apiManager } from "../../api/api-manager"; -import { clusterStore, getHostedCluster } from "../../../common/cluster-store"; const storage = createStorage("context_namespaces", []); @@ -35,9 +34,6 @@ export class NamespaceStore extends KubeObjectStore { api = namespacesApi; @observable private contextNs = observable.set(); - @observable isReady = false; - - whenReady = when(() => this.isReady); constructor() { super(); @@ -45,15 +41,11 @@ export class NamespaceStore extends KubeObjectStore { } private async init() { - await clusterStore.whenLoaded; - if (!getHostedCluster()) return; - await getHostedCluster().whenReady; // wait for cluster-state from main + await this.contextReady; this.setContext(this.initialNamespaces); this.autoLoadAllowedNamespaces(); this.autoUpdateUrlAndLocalStorage(); - - this.isReady = true; } public onContextChange(callback: (contextNamespaces: string[]) => void, opts: IReactionOptions = {}): IReactionDisposer { @@ -73,16 +65,12 @@ export class NamespaceStore extends KubeObjectStore { } private autoLoadAllowedNamespaces(): IReactionDisposer { - return reaction(() => this.allowedNamespaces, namespaces => this.loadAll(namespaces), { + return reaction(() => this.allowedNamespaces, namespaces => this.loadAll({ namespaces }), { fireImmediately: true, equals: comparer.shallow, }); } - @computed get allowedNamespaces(): string[] { - return toJS(getHostedCluster().allowedNamespaces); - } - @computed private get initialNamespaces(): string[] { const namespaces = new Set(this.allowedNamespaces); @@ -103,27 +91,26 @@ export class NamespaceStore extends KubeObjectStore { 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); - // show all namespaces when nothing selected if (!namespaces.length) { - // return actual namespaces list since "allowedNamespaces" updating every 30s in cluster and thus might be stale - if (this.isLoaded) { - return this.items.map(namespace => namespace.getName()); - } - - return this.allowedNamespaces; + return this.allowedNamespaces; // show all namespaces when nothing selected } return namespaces; } 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 (accessibleNamespaces.length > 0) { + if (this.context?.cluster.accessibleNamespaces.length > 0) { return []; } diff --git a/src/renderer/components/+nodes/node-details.tsx b/src/renderer/components/+nodes/node-details.tsx index d4208c4545..810837d59d 100644 --- a/src/renderer/components/+nodes/node-details.tsx +++ b/src/renderer/components/+nodes/node-details.tsx @@ -29,7 +29,7 @@ export class NodeDetails extends React.Component { }); async componentDidMount() { - podsStore.loadSelectedNamespaces(); + podsStore.reloadAll(); } componentWillUnmount() { diff --git a/src/renderer/components/+user-management-roles-bindings/add-role-binding-dialog.tsx b/src/renderer/components/+user-management-roles-bindings/add-role-binding-dialog.tsx index 808cef90d6..85d27243c2 100644 --- a/src/renderer/components/+user-management-roles-bindings/add-role-binding-dialog.tsx +++ b/src/renderer/components/+user-management-roles-bindings/add-role-binding-dialog.tsx @@ -7,7 +7,7 @@ import { Dialog, DialogProps } from "../dialog"; import { Wizard, WizardStep } from "../wizard"; import { Select, SelectOption } from "../select"; 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 { Input } from "../input"; 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 { roleBindingsStore } from "./role-bindings.store"; import { showDetails } from "../kube-object"; +import { KubeObjectStore } from "../../kube-object.store"; interface BindingSelectOption extends SelectOption { value: string; // binding name @@ -73,14 +74,14 @@ export class AddRoleBindingDialog extends React.Component { }; async loadData() { - const stores = [ + const stores: KubeObjectStore[] = [ namespaceStore, rolesStore, serviceAccountsStore, ]; this.isLoading = true; - await Promise.all(stores.map(store => store.loadSelectedNamespaces())); + await Promise.all(stores.map(store => store.reloadAll())); this.isLoading = false; } @@ -136,8 +137,7 @@ export class AddRoleBindingDialog extends React.Component { roleBinding: this.roleBinding, addSubjects: subjects, }); - } - else { + } else { const name = useRoleForBindingName ? selectedRole.getName() : bindingName; roleBinding = await roleBindingsStore.create({ name, namespace }, { @@ -265,7 +265,7 @@ export class AddRoleBindingDialog extends React.Component { ); const disableNext = this.isLoading || !selectedRole || !selectedBindings.length; - const nextLabel = isEditing ? "Update" : "Create"; + const nextLabel = isEditing ? "Update" : "Create"; return ( { @observer export class CronJobDetails extends React.Component { async componentDidMount() { - jobStore.loadSelectedNamespaces(); + jobStore.reloadAll(); } render() { diff --git a/src/renderer/components/+workloads-daemonsets/daemonset-details.tsx b/src/renderer/components/+workloads-daemonsets/daemonset-details.tsx index ab3269ede5..329eaf3ed7 100644 --- a/src/renderer/components/+workloads-daemonsets/daemonset-details.tsx +++ b/src/renderer/components/+workloads-daemonsets/daemonset-details.tsx @@ -30,7 +30,7 @@ export class DaemonSetDetails extends React.Component { }); componentDidMount() { - podsStore.loadSelectedNamespaces(); + podsStore.reloadAll(); } componentWillUnmount() { diff --git a/src/renderer/components/+workloads-deployments/deployment-details.tsx b/src/renderer/components/+workloads-deployments/deployment-details.tsx index e22137ea67..e31f63d7d7 100644 --- a/src/renderer/components/+workloads-deployments/deployment-details.tsx +++ b/src/renderer/components/+workloads-deployments/deployment-details.tsx @@ -31,7 +31,7 @@ export class DeploymentDetails extends React.Component { }); componentDidMount() { - podsStore.loadSelectedNamespaces(); + podsStore.reloadAll(); } componentWillUnmount() { diff --git a/src/renderer/components/+workloads-jobs/job-details.tsx b/src/renderer/components/+workloads-jobs/job-details.tsx index 4ce4a9bc61..f0665bd291 100644 --- a/src/renderer/components/+workloads-jobs/job-details.tsx +++ b/src/renderer/components/+workloads-jobs/job-details.tsx @@ -25,7 +25,7 @@ interface Props extends KubeObjectDetailsProps { @observer export class JobDetails extends React.Component { async componentDidMount() { - podsStore.loadSelectedNamespaces(); + podsStore.reloadAll(); } render() { diff --git a/src/renderer/components/+workloads-overview/overview-statuses.tsx b/src/renderer/components/+workloads-overview/overview-statuses.tsx index 33e5aa37c5..1441115ed8 100644 --- a/src/renderer/components/+workloads-overview/overview-statuses.tsx +++ b/src/renderer/components/+workloads-overview/overview-statuses.tsx @@ -6,10 +6,9 @@ import { OverviewWorkloadStatus } from "./overview-workload-status"; import { Link } from "react-router-dom"; import { workloadURL, workloadStores } from "../+workloads"; import { namespaceStore } from "../+namespaces/namespace.store"; -import { PageFiltersList } from "../item-object-list/page-filters-list"; import { NamespaceSelectFilter } from "../+namespaces/namespace-select"; import { isAllowedResource, KubeResource } from "../../../common/rbac"; -import { ResourceNames } from "../../../renderer/utils/rbac"; +import { ResourceNames } from "../../utils/rbac"; import { autobind } from "../../utils"; const resources: KubeResource[] = [ @@ -27,7 +26,7 @@ export class OverviewStatuses extends React.Component { @autobind() renderWorkload(resource: KubeResource): React.ReactElement { const store = workloadStores[resource]; - const items = store.getAllByNs(namespaceStore.getContextNamespaces()); + const items = store.getAllByNs(namespaceStore.contextNamespaces); return (
@@ -50,7 +49,6 @@ export class OverviewStatuses extends React.Component {
Overview
-
{workloads}
diff --git a/src/renderer/components/+workloads-overview/overview.tsx b/src/renderer/components/+workloads-overview/overview.tsx index 50a25ef87c..92bc569307 100644 --- a/src/renderer/components/+workloads-overview/overview.tsx +++ b/src/renderer/components/+workloads-overview/overview.tsx @@ -16,6 +16,7 @@ import { cronJobStore } from "../+workloads-cronjobs/cronjob.store"; import { Events } from "../+events"; import { isAllowedResource } from "../../../common/rbac"; import { kubeWatchApi } from "../../api/kube-watch-api"; +import { clusterContext } from "../context"; interface Props extends RouteComponentProps { } @@ -29,6 +30,7 @@ export class WorkloadsOverview extends React.Component { jobStore, cronJobStore, eventStore, ], { preload: true, + namespaces: clusterContext.contextNamespaces, }), ]); } diff --git a/src/renderer/components/+workloads-replicasets/replicaset-details.tsx b/src/renderer/components/+workloads-replicasets/replicaset-details.tsx index f427d78e9d..0cf747a1d1 100644 --- a/src/renderer/components/+workloads-replicasets/replicaset-details.tsx +++ b/src/renderer/components/+workloads-replicasets/replicaset-details.tsx @@ -29,7 +29,7 @@ export class ReplicaSetDetails extends React.Component { }); async componentDidMount() { - podsStore.loadSelectedNamespaces(); + podsStore.reloadAll(); } componentWillUnmount() { diff --git a/src/renderer/components/+workloads-statefulsets/statefulset-details.tsx b/src/renderer/components/+workloads-statefulsets/statefulset-details.tsx index 780572eb96..f1f86b6b5f 100644 --- a/src/renderer/components/+workloads-statefulsets/statefulset-details.tsx +++ b/src/renderer/components/+workloads-statefulsets/statefulset-details.tsx @@ -30,7 +30,7 @@ export class StatefulSetDetails extends React.Component { }); componentDidMount() { - podsStore.loadSelectedNamespaces(); + podsStore.reloadAll(); } componentWillUnmount() { diff --git a/src/renderer/components/app.tsx b/src/renderer/components/app.tsx index 2236b3d6be..8b7f8a527c 100755 --- a/src/renderer/components/app.tsx +++ b/src/renderer/components/app.tsx @@ -43,12 +43,13 @@ import { ClusterPageMenuRegistration, clusterPageMenuRegistry } from "../../exte import { TabLayout, TabLayoutRoute } from "./layout/tab-layout"; import { StatefulSetScaleDialog } from "./+workloads-statefulsets/statefulset-scale-dialog"; import { eventStore } from "./+events/event.store"; -import { namespaceStore } from "./+namespaces/namespace.store"; import { nodesStore } from "./+nodes/nodes.store"; import { podsStore } from "./+workloads-pods/pods.store"; import { kubeWatchApi } from "../api/kube-watch-api"; import { ReplicaSetScaleDialog } from "./+workloads-replicasets/replicaset-scale-dialog"; import { CommandContainer } from "./command-palette/command-container"; +import { KubeObjectStore } from "../kube-object.store"; +import { clusterContext } from "./context"; @observer export class App extends React.Component { @@ -76,11 +77,9 @@ export class App extends React.Component { }); whatInput.ask(); // Start to monitor user input device - await namespaceStore.whenReady; - await kubeWatchApi.init({ - getCluster: getHostedCluster, - getNamespaces: namespaceStore.getContextNamespaces, - }); + // Setup hosted cluster context + KubeObjectStore.defaultContext = clusterContext; + kubeWatchApi.context = clusterContext; } componentDidMount() { @@ -163,9 +162,9 @@ export class App extends React.Component { const tabRoutes = this.getTabLayoutRoutes(menu); if (tabRoutes.length > 0) { - const pageComponent = () => ; + const pageComponent = () => ; - route = tab.routePath)} />; + route = tab.routePath)}/>; this.extensionRoutes.set(menu, route); } else { const page = clusterPageRegistry.getByPageTarget(menu.target); @@ -229,7 +228,7 @@ export class App extends React.Component { - + ); diff --git a/src/renderer/components/button/button.scss b/src/renderer/components/button/button.scss index b850a3be59..9224b7e5d3 100644 --- a/src/renderer/components/button/button.scss +++ b/src/renderer/components/button/button.scss @@ -21,10 +21,16 @@ &.primary { background: $buttonPrimaryBackground; } + &.accent { background: $buttonAccentBackground; } + &.light { + background-color: $buttonLightBackground; + color: #505050; + } + &.plain { color: inherit; background: transparent; diff --git a/src/renderer/components/button/button.tsx b/src/renderer/components/button/button.tsx index 9fa822b214..8bcb37bad4 100644 --- a/src/renderer/components/button/button.tsx +++ b/src/renderer/components/button/button.tsx @@ -8,6 +8,7 @@ export interface ButtonProps extends ButtonHTMLAttributes, TooltipDecorator waiting?: boolean; primary?: boolean; accent?: boolean; + light?: boolean; plain?: boolean; outlined?: boolean; hidden?: boolean; @@ -24,13 +25,16 @@ export class Button extends React.PureComponent { private button: HTMLButtonElement; render() { - const { className, waiting, label, primary, accent, plain, hidden, active, big, round, outlined, tooltip, children, ...props } = this.props; - const btnProps = props as Partial; + const { + className, waiting, label, primary, accent, plain, hidden, active, big, + round, outlined, tooltip, light, children, ...props + } = this.props; + const btnProps: Partial = props; if (hidden) return null; 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 = ( diff --git a/src/renderer/components/context.ts b/src/renderer/components/context.ts new file mode 100755 index 0000000000..3c8c6d29e4 --- /dev/null +++ b/src/renderer/components/context.ts @@ -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 ?? []; + }, +}; diff --git a/src/renderer/components/dialog/dialog.tsx b/src/renderer/components/dialog/dialog.tsx index 232fee6475..81851616e8 100644 --- a/src/renderer/components/dialog/dialog.tsx +++ b/src/renderer/components/dialog/dialog.tsx @@ -146,11 +146,10 @@ export class Dialog extends React.PureComponent { {dialog} ); - } - else if (!this.isOpen) { + } else if (!this.isOpen) { return null; } - return createPortal(dialog, document.body); + return createPortal(dialog, document.body) as React.ReactPortal; } } diff --git a/src/renderer/components/dock/upgrade-chart.store.ts b/src/renderer/components/dock/upgrade-chart.store.ts index f609420d9d..63468f3180 100644 --- a/src/renderer/components/dock/upgrade-chart.store.ts +++ b/src/renderer/components/dock/upgrade-chart.store.ts @@ -80,7 +80,7 @@ export class UpgradeChartStore extends DockTabStore { const values = this.values.getData(tabId); await Promise.all([ - !releaseStore.isLoaded && releaseStore.loadSelectedNamespaces(), + !releaseStore.isLoaded && releaseStore.loadFromContextNamespaces(), !values && this.loadValues(tabId) ]); } diff --git a/src/renderer/components/item-object-list/item-list-layout.tsx b/src/renderer/components/item-object-list/item-list-layout.tsx index ac0d2ea635..5eeb7b2f11 100644 --- a/src/renderer/components/item-object-list/item-list-layout.tsx +++ b/src/renderer/components/item-object-list/item-list-layout.tsx @@ -38,6 +38,7 @@ interface IHeaderPlaceholders { export interface ItemListLayoutProps { tableId?: string; className: IClassName; + items?: T[]; store: ItemStore; dependentStores?: ItemStore[]; preloadStores?: boolean; @@ -138,7 +139,8 @@ export class ItemListLayout extends React.Component { const { store, dependentStores } = this.props; const stores = Array.from(new Set([store, ...dependentStores])); - stores.forEach(store => store.loadAll(namespaceStore.getContextNamespaces())); + // load context namespaces by default (see also: ``) + stores.forEach(store => store.loadAll(namespaceStore.contextNamespaces)); } private filterCallbacks: { [type: string]: ItemsFilter } = { @@ -179,11 +181,7 @@ export class ItemListLayout extends React.Component { @computed get filters() { let { activeFilters } = pageFilters; - const { isClusterScoped, isSearchable, searchFilters } = this.props; - - if (isClusterScoped) { - activeFilters = activeFilters.filter(({ type }) => type !== FilterType.NAMESPACE); - } + const { isSearchable, searchFilters } = this.props; if (!(isSearchable && searchFilters)) { activeFilters = activeFilters.filter(({ type }) => type !== FilterType.SEARCH); @@ -217,7 +215,9 @@ export class ItemListLayout extends React.Component { } }); - return this.applyFilters(filterItems, allItems); + const items = this.props.items ?? allItems; + + return this.applyFilters(filterItems, items); } @autobind() @@ -337,8 +337,8 @@ export class ItemListLayout extends React.Component { } renderInfo() { - const { allItems, items, isReady, userSettings, filters } = this; - const allItemsCount = allItems.length; + const { items, isReady, userSettings, filters } = this; + const allItemsCount = this.props.store.getTotalCount(); const itemsCount = items.length; const isFiltered = isReady && filters.length > 0; diff --git a/src/renderer/components/item-object-list/page-filters.store.ts b/src/renderer/components/item-object-list/page-filters.store.ts index 8f5fa2c9eb..933a94c06b 100644 --- a/src/renderer/components/item-object-list/page-filters.store.ts +++ b/src/renderer/components/item-object-list/page-filters.store.ts @@ -1,6 +1,5 @@ import { computed, observable, reaction } from "mobx"; import { autobind } from "../../utils"; -import { namespaceStore } from "../+namespaces/namespace.store"; import { searchUrlParam } from "../input/search-input-url"; export enum FilterType { @@ -24,32 +23,6 @@ export class PageFiltersStore { constructor() { 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() { diff --git a/src/renderer/components/kube-object/kube-object-list-layout.tsx b/src/renderer/components/kube-object/kube-object-list-layout.tsx index 226023fc8d..d8e78aa69d 100644 --- a/src/renderer/components/kube-object/kube-object-list-layout.tsx +++ b/src/renderer/components/kube-object/kube-object-list-layout.tsx @@ -8,6 +8,7 @@ import { KubeObjectStore } from "../../kube-object.store"; import { KubeObjectMenu } from "./kube-object-menu"; import { kubeSelectedUrlParam, showDetails } from "./kube-object-details"; import { kubeWatchApi } from "../../api/kube-watch-api"; +import { clusterContext } from "../context"; export interface KubeObjectListLayoutProps extends ItemListLayoutProps { store: KubeObjectStore; @@ -26,7 +27,8 @@ export class KubeObjectListLayout extends React.Component { async componentDidMount() { - crdStore.loadSelectedNamespaces(); + crdStore.reloadAll(); } renderCustomResources() { diff --git a/src/renderer/components/notifications/notifications.scss b/src/renderer/components/notifications/notifications.scss index 37d4990ee5..4b300edda6 100644 --- a/src/renderer/components/notifications/notifications.scss +++ b/src/renderer/components/notifications/notifications.scss @@ -42,5 +42,9 @@ box-shadow: 0 0 20px $boxShadow; } } + + .close { + margin-top: -2px; + } } } diff --git a/src/renderer/components/notifications/notifications.store.ts b/src/renderer/components/notifications/notifications.store.tsx similarity index 95% rename from src/renderer/components/notifications/notifications.store.ts rename to src/renderer/components/notifications/notifications.store.tsx index 55549b9066..45c1eb9a6b 100644 --- a/src/renderer/components/notifications/notifications.store.ts +++ b/src/renderer/components/notifications/notifications.store.tsx @@ -18,6 +18,7 @@ export interface Notification { message: NotificationMessage; status?: NotificationStatus; 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() diff --git a/src/renderer/components/notifications/notifications.tsx b/src/renderer/components/notifications/notifications.tsx index 4ab297e289..0c1ac692cf 100644 --- a/src/renderer/components/notifications/notifications.tsx +++ b/src/renderer/components/notifications/notifications.tsx @@ -72,23 +72,26 @@ export class Notifications extends React.Component { return (
this.elem = e}> {notifications.map(notification => { - const { id, status } = notification; + const { id, status, onClose } = notification; const msgText = this.getMessage(notification); return (
addAutoHideTimer(id)} onMouseEnter={() => removeAutoHideTimer(id)}> -
+
{msgText}
-
+
remove(id))} + onClick={prevDefault(() => { + remove(id); + onClose?.(); + })} />
diff --git a/src/renderer/ipc/index.tsx b/src/renderer/ipc/index.tsx new file mode 100644 index 0000000000..991d9b7616 --- /dev/null +++ b/src/renderer/ipc/index.tsx @@ -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
+ + ), { + id: notificationId, + onClose() { + sendToBackchannel(backchannel, notificationId, { doUpdate: false }); + } + } + ); +} + +export function registerIpcHandlers() { + onCorrect({ + source: ipcRenderer, + channel: UpdateAvailableChannel, + listener: UpdateAvailableHandler, + verifier: areArgsUpdateAvailableFromMain, + }); +} diff --git a/src/renderer/item.store.ts b/src/renderer/item.store.ts index eccd2b52df..a9ac3179c9 100644 --- a/src/renderer/item.store.ts +++ b/src/renderer/item.store.ts @@ -9,7 +9,7 @@ export interface ItemObject { @autobind() export abstract class ItemStore { - abstract loadAll(...args: any[]): Promise; + abstract loadAll(...args: any[]): Promise; protected defaultSorting = (item: T) => item.getName(); @@ -22,11 +22,23 @@ export abstract class ItemStore { 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): T { return this.items.find(item => item.getName() === name); } + getIndexById(id: string): number { + return this.items.findIndex(item => item.getId() === id); + } + @action protected sortItems(items: T[] = this.items, sorting?: ((item: T) => any)[], order?: "asc" | "desc"): T[] { return orderBy(items, sorting || this.defaultSorting, order); diff --git a/src/renderer/kube-object.store.ts b/src/renderer/kube-object.store.ts index d56e6bd912..9a2a3da4e3 100644 --- a/src/renderer/kube-object.store.ts +++ b/src/renderer/kube-object.store.ts @@ -1,8 +1,9 @@ -import type { Cluster } from "../main/cluster"; -import { action, observable, reaction } from "mobx"; +import type { ClusterContext } from "./components/context"; + +import { action, computed, observable, reaction, when } from "mobx"; import { autobind } from "./utils"; 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 { apiManager } from "./api/api-manager"; import { IKubeApiQueryParams, KubeApi, parseKubeApi } from "./api/kube-api"; @@ -15,15 +16,38 @@ export interface KubeObjectStoreLoadingParams { @autobind() export abstract class KubeObjectStore extends ItemStore { + @observable static defaultContext: ClusterContext; // TODO: support multiple cluster contexts + abstract api: KubeApi; public readonly limit?: number; public readonly bufferSize: number = 50000; + private loadedNamespaces: string[] = []; + + contextReady = when(() => Boolean(this.context)); constructor() { super(); 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 { const { limit } = this; @@ -79,23 +103,25 @@ export abstract class KubeObjectStore extends ItemSt } } - protected async resolveCluster(): Promise { - const { getHostedCluster } = await import("../common/cluster-store"); - - return getHostedCluster(); - } - protected async loadItems({ namespaces, api }: KubeObjectStoreLoadingParams): Promise { - 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)) { - if (api.isNamespaced) { - return Promise + const isLoadingAll = this.context.allNamespaces.every(ns => namespaces.includes(ns)); + + 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 }))) .then(items => items.flat()); } - - return api.list({}, this.query); } return []; @@ -106,24 +132,25 @@ export abstract class KubeObjectStore extends ItemSt } @action - async loadAll(namespaces: string[] = []): Promise { + async loadAll(options: { namespaces?: string[], merge?: boolean } = {}): Promise { + await this.contextReady; this.isLoading = true; try { - if (!namespaces.length) { - const { namespaceStore } = await import("./components/+namespaces/namespace.store"); + const { + 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 - namespaces.push(...namespaceStore.allowedNamespaces); - } + const items = await this.loadItems({ namespaces, api: this.api }); - let items = await this.loadItems({ namespaces, api: this.api }); - - items = this.filterItemsOnLoad(items); - items = this.sortItems(items); - - this.items.replace(items); this.isLoaded = true; + + if (merge) { + this.mergeItems(items, { replace: false }); + } else { + return items; + } } catch (error) { console.error("Loading store items failed", { error, store: this }); this.resetOnError(error); @@ -132,10 +159,36 @@ export abstract class KubeObjectStore extends ItemSt } } - async loadSelectedNamespaces(): Promise { - const { namespaceStore } = await import("./components/+namespaces/namespace.store"); + @action + 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) { @@ -204,12 +257,7 @@ export abstract class KubeObjectStore extends ItemSt protected eventsBuffer = observable.array>([], { deep: false }); protected bindWatchEventsUpdater(delay = 1000) { - kubeWatchApi.onMessage.addListener(({ store, data }: IKubeWatchMessage) => { - if (!this.isLoaded || store !== this) return; - this.eventsBuffer.push(data); - }); - - reaction(() => this.eventsBuffer.length > 0, this.updateFromEventsBuffer, { + reaction(() => this.eventsBuffer.length, this.updateFromEventsBuffer, { delay }); } @@ -219,7 +267,31 @@ export abstract class KubeObjectStore extends ItemSt } 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 @@ -239,7 +311,7 @@ export abstract class KubeObjectStore extends ItemSt if (!item) { items.push(newItem); } else { - items.splice(index, 1, newItem); + items[index] = newItem; } break; case "DELETED": diff --git a/src/renderer/lens-app.tsx b/src/renderer/lens-app.tsx index d588206c01..88a59de91d 100644 --- a/src/renderer/lens-app.tsx +++ b/src/renderer/lens-app.tsx @@ -13,6 +13,7 @@ import { extensionLoader } from "../extensions/extension-loader"; import { broadcastMessage } from "../common/ipc"; import { CommandContainer } from "./components/command-palette/command-container"; import { LensProtocolRouterRenderer } from "./protocol-handler/router"; +import { registerIpcHandlers } from "./ipc"; @observer export class LensApp extends React.Component { @@ -25,6 +26,8 @@ export class LensApp extends React.Component { window.addEventListener("online", () => { broadcastMessage("network:online"); }); + + registerIpcHandlers(); } render() { diff --git a/src/renderer/themes/lens-dark.json b/src/renderer/themes/lens-dark.json index 4a7a0f2f70..0a56f851ec 100644 --- a/src/renderer/themes/lens-dark.json +++ b/src/renderer/themes/lens-dark.json @@ -25,6 +25,7 @@ "sidebarSubmenuActiveColor": "#ffffff", "buttonPrimaryBackground": "#3d90ce", "buttonDefaultBackground": "#414448", + "buttonLightBackground": "#f1f1f1", "buttonAccentBackground": "#e85555", "buttonDisabledBackground": "#808080", "tableBgcStripe": "#2a2d33", diff --git a/src/renderer/themes/lens-light.json b/src/renderer/themes/lens-light.json index f59d3c9555..e65722b889 100644 --- a/src/renderer/themes/lens-light.json +++ b/src/renderer/themes/lens-light.json @@ -26,6 +26,7 @@ "sidebarBackground": "#e8e8e8", "buttonPrimaryBackground": "#3d90ce", "buttonDefaultBackground": "#414448", + "buttonLightBackground": "#f1f1f1", "buttonAccentBackground": "#e85555", "buttonDisabledBackground": "#808080", "tableBgcStripe": "#f8f8f8", diff --git a/src/renderer/themes/theme-vars.scss b/src/renderer/themes/theme-vars.scss index 6b556bdc88..bb897818f5 100644 --- a/src/renderer/themes/theme-vars.scss +++ b/src/renderer/themes/theme-vars.scss @@ -34,6 +34,7 @@ $sidebarBackground: var(--sidebarBackground); // Elements $buttonPrimaryBackground: var(--buttonPrimaryBackground); $buttonDefaultBackground: var(--buttonDefaultBackground); +$buttonLightBackground: var(--buttonLightBackground); $buttonAccentBackground: var(--buttonAccentBackground); $buttonDisabledBackground: var(--buttonDisabledBackground); @@ -131,4 +132,4 @@ $selectOptionHoveredColor: var(--selectOptionHoveredColor); $lineProgressBackground: var(--lineProgressBackground); $radioActiveBackground: var(--radioActiveBackground); $menuActiveBackground: var(--menuActiveBackground); -$menuSelectedOptionBgc: var(--menuSelectedOptionBgc); \ No newline at end of file +$menuSelectedOptionBgc: var(--menuSelectedOptionBgc); diff --git a/static/RELEASE_NOTES.md b/static/RELEASE_NOTES.md index adbc18c6cf..b40eb828bc 100644 --- a/static/RELEASE_NOTES.md +++ b/static/RELEASE_NOTES.md @@ -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! -## 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) - Command palette - Generic logs view with Pod selector +- In-app survey extension - Possibility to add custom Helm repository through Lens - Possibility to change visibility of common resource list columns - 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 - Update bundled kubectl to v1.18.15 - Improve how watch requests are handled +- Helm rollback window with more details +- Log more on start up - Export PodDetailsList component to extension API - Export Wizard components to extension API - Export NamespaceSelect component to extension API diff --git a/webpack.extensions.ts b/webpack.extensions.ts index 0c2829a406..1b9c812868 100644 --- a/webpack.extensions.ts +++ b/webpack.extensions.ts @@ -1,9 +1,9 @@ import path from "path"; 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 outDir = "./src/extensions/npm/extensions/dist"; @@ -22,6 +22,10 @@ export default function (): webpack.Configuration { // e.g. require('@k8slens/extensions') libraryTarget: "commonjs" }, + cache: isDevelopment, + optimization: { + minimize: false, // speed up types compilation + }, module: { rules: [ { diff --git a/yarn.lock b/yarn.lock index cbebf5d478..b09b949135 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1079,6 +1079,13 @@ resolved "https://registry.yarnpkg.com/@types/boom/-/boom-7.3.0.tgz#33280c5552d4cfabc21b8b7e0f6d29292decd985" 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@*": version "0.12.2" resolved "https://registry.yarnpkg.com/@types/caseless/-/caseless-0.12.2.tgz#f65d3d6389e01eeb458bd54dc8f52b95a9463bc8" @@ -1608,6 +1615,14 @@ "@types/prop-types" "*" 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@*": version "0.2.28" 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" 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: version "1.1.0" 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: 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: version "5.1.2" resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d" 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: version "1.1.0" resolved "https://registry.yarnpkg.com/safe-regex/-/safe-regex-1.1.0.tgz#40a3669f3b077d1e943d44629e157dd48023bf2e"