diff --git a/docs/getting-started/README.md b/docs/getting-started/README.md index 23a7cac9ed..ed6537bd76 100644 --- a/docs/getting-started/README.md +++ b/docs/getting-started/README.md @@ -5,7 +5,7 @@ Lens is lightweight and simple to install. You'll be up and running in just a fe ## System Requirements -Review the [System Requirements](/supporting/requirements/) to check if your computer configuration is supported. +Review the [System Requirements](../supporting/requirements.md) to check if your computer configuration is supported. ## macOS diff --git a/extensions/survey/renderer.tsx b/extensions/survey/renderer.tsx index e8b2e4758c..c740015634 100644 --- a/extensions/survey/renderer.tsx +++ b/extensions/survey/renderer.tsx @@ -15,7 +15,10 @@ export default class SurveyRendererExtension extends LensRendererExtension { } ]; async onActivate() { - await surveyPreferencesStore.loadExtension(this); - survey.start(); + // Activate extension only on main renderer + if (window.location.hostname === "localhost") { + await surveyPreferencesStore.loadExtension(this); + survey.start(); + } } } diff --git a/package.json b/package.json index 8ee3c9726a..403d7c0d0a 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-beta.1", + "version": "4.1.0", "main": "static/build/main.js", "copyright": "© 2020, Mirantis, Inc.", "license": "MIT", @@ -231,7 +231,7 @@ "react": "^17.0.1", "react-dom": "^17.0.1", "react-router": "^5.2.0", - "readable-web-to-node-stream": "^3.0.1", + "readable-stream": "^3.6.0", "request": "^2.88.2", "request-promise-native": "^1.0.8", "semver": "^7.3.2", @@ -287,6 +287,7 @@ "@types/react-router-dom": "^5.1.6", "@types/react-select": "^3.0.13", "@types/react-window": "^1.8.2", + "@types/readable-stream": "^2.3.9", "@types/request": "^2.48.5", "@types/request-promise-native": "^1.0.17", "@types/semver": "^7.2.0", @@ -369,7 +370,7 @@ "webpack-dev-server": "^3.11.0", "webpack-node-externals": "^1.7.2", "what-input": "^5.2.10", - "xterm": "^4.6.0", + "xterm": "^4.10.0", "xterm-addon-fit": "^0.4.0" } } diff --git a/src/common/__tests__/cluster-store.test.ts b/src/common/__tests__/cluster-store.test.ts index fcbd5ebd6b..6ee34388a2 100644 --- a/src/common/__tests__/cluster-store.test.ts +++ b/src/common/__tests__/cluster-store.test.ts @@ -2,7 +2,7 @@ import fs from "fs"; import mockFs from "mock-fs"; import yaml from "js-yaml"; import { Cluster } from "../../main/cluster"; -import { ClusterStore } from "../cluster-store"; +import { ClusterStore, getClusterIdFromHost } from "../cluster-store"; import { workspaceStore } from "../workspace-store"; const testDataIcon = fs.readFileSync("test-data/cluster-store-migration-icon.png"); @@ -446,3 +446,27 @@ describe("pre 3.6.0-beta.1 config with an existing cluster", () => { expect(icon.startsWith("data:;base64,")).toBe(true); }); }); + +describe("getClusterIdFromHost", () => { + const clusterFakeId = "fe540901-0bd6-4f6c-b472-bce1559d7c4a"; + + it("should return undefined for non cluster frame hosts", () => { + expect(getClusterIdFromHost("localhost:45345")).toBeUndefined(); + }); + + it("should return ClusterId for cluster frame hosts", () => { + expect(getClusterIdFromHost(`${clusterFakeId}.localhost:59110`)).toBe(clusterFakeId); + }); + + it("should return ClusterId for cluster frame hosts with additional subdomains", () => { + expect(getClusterIdFromHost(`abc.${clusterFakeId}.localhost:59110`)).toBe(clusterFakeId); + expect(getClusterIdFromHost(`abc.def.${clusterFakeId}.localhost:59110`)).toBe(clusterFakeId); + expect(getClusterIdFromHost(`abc.def.ghi.${clusterFakeId}.localhost:59110`)).toBe(clusterFakeId); + expect(getClusterIdFromHost(`abc.def.ghi.jkl.${clusterFakeId}.localhost:59110`)).toBe(clusterFakeId); + expect(getClusterIdFromHost(`abc.def.ghi.jkl.mno.${clusterFakeId}.localhost:59110`)).toBe(clusterFakeId); + expect(getClusterIdFromHost(`abc.def.ghi.jkl.mno.pqr.${clusterFakeId}.localhost:59110`)).toBe(clusterFakeId); + expect(getClusterIdFromHost(`abc.def.ghi.jkl.mno.pqr.stu.${clusterFakeId}.localhost:59110`)).toBe(clusterFakeId); + expect(getClusterIdFromHost(`abc.def.ghi.jkl.mno.pqr.stu.vwx.${clusterFakeId}.localhost:59110`)).toBe(clusterFakeId); + expect(getClusterIdFromHost(`abc.def.ghi.jkl.mno.pqr.stu.vwx.yz.${clusterFakeId}.localhost:59110`)).toBe(clusterFakeId); + }); +}); diff --git a/src/common/cluster-store.ts b/src/common/cluster-store.ts index d8bd28f1e8..4000684d16 100644 --- a/src/common/cluster-store.ts +++ b/src/common/cluster-store.ts @@ -354,10 +354,11 @@ export class ClusterStore extends BaseStore { export const clusterStore = ClusterStore.getInstance(); -export function getClusterIdFromHost(hostname: string): ClusterId { - const subDomains = hostname.split(":")[0].split("."); +export function getClusterIdFromHost(host: string): ClusterId | undefined { + // e.g host == "%clusterId.localhost:45345" + const subDomains = host.split(":")[0].split("."); - return subDomains.slice(-2)[0]; // e.g host == "%clusterId.localhost:45345" + return subDomains.slice(-2, -1)[0]; // ClusterId or undefined } export function getClusterFrameUrl(clusterId: ClusterId) { @@ -365,7 +366,7 @@ export function getClusterFrameUrl(clusterId: ClusterId) { } export function getHostedClusterId() { - return getClusterIdFromHost(location.hostname); + return getClusterIdFromHost(location.host); } export function getHostedCluster(): Cluster { diff --git a/src/main/app-updater.ts b/src/main/app-updater.ts index 9a3eaa8ae8..3145d74595 100644 --- a/src/main/app-updater.ts +++ b/src/main/app-updater.ts @@ -10,9 +10,8 @@ function handleAutoUpdateBackChannel(event: Electron.IpcMainEvent, ...[arg]: Upd 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 })); + autoUpdater.on("update-downloaded", () => autoUpdater.quitAndInstall()); + autoUpdater.downloadUpdate().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; diff --git a/src/main/cluster.ts b/src/main/cluster.ts index cd92ab2650..13c74a285e 100644 --- a/src/main/cluster.ts +++ b/src/main/cluster.ts @@ -399,6 +399,7 @@ export class Cluster implements ClusterModel, ClusterState { this.accessible = false; this.ready = false; this.activated = false; + this.allowedNamespaces = []; this.resourceAccessStatuses.clear(); this.pushState(); } diff --git a/src/main/index.ts b/src/main/index.ts index 38e072d8c3..ddee53bfea 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -4,7 +4,7 @@ import "../common/system-ca"; import "../common/prometheus-providers"; import * as Mobx from "mobx"; import * as LensExtensions from "../extensions/core-api"; -import { app, dialog, ipcMain, powerMonitor } from "electron"; +import { app, autoUpdater, ipcMain, dialog, powerMonitor } from "electron"; import { appName } from "../common/vars"; import path from "path"; import { LensProxy } from "./lens-proxy"; @@ -204,14 +204,25 @@ app.on("activate", (event, hasVisibleWindows) => { } }); +/** + * This variable should is used so that `autoUpdater.installAndQuit()` works + */ +let blockQuit = true; + +autoUpdater.on("before-quit-for-update", () => blockQuit = false); + app.on("will-quit", (event) => { // Quit app on Cmd+Q (MacOS) logger.info("APP:QUIT"); appEventBus.emit({name: "app", action: "close"}); - event.preventDefault(); // prevent app's default shutdown (e.g. required for telemetry, etc.) + clusterManager?.stop(); // close cluster connections - return; // skip exit to make tray work, to quit go to app's global menu or tray's menu + if (blockQuit) { + event.preventDefault(); // prevent app's default shutdown (e.g. required for telemetry, etc.) + + return; // skip exit to make tray work, to quit go to app's global menu or tray's menu + } }); app.on("open-url", (event, rawUrl) => { diff --git a/src/main/shell-session.ts b/src/main/shell-session.ts index be04649a31..9e5af371f7 100644 --- a/src/main/shell-session.ts +++ b/src/main/shell-session.ts @@ -108,7 +108,7 @@ export class ShellSession extends EventEmitter { if(isWindows) { env["SystemRoot"] = process.env.SystemRoot; - env["PTYSHELL"] = "powershell.exe"; + env["PTYSHELL"] = process.env.SHELL || "powershell.exe"; env["PATH"] = pathStr; } else if(typeof(process.env.SHELL) != "undefined") { env["PTYSHELL"] = process.env.SHELL; diff --git a/src/renderer/api/__tests__/api-manager.test.ts b/src/renderer/api/__tests__/api-manager.test.ts new file mode 100644 index 0000000000..29ce7bd189 --- /dev/null +++ b/src/renderer/api/__tests__/api-manager.test.ts @@ -0,0 +1,40 @@ +import { ingressStore } from "../../components/+network-ingresses/ingress.store"; +import { apiManager } from "../api-manager"; +import { KubeApi } from "../kube-api"; + +class TestApi extends KubeApi { + + protected async checkPreferredVersion() { + return; + } +} + +describe("ApiManager", () => { + describe("registerApi", () => { + it("re-register store if apiBase changed", async () => { + const apiBase = "apis/v1/foo"; + const fallbackApiBase = "/apis/extensions/v1beta1/foo"; + const kubeApi = new TestApi({ + apiBase, + fallbackApiBases: [fallbackApiBase], + checkPreferredVersion: true, + }); + + apiManager.registerApi(apiBase, kubeApi); + + // Define to use test api for ingress store + Object.defineProperty(ingressStore, "api", { value: kubeApi }); + apiManager.registerStore(ingressStore, [kubeApi]); + + // Test that store is returned with original apiBase + expect(apiManager.getStore(kubeApi)).toBe(ingressStore); + + // Change apiBase similar as checkPreferredVersion does + Object.defineProperty(kubeApi, "apiBase", { value: fallbackApiBase }); + apiManager.registerApi(fallbackApiBase, kubeApi); + + // Test that store is returned with new apiBase + expect(apiManager.getStore(kubeApi)).toBe(ingressStore); + }); + }); +}); diff --git a/src/renderer/api/api-manager.ts b/src/renderer/api/api-manager.ts index a802851f65..90e63f692b 100644 --- a/src/renderer/api/api-manager.ts +++ b/src/renderer/api/api-manager.ts @@ -23,6 +23,12 @@ export class ApiManager { registerApi(apiBase: string, api: KubeApi) { if (!this.apis.has(apiBase)) { + this.stores.forEach((store) => { + if(store.api === api) { + this.stores.set(apiBase, store); + } + }); + this.apis.set(apiBase, api); } } diff --git a/src/renderer/api/endpoints/events.api.ts b/src/renderer/api/endpoints/events.api.ts index df9aa540a4..4fb21aaaa5 100644 --- a/src/renderer/api/endpoints/events.api.ts +++ b/src/renderer/api/endpoints/events.api.ts @@ -28,7 +28,7 @@ export class KubeEvent extends KubeObject { firstTimestamp: string; lastTimestamp: string; count: number; - type: string; + type: "Normal" | "Warning" | string; eventTime: null; reportingComponent: string; reportingInstance: string; diff --git a/src/renderer/api/kube-api.ts b/src/renderer/api/kube-api.ts index 0be497a8f1..a880cc2406 100644 --- a/src/renderer/api/kube-api.ts +++ b/src/renderer/api/kube-api.ts @@ -8,10 +8,10 @@ import { apiManager } from "./api-manager"; import { apiKube } from "./index"; import { createKubeApiURL, parseKubeApi } from "./kube-api-parse"; import { KubeJsonApi, KubeJsonApiData, KubeJsonApiDataList } from "./kube-json-api"; -import { IKubeObjectConstructor, KubeObject } from "./kube-object"; +import { IKubeObjectConstructor, KubeObject, KubeStatus } from "./kube-object"; import byline from "byline"; -import { ReadableWebToNodeStream } from "readable-web-to-node-stream"; import { IKubeWatchEvent } from "./kube-watch-api"; +import { ReadableWebToNodeStream } from "../utils/readableStream"; export interface IKubeApiOptions { /** @@ -93,9 +93,11 @@ export function ensureObjectSelfLink(api: KubeApi, object: KubeJsonApiData) { } } -type KubeApiWatchOptions = { +export type KubeApiWatchCallback = (data: IKubeWatchEvent, error: any) => void; + +export type KubeApiWatchOptions = { namespace: string; - callback?: (data: IKubeWatchEvent) => void; + callback?: KubeApiWatchCallback; abortController?: AbortController }; @@ -370,8 +372,14 @@ export class KubeApi { if (!opts.abortController) { opts.abortController = new AbortController(); } + let errorReceived = false; + let timedRetry: NodeJS.Timeout; const { abortController, namespace, callback } = opts; + abortController.signal.addEventListener("abort", () => { + clearTimeout(timedRetry); + }); + const watchUrl = this.getWatchUrl(namespace); const responsePromise = this.request.getResponse(watchUrl, null, { signal: abortController.signal @@ -379,49 +387,53 @@ export class KubeApi { 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); - } + callback?.(null, response); return; } const nodeStream = new ReadableWebToNodeStream(response.body); + + ["end", "close", "error"].forEach((eventName) => { + nodeStream.on(eventName, () => { + if (errorReceived) return; // kubernetes errors should be handled in a callback + + clearTimeout(timedRetry); + timedRetry = setTimeout(() => { // we did not get any kubernetes errors so let's retry + if (abortController.signal.aborted) return; + + this.watch({...opts, namespace, callback}); + }, 1000); + }); + }); + const stream = byline(nodeStream); stream.on("data", (line) => { try { const event: IKubeWatchEvent = JSON.parse(line); + if (event.type === "ERROR" && event.object.kind === "Status") { + errorReceived = true; + callback(null, new KubeStatus(event.object as any)); + + return; + } + this.modifyWatchEvent(event); if (callback) { - callback(event); + callback(event, null); } } 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); + callback?.(null, error); }).catch((error) => { - console.error("watch error", error); + callback?.(null, error); }); const disposer = () => { diff --git a/src/renderer/api/kube-object.ts b/src/renderer/api/kube-object.ts index 08bd6401b9..4ffd464d91 100644 --- a/src/renderer/api/kube-object.ts +++ b/src/renderer/api/kube-object.ts @@ -40,6 +40,29 @@ export interface IKubeObjectMetadata { }[]; } +export interface IKubeStatus { + kind: string; + apiVersion: string; + code: number; + message?: string; + reason?: string; +} + +export class KubeStatus { + public readonly kind = "Status"; + public readonly apiVersion: string; + public readonly code: number; + public readonly message: string; + public readonly reason: string; + + constructor(data: IKubeStatus) { + this.apiVersion = data.apiVersion; + this.code = data.code; + this.message = data.message || ""; + this.reason = data.reason || ""; + } +} + export type IKubeMetaField = keyof IKubeObjectMetadata; @autobind() @@ -99,12 +122,15 @@ export class KubeObject implements ItemObject { return this.metadata.namespace || undefined; } - // todo: refactor with named arguments - getAge(humanize = true, compact = true, fromNow = false) { + getTimeDiffFromNow(): number { + return new Date().getTime() - new Date(this.metadata.creationTimestamp).getTime(); + } + + getAge(humanize = true, compact = true, fromNow = false): string | number { if (fromNow) { - return moment(this.metadata.creationTimestamp).fromNow(); + return moment(this.metadata.creationTimestamp).fromNow(); // "string", getTimeDiffFromNow() cannot be used } - const diff = new Date().getTime() - new Date(this.metadata.creationTimestamp).getTime(); + const diff = this.getTimeDiffFromNow(); if (humanize) { return formatDuration(diff, compact); diff --git a/src/renderer/api/kube-watch-api.ts b/src/renderer/api/kube-watch-api.ts index 5523c5c5a9..f2f50193db 100644 --- a/src/renderer/api/kube-watch-api.ts +++ b/src/renderer/api/kube-watch-api.ts @@ -12,7 +12,7 @@ import { KubeJsonApiData } from "./kube-json-api"; import { isDebugging, isProduction } from "../../common/vars"; export interface IKubeWatchEvent { - type: "ADDED" | "MODIFIED" | "DELETED"; + type: "ADDED" | "MODIFIED" | "DELETED" | "ERROR"; object?: T; } @@ -32,19 +32,9 @@ export interface IKubeWatchLog { @autobind() export class KubeWatchApi { @observable context: ClusterContext = null; - @observable subscribers = observable.map(); - @observable isConnected = false; contextReady = when(() => Boolean(this.context)); - constructor() { - this.init(); - } - - private async init() { - await this.contextReady; - } - isAllowedApi(api: KubeApi): boolean { return Boolean(this.context?.cluster.isAllowedResource(api.kind)); } diff --git a/src/renderer/components/+apps-releases/release.store.ts b/src/renderer/components/+apps-releases/release.store.ts index 9548e494f7..559a90eb01 100644 --- a/src/renderer/components/+apps-releases/release.store.ts +++ b/src/renderer/components/+apps-releases/release.store.ts @@ -33,7 +33,7 @@ export class ReleaseStore extends ItemStore { }); if (amountChanged || labelsChanged) { - this.loadAll(); + this.loadFromContextNamespaces(); } this.releaseSecrets = [...secrets]; }); @@ -58,7 +58,7 @@ export class ReleaseStore extends ItemStore { } @action - async loadAll(namespaces = namespaceStore.allowedNamespaces) { + async loadAll(namespaces: string[]) { this.isLoading = true; try { @@ -78,9 +78,16 @@ export class ReleaseStore extends ItemStore { } async loadItems(namespaces: string[]) { - return Promise - .all(namespaces.map(namespace => helmReleasesApi.list(namespace))) - .then(items => items.flat()); + const isLoadingAll = namespaceStore.allowedNamespaces.every(ns => namespaces.includes(ns)); + const noAccessibleNamespaces = namespaceStore.context.cluster.accessibleNamespaces.length === 0; + + if (isLoadingAll && noAccessibleNamespaces) { + return helmReleasesApi.list(); + } else { + return Promise + .all(namespaces.map(namespace => helmReleasesApi.list(namespace))) + .then(items => items.flat()); + } } async create(payload: IReleaseCreatePayload) { diff --git a/src/renderer/components/+cluster/cluster-overview.tsx b/src/renderer/components/+cluster/cluster-overview.tsx index 1f7a7f6b78..bc587671dc 100644 --- a/src/renderer/components/+cluster/cluster-overview.tsx +++ b/src/renderer/components/+cluster/cluster-overview.tsx @@ -13,8 +13,6 @@ import { ClusterIssues } from "./cluster-issues"; import { ClusterMetrics } from "./cluster-metrics"; import { clusterOverviewStore } from "./cluster-overview.store"; import { ClusterPieCharts } from "./cluster-pie-charts"; -import { eventStore } from "../+events/event.store"; -import { kubeWatchApi } from "../../api/kube-watch-api"; @observer export class ClusterOverview extends React.Component { @@ -28,10 +26,6 @@ export class ClusterOverview extends React.Component { this.metricPoller.start(true); disposeOnUnmount(this, [ - kubeWatchApi.subscribeStores([nodesStore, podsStore, eventStore], { - preload: true, - }), - reaction( () => clusterOverviewStore.metricNodeRole, // Toggle Master/Worker node switcher () => this.metricPoller.restart(true) diff --git a/src/renderer/components/+events/event.store.ts b/src/renderer/components/+events/event.store.ts index d6090be947..03d272643f 100644 --- a/src/renderer/components/+events/event.store.ts +++ b/src/renderer/components/+events/event.store.ts @@ -20,8 +20,8 @@ export class EventStore extends KubeObjectStore { protected sortItems(items: KubeEvent[]) { return super.sortItems(items, [ - event => event.metadata.creationTimestamp - ], "desc"); + event => event.getTimeDiffFromNow(), // keep events order as timeline ("fresh" on top) + ], "asc"); } getEventsByObject(obj: KubeObject): KubeEvent[] { diff --git a/src/renderer/components/+events/events.tsx b/src/renderer/components/+events/events.tsx index 8e2af5d9a8..4b7b64dd15 100644 --- a/src/renderer/components/+events/events.tsx +++ b/src/renderer/components/+events/events.tsx @@ -1,16 +1,21 @@ import "./events.scss"; import React, { Fragment } from "react"; +import { computed, observable } from "mobx"; import { observer } from "mobx-react"; +import { orderBy } from "lodash"; import { TabLayout } from "../layout/tab-layout"; -import { eventStore } from "./event.store"; +import { EventStore, eventStore } from "./event.store"; import { getDetailsUrl, KubeObjectListLayout, KubeObjectListLayoutProps } from "../kube-object"; import { KubeEvent } from "../../api/endpoints/events.api"; +import { TableSortCallbacks, TableSortParams, TableProps } from "../table"; +import { IHeaderPlaceholders } from "../item-object-list"; import { Tooltip } from "../tooltip"; import { Link } from "react-router-dom"; import { cssNames, IClassName, stopPropagation } from "../../utils"; import { Icon } from "../icon"; import { lookupApiLink } from "../../api/kube-api"; +import { eventsURL } from "./events.route"; enum columnId { message = "message", @@ -36,62 +41,116 @@ const defaultProps: Partial = { export class Events extends React.Component { static defaultProps = defaultProps as object; + @observable sorting: TableSortParams = { + sortBy: columnId.age, + orderBy: "asc", + }; + + private sortingCallbacks: TableSortCallbacks = { + [columnId.namespace]: (event: KubeEvent) => event.getNs(), + [columnId.type]: (event: KubeEvent) => event.type, + [columnId.object]: (event: KubeEvent) => event.involvedObject.name, + [columnId.count]: (event: KubeEvent) => event.count, + [columnId.age]: (event: KubeEvent) => event.getTimeDiffFromNow(), + }; + + private tableConfiguration: TableProps = { + sortSyncWithUrl: false, + sortByDefault: this.sorting, + onSort: params => this.sorting = params, + }; + + get store(): EventStore { + return eventStore; + } + + @computed get items(): KubeEvent[] { + const items = this.store.contextItems; + const { sortBy, orderBy: order } = this.sorting; + + // we must sort items before passing to "KubeObjectListLayout -> Table" + // to make it work with "compact=true" (proper table sorting actions + initial items) + return orderBy(items, this.sortingCallbacks[sortBy], order as any); + } + + @computed get visibleItems(): KubeEvent[] { + const { compact, compactLimit } = this.props; + + if (compact) { + return this.items.slice(0, compactLimit); + } + + return this.items; + } + + customizeHeader = ({ info, title }: IHeaderPlaceholders) => { + const { compact } = this.props; + const { store, items, visibleItems } = this; + const allEventsAreShown = visibleItems.length === items.length; + + // handle "compact"-mode header + if (compact) { + if (allEventsAreShown) return title; // title == "Events" + + return <> + {title} + ({visibleItems.length} of {items.length}) + ; + } + + return { + info: <> + {info} + + + }; + }; + render() { + const { store, visibleItems } = this; const { compact, compactLimit, className, ...layoutProps } = this.props; + const events = ( event.getNs(), - [columnId.type]: (event: KubeEvent) => event.involvedObject.kind, - [columnId.object]: (event: KubeEvent) => event.involvedObject.name, - [columnId.count]: (event: KubeEvent) => event.count, - [columnId.age]: (event: KubeEvent) => event.metadata.creationTimestamp, - }} + items={visibleItems} + virtual={!compact} + tableProps={this.tableConfiguration} + sortingCallbacks={this.sortingCallbacks} searchFilters={[ (event: KubeEvent) => event.getSearchFields(), (event: KubeEvent) => event.message, (event: KubeEvent) => event.getSource(), (event: KubeEvent) => event.involvedObject.name, ]} - renderHeaderTitle="Events" - customizeHeader={({ title, info }) => ( - compact ? title : ({ - info: ( - <> - {info} - - - ) - }) - )} renderTableHeader={[ + { title: "Type", className: "type", sortBy: columnId.type, id: columnId.type }, { title: "Message", className: "message", id: columnId.message }, { title: "Namespace", className: "namespace", sortBy: columnId.namespace, id: columnId.namespace }, - { title: "Type", className: "type", sortBy: columnId.type, id: columnId.type }, { title: "Involved Object", className: "object", sortBy: columnId.object, id: columnId.object }, { title: "Source", className: "source", id: columnId.source }, { title: "Count", className: "count", sortBy: columnId.count, id: columnId.count }, - { title: "Age", className: "age", sortBy: columnId.age, id: columnId.age }, + { title: "Last Seen", className: "age", sortBy: columnId.age, id: columnId.age }, ]} renderTableContents={(event: KubeEvent) => { const { involvedObject, type, message } = event; - const { kind, name } = involvedObject; const tooltipId = `message-${event.getId()}`; - const isWarning = type === "Warning"; - const detailsUrl = getDetailsUrl(lookupApiLink(involvedObject, event)); + const isWarning = event.isWarning(); return [ + type, // type of event: "Normal" or "Warning" { className: { warning: isWarning }, title: ( @@ -104,17 +163,14 @@ export class Events extends React.Component { ) }, event.getNs(), - kind, - {name}, + + {involvedObject.kind}: {involvedObject.name} + , event.getSource(), event.count, event.getAge(), ]; }} - virtual={!compact} - filterItems={[ - items => compact ? items.slice(0, compactLimit) : items, - ]} /> ); diff --git a/src/renderer/components/+namespaces/namespace-select-filter.tsx b/src/renderer/components/+namespaces/namespace-select-filter.tsx new file mode 100644 index 0000000000..453849e534 --- /dev/null +++ b/src/renderer/components/+namespaces/namespace-select-filter.tsx @@ -0,0 +1,77 @@ +import "./namespace-select.scss"; + +import React from "react"; +import { observer } from "mobx-react"; +import { components, PlaceholderProps } from "react-select"; + +import { Icon } from "../icon"; +import { FilterIcon } from "../item-object-list/filter-icon"; +import { FilterType } from "../item-object-list/page-filters.store"; +import { SelectOption } from "../select"; +import { NamespaceSelect } from "./namespace-select"; +import { namespaceStore } from "./namespace.store"; + +const Placeholder = observer((props: PlaceholderProps) => { + const getPlaceholder = (): React.ReactNode => { + const namespaces = namespaceStore.contextNamespaces; + + switch (namespaces.length) { + case 0: + case namespaceStore.allowedNamespaces.length: + return <>All namespaces; + case 1: + return <>Namespace: {namespaces[0]}; + default: + return <>Namespaces: {namespaces.join(", ")}; + } + }; + + return ( + + {getPlaceholder()} + + ); +}); + + +@observer +export class NamespaceSelectFilter extends React.Component { + formatOptionLabel({ value: namespace, label }: SelectOption) { + if (namespace) { + const isSelected = namespaceStore.hasContext(namespace); + + return ( +
+ + {namespace} + {isSelected && } +
+ ); + } + + return label; + } + + onChange([{ value: namespace }]: SelectOption[]) { + if (namespace) { + namespaceStore.toggleContext(namespace); + } else { + namespaceStore.resetContext(); // "All namespaces" clicked, empty list considered as "all" + } + } + + render() { + return ( + + ); + } +} diff --git a/src/renderer/components/+namespaces/namespace-select.tsx b/src/renderer/components/+namespaces/namespace-select.tsx index 27fbb8a311..207a274d2c 100644 --- a/src/renderer/components/+namespaces/namespace-select.tsx +++ b/src/renderer/components/+namespaces/namespace-select.tsx @@ -7,8 +7,6 @@ import { Select, SelectOption, SelectProps } from "../select"; import { cssNames } from "../../utils"; import { Icon } from "../icon"; import { namespaceStore } from "./namespace.store"; -import { FilterIcon } from "../item-object-list/filter-icon"; -import { FilterType } from "../item-object-list/page-filters.store"; import { kubeWatchApi } from "../../api/kube-watch-api"; interface Props extends SelectProps { @@ -35,7 +33,7 @@ export class NamespaceSelect extends React.Component { ]); } - @computed get options(): SelectOption[] { + @computed.struct get options(): SelectOption[] { const { customizeOptions, showClusterOption, showAllNamespacesOption } = this.props; let options: SelectOption[] = namespaceStore.items.map(ns => ({ value: ns.getName() })); @@ -78,59 +76,3 @@ export class NamespaceSelect extends React.Component { ); } } - -@observer -export class NamespaceSelectFilter extends React.Component { - @computed get placeholder(): React.ReactNode { - const namespaces = namespaceStore.contextNamespaces; - - switch (namespaces.length) { - case 0: - case namespaceStore.allowedNamespaces.length: - return <>All namespaces; - case 1: - return <>Namespace: {namespaces[0]}; - default: - return <>Namespaces: {namespaces.join(", ")}; - } - } - - formatOptionLabel = ({ value: namespace, label }: SelectOption) => { - if (namespace) { - const isSelected = namespaceStore.hasContext(namespace); - - return ( -
- - {namespace} - {isSelected && } -
- ); - } - - return label; - }; - - onChange = ([{ value: namespace }]: SelectOption[]) => { - if (namespace) { - namespaceStore.toggleContext(namespace); - } else { - namespaceStore.resetContext(); // "All namespaces" clicked, empty list considered as "all" - } - }; - - render() { - return ( - false} - controlShouldRenderValue={false} - placeholder={this.placeholder} - onChange={this.onChange} - formatOptionLabel={this.formatOptionLabel} - /> - ); - } -} diff --git a/src/renderer/components/+workloads-deployments/deployment-scale-dialog.test.tsx b/src/renderer/components/+workloads-deployments/deployment-scale-dialog.test.tsx index e3d18669f9..da3ac55e07 100644 --- a/src/renderer/components/+workloads-deployments/deployment-scale-dialog.test.tsx +++ b/src/renderer/components/+workloads-deployments/deployment-scale-dialog.test.tsx @@ -4,9 +4,9 @@ import "@testing-library/jest-dom/extend-expect"; import { DeploymentScaleDialog } from "./deployment-scale-dialog"; jest.mock("../../api/endpoints"); -import { deploymentApi } from "../../api/endpoints"; +import { Deployment, deploymentApi } from "../../api/endpoints"; -const dummyDeployment = { +const dummyDeployment: Deployment = { apiVersion: "v1", kind: "dummy", metadata: { @@ -83,6 +83,7 @@ const dummyDeployment = { getName: jest.fn(), getNs: jest.fn(), getAge: jest.fn(), + getTimeDiffFromNow: jest.fn(), getFinalizers: jest.fn(), getLabels: jest.fn(), getAnnotations: jest.fn(), diff --git a/src/renderer/components/+workloads-overview/overview-statuses.tsx b/src/renderer/components/+workloads-overview/overview-statuses.tsx index 1441115ed8..bc0484dadc 100644 --- a/src/renderer/components/+workloads-overview/overview-statuses.tsx +++ b/src/renderer/components/+workloads-overview/overview-statuses.tsx @@ -6,7 +6,7 @@ import { OverviewWorkloadStatus } from "./overview-workload-status"; import { Link } from "react-router-dom"; import { workloadURL, workloadStores } from "../+workloads"; import { namespaceStore } from "../+namespaces/namespace.store"; -import { NamespaceSelectFilter } from "../+namespaces/namespace-select"; +import { NamespaceSelectFilter } from "../+namespaces/namespace-select-filter"; import { isAllowedResource, KubeResource } from "../../../common/rbac"; import { ResourceNames } from "../../utils/rbac"; import { autobind } from "../../utils"; diff --git a/src/renderer/components/+workloads-replicasets/replicaset-scale-dialog.test.tsx b/src/renderer/components/+workloads-replicasets/replicaset-scale-dialog.test.tsx index 804b7c344f..131bc08ca2 100755 --- a/src/renderer/components/+workloads-replicasets/replicaset-scale-dialog.test.tsx +++ b/src/renderer/components/+workloads-replicasets/replicaset-scale-dialog.test.tsx @@ -4,9 +4,9 @@ jest.mock("../../api/endpoints"); import { ReplicaSetScaleDialog } from "./replicaset-scale-dialog"; import { render, waitFor, fireEvent } from "@testing-library/react"; import React from "react"; -import { replicaSetApi } from "../../api/endpoints/replica-set.api"; +import { ReplicaSet, replicaSetApi } from "../../api/endpoints/replica-set.api"; -const dummyReplicaSet = { +const dummyReplicaSet: ReplicaSet = { apiVersion: "v1", kind: "dummy", metadata: { @@ -67,7 +67,6 @@ const dummyReplicaSet = { getCurrent: jest.fn(), getReady: jest.fn(), getImages: jest.fn(), - getReplicas: jest.fn(), getSelectors: jest.fn(), getTemplateLabels: jest.fn(), getAffinity: jest.fn(), @@ -79,6 +78,7 @@ const dummyReplicaSet = { getName: jest.fn(), getNs: jest.fn(), getAge: jest.fn(), + getTimeDiffFromNow: jest.fn(), getFinalizers: jest.fn(), getLabels: jest.fn(), getAnnotations: jest.fn(), diff --git a/src/renderer/components/+workloads-statefulsets/statefulset-scale-dialog.test.tsx b/src/renderer/components/+workloads-statefulsets/statefulset-scale-dialog.test.tsx index c0a26dbd52..faf94b995d 100755 --- a/src/renderer/components/+workloads-statefulsets/statefulset-scale-dialog.test.tsx +++ b/src/renderer/components/+workloads-statefulsets/statefulset-scale-dialog.test.tsx @@ -1,12 +1,12 @@ import "@testing-library/jest-dom/extend-expect"; jest.mock("../../api/endpoints"); -import { statefulSetApi } from "../../api/endpoints"; +import { StatefulSet, statefulSetApi } from "../../api/endpoints"; import { StatefulSetScaleDialog } from "./statefulset-scale-dialog"; import { render, waitFor, fireEvent } from "@testing-library/react"; import React from "react"; -const dummyStatefulSet = { +const dummyStatefulSet: StatefulSet = { apiVersion: "v1", kind: "dummy", metadata: { @@ -88,6 +88,7 @@ const dummyStatefulSet = { getName: jest.fn(), getNs: jest.fn(), getAge: jest.fn(), + getTimeDiffFromNow: jest.fn(), getFinalizers: jest.fn(), getLabels: jest.fn(), getAnnotations: jest.fn(), diff --git a/src/renderer/components/cluster-icon/cluster-icon.tsx b/src/renderer/components/cluster-icon/cluster-icon.tsx index c1b0ef3577..48d23eb91f 100644 --- a/src/renderer/components/cluster-icon/cluster-icon.tsx +++ b/src/renderer/components/cluster-icon/cluster-icon.tsx @@ -52,7 +52,7 @@ export class ClusterIcon extends React.Component { cluster, showErrors, showTooltip, errorClass, options, interactive, isActive, children, ...elemProps } = this.props; - const { name, preferences, id: clusterId } = cluster; + const { name, preferences, id: clusterId, online } = cluster; const eventCount = this.eventCount; const { icon } = preferences; const clusterIconId = `cluster-icon-${clusterId}`; @@ -68,7 +68,7 @@ export class ClusterIcon extends React.Component { )} {icon && {name}/} {!icon && } - {showErrors && eventCount > 0 && !isActive && ( + {showErrors && eventCount > 0 && !isActive && online && ( = 1000 ? `${Math.ceil(eventCount / 1000)}k+` : eventCount} diff --git a/src/renderer/components/cluster-manager/lens-views.ts b/src/renderer/components/cluster-manager/lens-views.ts index 17e6c9f00d..6e9ba6df6a 100644 --- a/src/renderer/components/cluster-manager/lens-views.ts +++ b/src/renderer/components/cluster-manager/lens-views.ts @@ -24,6 +24,9 @@ export async function initView(clusterId: ClusterId) { if (!cluster) { return; } + + logger.info(`[LENS-VIEW]: waiting cluster to be ready, clusterId=${clusterId}`); + await cluster.whenReady; logger.info(`[LENS-VIEW]: init dashboard, clusterId=${clusterId}`); const parentElem = document.getElementById("lens-views"); const iframe = document.createElement("iframe"); @@ -40,7 +43,15 @@ export async function initView(clusterId: ClusterId) { } export async function autoCleanOnRemove(clusterId: ClusterId, iframe: HTMLIFrameElement) { - await when(() => !clusterStore.getById(clusterId)); + await when(() => { + const cluster = clusterStore.getById(clusterId); + + if (!cluster) return true; + + const view = lensViews.get(clusterId); + + return cluster.disconnected && view?.isLoaded; + }); logger.info(`[LENS-VIEW]: remove dashboard, clusterId=${clusterId}`); lensViews.delete(clusterId); diff --git a/src/renderer/components/context.ts b/src/renderer/components/context.ts index 3c8c6d29e4..e8c9b1327d 100755 --- a/src/renderer/components/context.ts +++ b/src/renderer/components/context.ts @@ -14,7 +14,22 @@ export const clusterContext: ClusterContext = { }, get allNamespaces(): string[] { - return this.cluster?.allowedNamespaces ?? []; + if (!this.cluster) { + return []; + } + + // user given list of namespaces + if (this.cluster?.accessibleNamespaces.length) { + return this.cluster.accessibleNamespaces; + } + + if (namespaceStore.items.length > 0) { + // namespaces from kubernetes api + return namespaceStore.items.map((namespace) => namespace.getName()); + } else { + // fallback to cluster resolved namespaces because we could not load list + return this.cluster.allowedNamespaces || []; + } }, get contextNamespaces(): string[] { diff --git a/src/renderer/components/error-boundary/error-boundary.scss b/src/renderer/components/error-boundary/error-boundary.scss index e50ee9d694..d139ce7ce9 100644 --- a/src/renderer/components/error-boundary/error-boundary.scss +++ b/src/renderer/components/error-boundary/error-boundary.scss @@ -4,6 +4,17 @@ padding: var(--flex-gap); word-break: break-all; + .wrapper { + display: grid; + grid-template-columns: minmax(300px, 1fr) minmax(300px, 1fr); + column-gap: 12px; + row-gap: 12px; + + @media screen and (max-width: 900px) { + grid-template-columns: auto; + } + } + code { max-height: none; border: 5px solid $borderFaintColor; diff --git a/src/renderer/components/error-boundary/error-boundary.tsx b/src/renderer/components/error-boundary/error-boundary.tsx index d0476bf225..22beb3d195 100644 --- a/src/renderer/components/error-boundary/error-boundary.tsx +++ b/src/renderer/components/error-boundary/error-boundary.tsx @@ -49,7 +49,7 @@ export class ErrorBoundary extends React.Component {

To help us improve the product please report bugs to {slackLink} community or {githubLink} issues tracker.

-
+

Component stack:

{errorInfo.componentStack} 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 5eeb7b2f11..895c275fbb 100644 --- a/src/renderer/components/item-object-list/item-list-layout.tsx +++ b/src/renderer/components/item-object-list/item-list-layout.tsx @@ -15,7 +15,7 @@ import { SearchInputUrl } from "../input"; import { Filter, FilterType, pageFilters } from "./page-filters.store"; import { PageFiltersList } from "./page-filters-list"; import { PageFiltersSelect } from "./page-filters-select"; -import { NamespaceSelectFilter } from "../+namespaces/namespace-select"; +import { NamespaceSelectFilter } from "../+namespaces/namespace-select-filter"; import { themeStore } from "../../theme.store"; import { MenuActions } from "../menu/menu-actions"; import { MenuItem } from "../menu"; @@ -28,7 +28,7 @@ import { namespaceStore } from "../+namespaces/namespace.store"; export type SearchFilter = (item: T) => string | number | (string | number)[]; export type ItemsFilter = (items: T[]) => T[]; -interface IHeaderPlaceholders { +export interface IHeaderPlaceholders { title: ReactNode; search: ReactNode; filters: ReactNode; @@ -282,8 +282,8 @@ export class ItemListLayout extends React.Component { const dialogCustomProps = customizeRemoveDialog ? customizeRemoveDialog(selectedItems) : {}; const selectedCount = selectedItems.length; const tailCount = selectedCount > visibleMaxNamesCount ? selectedCount - visibleMaxNamesCount : 0; - const tail = tailCount > 0 ? "and {tailCount} more" : null; - const message = selectedCount <= 1 ?

Remove item {selectedNames}?

:

Remove {selectedCount} items {selectedNames} {tail}?

; + const tail = tailCount > 0 ? <>, and {tailCount} more : null; + const message = selectedCount <= 1 ?

Remove item {selectedNames}?

:

Remove {selectedCount} items {selectedNames}{tail}?

; ConfirmDialog.open({ ok: removeSelectedItems, @@ -372,7 +372,7 @@ export class ItemListLayout extends React.Component { let header = this.renderHeaderContent(placeholders); if (customizeHeader) { - const modifiedHeader = customizeHeader(placeholders, header); + const modifiedHeader = customizeHeader(placeholders, header) ?? {}; if (isReactNode(modifiedHeader)) { header = modifiedHeader; diff --git a/src/renderer/components/kube-object/kube-object-details.tsx b/src/renderer/components/kube-object/kube-object-details.tsx index 40a247c2d8..b07b0753fb 100644 --- a/src/renderer/components/kube-object/kube-object-details.tsx +++ b/src/renderer/components/kube-object/kube-object-details.tsx @@ -13,11 +13,21 @@ import { CrdResourceDetails } from "../+custom-resources"; import { KubeObjectMenu } from "./kube-object-menu"; import { kubeObjectDetailRegistry } from "../../api/kube-object-detail-registry"; +/** + * Used to store `object.selfLink` to show more info about resource in the details panel. + */ export const kubeDetailsUrlParam = createPageParam({ name: "kube-details", isSystem: true, }); +/** + * Used to highlight last active/selected table row with the resource. + * + * @example + * If we go to "Nodes (page) -> Node (details) -> Pod (details)", + * last clicked Node should be "active" while Pod details are shown). + */ export const kubeSelectedUrlParam = createPageParam({ name: "kube-selected", isSystem: true, @@ -26,8 +36,8 @@ export const kubeSelectedUrlParam = createPageParam({ } }); -export function showDetails(details = "", resetSelected = true) { - const detailsUrl = getDetailsUrl(details, resetSelected); +export function showDetails(selfLink = "", resetSelected = true) { + const detailsUrl = getDetailsUrl(selfLink, resetSelected); navigation.merge({ search: detailsUrl }); } @@ -36,18 +46,18 @@ export function hideDetails() { showDetails(); } -export function getDetailsUrl(details: string, resetSelected = false) { - const detailsUrl = kubeDetailsUrlParam.toSearchString({ value: details }); +export function getDetailsUrl(selfLink: string, resetSelected = false, mergeGlobals = true) { + const params = new URLSearchParams(mergeGlobals ? navigation.searchParams : ""); + + params.set(kubeDetailsUrlParam.urlName, selfLink); if (resetSelected) { - const params = new URLSearchParams(detailsUrl); - - params.delete(kubeSelectedUrlParam.name); - - return `?${params.toString()}`; + params.delete(kubeSelectedUrlParam.urlName); + } else { + params.set(kubeSelectedUrlParam.urlName, kubeSelectedUrlParam.get()); } - return detailsUrl; + return `?${params}`; } export interface KubeObjectDetailsProps { 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 d8e78aa69d..0ecb602c16 100644 --- a/src/renderer/components/kube-object/kube-object-list-layout.tsx +++ b/src/renderer/components/kube-object/kube-object-list-layout.tsx @@ -42,13 +42,13 @@ export class KubeObjectListLayout extends React.Component = (data: D) => string | number | (string | number)[]; +export type TableSortCallbacks = { [columnId: string]: TableSortCallback }; export interface TableProps extends React.DOMAttributes { items?: ItemObject[]; // Raw items data @@ -24,11 +25,11 @@ export interface TableProps extends React.DOMAttributes { selectable?: boolean; // Highlight rows on hover scrollable?: boolean; // Use scrollbar if content is bigger than parent's height storageKey?: string; // Keep some data in localStorage & restore on page reload, e.g sorting params - sortable?: { - // Define sortable callbacks for every column in - // @sortItem argument in the callback is an object, provided in - [sortBy: string]: TableSortCallback; - }; + /** + * Define sortable callbacks for every column in + * @sortItem argument in the callback is an object, provided in + */ + sortable?: TableSortCallbacks; sortSyncWithUrl?: boolean; // sorting state is managed globally from url params sortByDefault?: Partial; // default sorting params onSort?: (params: TableSortParams) => void; // callback on sort change, default: global sync with url diff --git a/src/renderer/ipc/index.tsx b/src/renderer/ipc/index.tsx index 991d9b7616..b9644f7404 100644 --- a/src/renderer/ipc/index.tsx +++ b/src/renderer/ipc/index.tsx @@ -34,14 +34,14 @@ function UpdateAvailableHandler(event: IpcRendererEvent, ...[backchannel, update Notifications.info( ( - <> +
Update Available

Version {updateInfo.version} of Lens IDE is now available. Would you like to update?

- +
), { id: notificationId, onClose() { diff --git a/src/renderer/kube-object.store.ts b/src/renderer/kube-object.store.ts index 9a2a3da4e3..987112a25c 100644 --- a/src/renderer/kube-object.store.ts +++ b/src/renderer/kube-object.store.ts @@ -2,7 +2,7 @@ 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 { KubeObject, KubeStatus } from "./api/kube-object"; import { IKubeWatchEvent } from "./api/kube-watch-api"; import { ItemStore } from "./item.store"; import { apiManager } from "./api/api-manager"; @@ -109,7 +109,9 @@ export abstract class KubeObjectStore extends ItemSt return api.list({}, this.query); } - const isLoadingAll = this.context.allNamespaces.every(ns => namespaces.includes(ns)); + const isLoadingAll = this.context.allNamespaces?.length > 1 + && this.context.cluster.accessibleNamespaces.length === 0 + && this.context.allNamespaces.every(ns => namespaces.includes(ns)); if (isLoadingAll) { this.loadedNamespaces = []; @@ -149,8 +151,10 @@ export abstract class KubeObjectStore extends ItemSt if (merge) { this.mergeItems(items, { replace: false }); } else { - return items; + this.mergeItems(items, { replace: true }); } + + return items; } catch (error) { console.error("Loading store items failed", { error, store: this }); this.resetOnError(error); @@ -176,10 +180,10 @@ export abstract class KubeObjectStore extends ItemSt // update existing items if (!replace) { - const partialIds = partialItems.map(item => item.getId()); + const namespaces = partialItems.map(item => item.getNs()); items = [ - ...this.items.filter(existingItem => !partialIds.includes(existingItem.getId())), + ...this.items.filter(existingItem => !namespaces.includes(existingItem.getNs())), ...partialItems, ]; } @@ -267,31 +271,80 @@ export abstract class KubeObjectStore extends ItemSt } subscribe(apis = this.getSubscribeApis()) { - let disposers: {(): void}[] = []; + const abortController = new AbortController(); + const namespaces = [...this.loadedNamespaces]; - 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), - })); + if (this.context.cluster?.isGlobalWatchEnabled && namespaces.length === 0) { + apis.forEach(api => this.watchNamespace(api, "", abortController)); } else { - apis.map(api => { + apis.forEach(api => { this.loadedNamespaces.forEach((namespace) => { - disposers.push(api.watch({ - namespace, - callback: (data) => callback(data) - })); + this.watchNamespace(api, namespace, abortController); }); }); } - return () => disposers.forEach(dispose => dispose()); + return () => { + abortController.abort(); + }; + } + + private watchNamespace(api: KubeApi, namespace: string, abortController: AbortController) { + let timedRetry: NodeJS.Timeout; + + abortController.signal.addEventListener("abort", () => clearTimeout(timedRetry)); + + const callback = (data: IKubeWatchEvent, error: any) => { + if (!this.isLoaded || abortController.signal.aborted) return; + + if (error instanceof Response) { + if (error.status === 404) { + // api has gone, let's not retry + return; + } else { // not sure what to do, best to retry + if (timedRetry) clearTimeout(timedRetry); + timedRetry = setTimeout(() => { + api.watch({ + namespace, + abortController, + callback + }); + }, 5000); + } + } else if (error instanceof KubeStatus && error.code === 410) { + if (timedRetry) clearTimeout(timedRetry); + // resourceVersion has gone, let's try to reload + timedRetry = setTimeout(() => { + (namespace === "" ? this.loadAll({ merge: false }) : this.loadAll({namespaces: [namespace]})).then(() => { + api.watch({ + namespace, + abortController, + callback + }); + }); + }, 1000); + } else if(error) { // not sure what to do, best to retry + if (timedRetry) clearTimeout(timedRetry); + + timedRetry = setTimeout(() => { + api.watch({ + namespace, + abortController, + callback + }); + }, 5000); + } + + if (data) { + this.eventsBuffer.push(data); + } + }; + + api.watch({ + namespace, + abortController, + callback: (data, error) => callback(data, error) + }); } @action diff --git a/src/renderer/navigation/page-param.ts b/src/renderer/navigation/page-param.ts index e73da03c44..50f6008738 100644 --- a/src/renderer/navigation/page-param.ts +++ b/src/renderer/navigation/page-param.ts @@ -20,7 +20,7 @@ export class PageParam { static SYSTEM_PREFIX = "lens-"; readonly name: string; - protected urlName: string; + readonly urlName: string; constructor(readonly init: PageParamInit | PageSystemParamInit, protected history: IObservableHistory) { const { isSystem, name } = init as PageSystemParamInit; diff --git a/src/renderer/utils/readableStream.ts b/src/renderer/utils/readableStream.ts new file mode 100644 index 0000000000..3b51106427 --- /dev/null +++ b/src/renderer/utils/readableStream.ts @@ -0,0 +1,87 @@ +import { Readable } from "readable-stream"; + +/** + * ReadableWebToNodeStream + * + * Copied from https://github.com/Borewit/readable-web-to-node-stream + * + * Adds read error handler + * + * */ +export class ReadableWebToNodeStream extends Readable { + + public bytesRead = 0; + public released = false; + + /** + * Default web API stream reader + * https://developer.mozilla.org/en-US/docs/Web/API/ReadableStreamDefaultReader + */ + private reader: ReadableStreamReader; + private pendingRead: Promise; + + /** + * + * @param stream Readable​Stream: https://developer.mozilla.org/en-US/docs/Web/API/ReadableStream + */ + constructor(stream: ReadableStream) { + super(); + this.reader = stream.getReader(); + } + + /** + * Implementation of readable._read(size). + * When readable._read() is called, if data is available from the resource, + * the implementation should begin pushing that data into the read queue + * https://nodejs.org/api/stream.html#stream_readable_read_size_1 + */ + public async _read() { + // Should start pushing data into the queue + // Read data from the underlying Web-API-readable-stream + if (this.released) { + this.push(null); // Signal EOF + + return; + } + + try { + this.pendingRead = this.reader.read(); + const data = await this.pendingRead; + + // clear the promise before pushing pushing new data to the queue and allow sequential calls to _read() + delete this.pendingRead; + + if (data.done || this.released) { + this.push(null); // Signal EOF + } else { + this.bytesRead += data.value.length; + this.push(data.value); // Push new data to the queue + } + } catch(error) { + this.push(null); // Signal EOF + } + } + + /** + * If there is no unresolved read call to Web-API Readable​Stream immediately returns; + * otherwise will wait until the read is resolved. + */ + public async waitForReadToComplete() { + if (this.pendingRead) { + await this.pendingRead; + } + } + + /** + * Close wrapper + */ + public async close(): Promise { + await this.syncAndRelease(); + } + + private async syncAndRelease() { + this.released = true; + await this.waitForReadToComplete(); + await this.reader.releaseLock(); + } +} diff --git a/static/RELEASE_NOTES.md b/static/RELEASE_NOTES.md index b40eb828bc..8c43fcda18 100644 --- a/static/RELEASE_NOTES.md +++ b/static/RELEASE_NOTES.md @@ -2,12 +2,15 @@ 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-beta.1 (current version) +## 4.1.0 (current version) -- Change: list views default to a namespace (insted of listing resources from all namespaces) +**Upgrade note:** Where have all my pods gone? Namespaced Kubernetes resources are now initially shown only for the "default" namespace. Use the namespaces selector to add more. + +- Change: list views default to a namespace (instead of listing resources from all namespaces) - Command palette - Generic logs view with Pod selector - In-app survey extension +- Auto-update notifications and confirmation - Possibility to add custom Helm repository through Lens - Possibility to change visibility of common resource list columns - Suspend / resume buttons for CronJobs diff --git a/yarn.lock b/yarn.lock index b09b949135..bc2e606b71 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11489,14 +11489,6 @@ 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" @@ -14417,10 +14409,10 @@ xterm-addon-fit@^0.4.0: resolved "https://registry.yarnpkg.com/xterm-addon-fit/-/xterm-addon-fit-0.4.0.tgz#06e0c5d0a6aaacfb009ef565efa1c81e93d90193" integrity sha512-p4BESuV/g2L6pZzFHpeNLLnep9mp/DkF3qrPglMiucSFtD8iJxtMufEoEJbN8LZwB4i+8PFpFvVuFrGOSpW05w== -xterm@^4.6.0: - version "4.6.0" - resolved "https://registry.yarnpkg.com/xterm/-/xterm-4.6.0.tgz#1b49b32e546409c110fbe8ece0b4a388504a937d" - integrity sha512-98211RIDrAECqpsxs6gbilwMcxLtxSDIvtzZUIqP1xIByXtuccJ4pmMhHGJATZeEGe/reARPMqwPINK8T7jGZg== +xterm@^4.10.0: + version "4.10.0" + resolved "https://registry.yarnpkg.com/xterm/-/xterm-4.10.0.tgz#fc4f554e3e718aff9b83622e858e64b0953067bb" + integrity sha512-Wn66I8YpSVkgP3R95GjABC6Eb21pFfnCSnyIqKIIoUI13ohvwd0KGVzUDfyEFfSAzKbPJfrT2+vt7SfUXBZQKQ== y18n@^3.2.1: version "3.2.1"