From e6c90c28cc2a7f3a768d2c5f683b7f791fef6993 Mon Sep 17 00:00:00 2001 From: Lauri Nevala Date: Thu, 4 Feb 2021 19:44:28 +0200 Subject: [PATCH 1/9] Return no metrics immediately if chartData.datasets are missing (#2082) Signed-off-by: Lauri Nevala --- src/renderer/components/chart/bar-chart.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/renderer/components/chart/bar-chart.tsx b/src/renderer/components/chart/bar-chart.tsx index 19ef031ba6..5c3e134664 100644 --- a/src/renderer/components/chart/bar-chart.tsx +++ b/src/renderer/components/chart/bar-chart.tsx @@ -50,6 +50,10 @@ export class BarChart extends React.Component { }) }; + if (chartData.datasets.length == 0) { + return ; + } + const formatTimeLabels = (timestamp: string, index: number) => { const label = moment(parseInt(timestamp)).format("HH:mm"); const offset = " "; @@ -143,10 +147,6 @@ export class BarChart extends React.Component { }; const options = merge(barOptions, customOptions); - if (chartData.datasets.length == 0) { - return ; - } - return ( Date: Thu, 4 Feb 2021 13:16:06 -0500 Subject: [PATCH 2/9] add custom configuration for dependabot (#1973) Signed-off-by: Sebastian Malton --- .dependabot/config.yml | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 .dependabot/config.yml diff --git a/.dependabot/config.yml b/.dependabot/config.yml new file mode 100644 index 0000000000..a77a36c653 --- /dev/null +++ b/.dependabot/config.yml @@ -0,0 +1,17 @@ +# See https://docs.github.com/en/free-pro-team@latest/github/administering-a-repository/configuration-options-for-dependency-updates +# for config options + +version: 2 +updates: + - package-ecosystem: "npm" + directory: "/" + schedule: + interval: "daily" + open-pull-requests-limit: 4 + reviewers: + - "lensapp/lens-maintainers" + labels: + - "dependencies" + versioning-strategy: + lockfile-only: false + increase: true From b36d9ff41831f05416391db667eaea82aade0491 Mon Sep 17 00:00:00 2001 From: Violetta <38247153+vshakirova@users.noreply.github.com> Date: Fri, 5 Feb 2021 21:23:15 +0400 Subject: [PATCH 3/9] Helm rollback window with more details (#2085) Signed-off-by: vshakirova --- src/renderer/api/endpoints/helm-releases.api.ts | 1 + .../components/+apps-releases/release-rollback-dialog.tsx | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) 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/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} /> From 06b61c3392d8555bc05e9879b4568860ea9c8ee0 Mon Sep 17 00:00:00 2001 From: Alex Andreev Date: Mon, 8 Feb 2021 21:02:05 +0300 Subject: [PATCH 4/9] Make macOs app icon a bit smaller (#2094) Signed-off-by: Alex Andreev --- build/icons/512x512.png | Bin 10880 -> 23156 bytes build/icons/512x512@2x.png | Bin 0 -> 52406 bytes 2 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 build/icons/512x512@2x.png diff --git a/build/icons/512x512.png b/build/icons/512x512.png index 2c953f6efd12d7c8e8b597e8bb6ea7aca598bedf..e08b9f5b156edf40d862a027b068fa1dd618c05b 100644 GIT binary patch literal 23156 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 Lzl1F@^N9Q(oN*GU literal 10880 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 H7!m&ia8liC diff --git a/build/icons/512x512@2x.png b/build/icons/512x512@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..da0aa8ae806029c3c3d5db12fbd0782bf41cacca GIT binary patch literal 52406 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 Date: Tue, 9 Feb 2021 13:09:50 +0200 Subject: [PATCH 5/9] Release v4.1.0-beta.1 (#2101) Signed-off-by: Jari Kolehmainen --- package.json | 2 +- static/RELEASE_NOTES.md | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 6d42bd99be..31464da169 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", 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 From 035dd470ef679d92e8ef69bfdfd2117e1f877320 Mon Sep 17 00:00:00 2001 From: Jari Kolehmainen Date: Tue, 9 Feb 2021 15:31:15 +0200 Subject: [PATCH 6/9] Refactor watches to use native k8s api (#2095) * fix lint Signed-off-by: Roman * fixes & refactoring Signed-off-by: Roman * fix lint, micro-refactoring Signed-off-by: Roman * more refactoring, clean up, responding to comments Signed-off-by: Roman * fix: remove extra check for cluster.allowedApi from processing buffered watch-api events Signed-off-by: Roman * refactoring, detaching NamespaceStore from KubeObjectStore Signed-off-by: Roman * fix: wait for contextReady in NamespaceStore Signed-off-by: Roman * refactoring & fixes Signed-off-by: Roman * fix lint Signed-off-by: Roman * fixes: reloading context stores on NamespaceSelect-change Signed-off-by: Roman * optimize loading all resources when "all namespaces" selected -> single request per resource (when have rights) Signed-off-by: Roman * use native k8s api watches Signed-off-by: Jari Kolehmainen * retry watch when it makes sense Signed-off-by: Jari Kolehmainen * workaround for browser connection limits Signed-off-by: Jari Kolehmainen * cleanup Signed-off-by: Jari Kolehmainen * cleanup Signed-off-by: Jari Kolehmainen * use always random subdomain for getResponse Signed-off-by: Jari Kolehmainen * resubscribe stores on contextNamespace change Signed-off-by: Jari Kolehmainen * fix Signed-off-by: Jari Kolehmainen * modify watch event before calling callback Signed-off-by: Jari Kolehmainen Co-authored-by: Roman --- package.json | 3 + src/main/lens-proxy.ts | 3 +- src/main/router.ts | 5 +- src/main/routes/index.ts | 1 - src/main/routes/watch-route.ts | 162 --------- src/renderer/api/json-api.ts | 30 +- src/renderer/api/kube-api.ts | 95 ++++- src/renderer/api/kube-watch-api.ts | 331 +++--------------- .../+apps-releases/release.store.ts | 10 +- .../+custom-resources/crd-resources.tsx | 4 +- .../components/+events/kube-event-details.tsx | 2 +- .../+namespaces/namespace-details.tsx | 4 +- .../+namespaces/namespace-select.tsx | 2 +- .../components/+namespaces/namespace.store.ts | 39 +-- .../components/+nodes/node-details.tsx | 2 +- .../add-role-binding-dialog.tsx | 12 +- .../+workloads-cronjobs/cronjob-details.tsx | 2 +- .../daemonset-details.tsx | 2 +- .../deployment-details.tsx | 2 +- .../+workloads-jobs/job-details.tsx | 2 +- .../+workloads-overview/overview-statuses.tsx | 4 +- .../+workloads-overview/overview.tsx | 2 + .../replicaset-details.tsx | 2 +- .../statefulset-details.tsx | 2 +- src/renderer/components/app.tsx | 17 +- src/renderer/components/context.ts | 23 ++ .../components/dock/upgrade-chart.store.ts | 2 +- .../item-object-list/item-list-layout.tsx | 18 +- .../item-object-list/page-filters.store.ts | 27 -- .../kube-object/kube-object-list-layout.tsx | 6 +- src/renderer/components/layout/sidebar.tsx | 2 +- src/renderer/item.store.ts | 14 +- src/renderer/kube-object.store.ts | 148 ++++++-- yarn.lock | 33 +- 34 files changed, 414 insertions(+), 599 deletions(-) delete mode 100644 src/main/routes/watch-route.ts create mode 100755 src/renderer/components/context.ts diff --git a/package.json b/package.json index 31464da169..732b89c8cb 100644 --- a/package.json +++ b/package.json @@ -189,6 +189,7 @@ "@kubernetes/client-node": "^0.12.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", @@ -221,6 +222,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", @@ -242,6 +244,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/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/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.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..6274a94f30 100644 --- a/src/renderer/components/+workloads-overview/overview-statuses.tsx +++ b/src/renderer/components/+workloads-overview/overview-statuses.tsx @@ -6,7 +6,6 @@ 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"; @@ -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/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/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/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/yarn.lock b/yarn.lock index 114e2f8b5a..cd3384eba5 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" @@ -11449,6 +11464,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" @@ -11867,16 +11890,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" From 741973dd29c463b8aca28553d2a537303d1c0b6d Mon Sep 17 00:00:00 2001 From: Roman Date: Tue, 9 Feb 2021 17:33:27 +0200 Subject: [PATCH 7/9] Fix: export Dialog to extensions-api (#2105) Signed-off-by: Roman --- package.json | 2 +- src/renderer/components/dialog/dialog.tsx | 5 ++--- webpack.extensions.ts | 8 ++++++-- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 732b89c8cb..27721b6e7a 100644 --- a/package.json +++ b/package.json @@ -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", 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/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: [ { From a61425124f18b1cc2d8a507084a472029acc3e6b Mon Sep 17 00:00:00 2001 From: Sebastian Malton Date: Tue, 9 Feb 2021 10:47:24 -0500 Subject: [PATCH 8/9] Add auto-update notifications and confirmation (#1941) * add auto-update notifications and confirmation * Show single update notification (#1985) * Moving notification icons to top (#1987) * Switch to EventEmitter (producer&consumer) model * Add `onCorrect` and `onceCorrect` to ipc module for typechecking ipc messages * move type enforced ipc methods to seperate file, add unit tests Signed-off-by: Jari Kolehmainen Signed-off-by: Alex Andreev Signed-off-by: Sebastian Malton --- .../capabilities/color-reference.md | 1 + .../ipc/__tests__/type-enforced-ipc.test.ts | 126 ++++++++++++++++++ src/common/ipc/index.ts | 3 + src/common/{ => ipc}/ipc.ts | 6 +- src/common/ipc/type-enforced-ipc.ts | 71 ++++++++++ src/common/ipc/update-available/index.ts | 48 +++++++ src/common/utils/delay.ts | 12 +- src/common/utils/index.ts | 1 + src/main/app-updater.ts | 90 ++++++++++--- src/main/index.ts | 8 +- src/main/tray.ts | 16 +-- src/main/window-manager.ts | 6 +- .../+workloads-overview/overview-statuses.tsx | 2 +- src/renderer/components/button/button.scss | 6 + src/renderer/components/button/button.tsx | 10 +- .../notifications/notifications.scss | 4 + ...tions.store.ts => notifications.store.tsx} | 1 + .../notifications/notifications.tsx | 13 +- src/renderer/ipc/index.tsx | 61 +++++++++ src/renderer/lens-app.tsx | 3 + src/renderer/themes/lens-dark.json | 1 + src/renderer/themes/lens-light.json | 1 + src/renderer/themes/theme-vars.scss | 3 +- 23 files changed, 440 insertions(+), 53 deletions(-) create mode 100644 src/common/ipc/__tests__/type-enforced-ipc.test.ts create mode 100644 src/common/ipc/index.ts rename src/common/{ => ipc}/ipc.ts (90%) create mode 100644 src/common/ipc/type-enforced-ipc.ts create mode 100644 src/common/ipc/update-available/index.ts rename src/renderer/components/notifications/{notifications.store.ts => notifications.store.tsx} (95%) create mode 100644 src/renderer/ipc/index.tsx 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/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 c2f8562cf7..48b0b89153 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 208e042759..7d0686d29b 100644 --- a/src/common/utils/delay.ts +++ b/src/common/utils/delay.ts @@ -1,6 +1,8 @@ -// Create async delay for provided timeout in milliseconds - -export async function delay(timeoutMs = 1000) { - if (!timeoutMs) return; - await new Promise(resolve => setTimeout(resolve, timeoutMs)); +/** + * Return a promise that will be resolved after at least `timeout` ms have + * passed + * @param timeout The number of milliseconds before resolving + */ +export function delay(timeout = 1000): Promise { + return new Promise(resolve => setTimeout(resolve, timeout)); } diff --git a/src/common/utils/index.ts b/src/common/utils/index.ts index 942c675f0a..2b8147fad9 100644 --- a/src/common/utils/index.ts +++ b/src/common/utils/index.ts @@ -18,3 +18,4 @@ export * from "./openExternal"; export * from "./downloadFile"; export * from "./escapeRegExp"; export * from "./tar"; +export * from "./delay"; 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 af24026a5e..50571a8862 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"; @@ -28,6 +27,7 @@ import { installDeveloperTools } from "./developer-tools"; import { filesystemProvisionerStore } from "./extension-filesystem"; 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; @@ -72,11 +72,6 @@ app.on("ready", async () => { app.exit(); }); - logger.info(`📡 Checking for app updates`); - const updater = new AppUpdater(); - - updater.start(); - registerFileProtocol("static", __static); await installDeveloperTools(); @@ -133,6 +128,7 @@ app.on("ready", async () => { logger.info("🖥️ Starting WindowManager"); windowManager = WindowManager.getInstance(proxyPort); + windowManager.whenLoaded.then(() => startUpdateChecking()); logger.info("🧩 Initializing extensions"); 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/main/window-manager.ts b/src/main/window-manager.ts index 691aa7c66b..c092e186cb 100644 --- a/src/main/window-manager.ts +++ b/src/main/window-manager.ts @@ -1,5 +1,5 @@ import type { ClusterId } from "../common/cluster-store"; -import { observable } from "mobx"; +import { observable, when } from "mobx"; import { app, BrowserWindow, dialog, shell, webContents } from "electron"; import windowStateKeeper from "electron-window-state"; import { appEventBus } from "../common/event-bus"; @@ -16,6 +16,9 @@ export class WindowManager extends Singleton { protected windowState: windowStateKeeper.State; protected disposers: Record = {}; + @observable mainViewInitiallyLoaded = false; + whenLoaded = when(() => this.mainViewInitiallyLoaded); + @observable activeClusterId: ClusterId; constructor(protected proxyPort: number) { @@ -101,6 +104,7 @@ export class WindowManager extends Singleton { setTimeout(() => { appEventBus.emit({ name: "app", action: "start" }); }, 1000); + this.mainViewInitiallyLoaded = true; } catch (err) { dialog.showErrorBox("ERROR!", err.toString()); } diff --git a/src/renderer/components/+workloads-overview/overview-statuses.tsx b/src/renderer/components/+workloads-overview/overview-statuses.tsx index 6274a94f30..1441115ed8 100644 --- a/src/renderer/components/+workloads-overview/overview-statuses.tsx +++ b/src/renderer/components/+workloads-overview/overview-statuses.tsx @@ -8,7 +8,7 @@ import { workloadURL, workloadStores } from "../+workloads"; import { namespaceStore } from "../+namespaces/namespace.store"; 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[] = [ 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/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/lens-app.tsx b/src/renderer/lens-app.tsx index 5c0dd79a95..963bd43e4e 100644 --- a/src/renderer/lens-app.tsx +++ b/src/renderer/lens-app.tsx @@ -12,6 +12,7 @@ import { ConfirmDialog } from "./components/confirm-dialog"; import { extensionLoader } from "../extensions/extension-loader"; import { broadcastMessage } from "../common/ipc"; import { CommandContainer } from "./components/command-palette/command-container"; +import { registerIpcHandlers } from "./ipc"; @observer export class LensApp extends React.Component { @@ -23,6 +24,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); From 2b22ec0aa31ec279fd324e830f8715f684031391 Mon Sep 17 00:00:00 2001 From: Alex Andreev Date: Tue, 9 Feb 2021 18:52:33 +0300 Subject: [PATCH 9/9] Fix: saving apiManager store keys with string type (#2091) * Saving apiManager store keys with string type Signed-off-by: Alex Andreev * Replacing stores key from "kind" to "apiBase" Signed-off-by: Alex Andreev --- src/renderer/api/api-manager.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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; } }