From e3e0b272bcd9acff2531f8fd3447c1bc06d25a7d Mon Sep 17 00:00:00 2001 From: Jari Kolehmainen Date: Tue, 9 Feb 2021 19:52:21 +0200 Subject: [PATCH 01/32] Release v4.1.0-beta.2 (#2106) Signed-off-by: Jari Kolehmainen --- package.json | 2 +- static/RELEASE_NOTES.md | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 27721b6e7a..6c53701b72 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-beta.2", "main": "static/build/main.js", "copyright": "© 2020, Mirantis, Inc.", "license": "MIT", diff --git a/static/RELEASE_NOTES.md b/static/RELEASE_NOTES.md index b40eb828bc..0d472ec5cd 100644 --- a/static/RELEASE_NOTES.md +++ b/static/RELEASE_NOTES.md @@ -2,12 +2,13 @@ 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-beta.2 (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 +- 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 From b144eb916ba2413c1a386dda8ccc53a3017f8fdd Mon Sep 17 00:00:00 2001 From: Jari Kolehmainen Date: Wed, 10 Feb 2021 14:27:48 +0200 Subject: [PATCH 02/32] Remove unnecessary subscribeStores from cluster overview (#2108) Signed-off-by: Jari Kolehmainen --- src/renderer/components/+cluster/cluster-overview.tsx | 6 ------ 1 file changed, 6 deletions(-) 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) From c0ef006cb8e0b6c6a810c17738db0f33ba05dcb5 Mon Sep 17 00:00:00 2001 From: Alex Andreev Date: Thu, 11 Feb 2021 10:32:57 +0300 Subject: [PATCH 03/32] Fix: preventing namespace filter jumping scroll (#2104) * 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 * Replace placeholder component in NamespaceSelectFilter Signed-off-by: Alex Andreev Co-authored-by: Roman Co-authored-by: Jari Kolehmainen --- .../+namespaces/namespace-select-filter.tsx | 77 +++++++++++++++++++ .../+namespaces/namespace-select.tsx | 60 +-------------- .../+workloads-overview/overview-statuses.tsx | 2 +- .../item-object-list/item-list-layout.tsx | 2 +- 4 files changed, 80 insertions(+), 61 deletions(-) create mode 100644 src/renderer/components/+namespaces/namespace-select-filter.tsx 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-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/item-object-list/item-list-layout.tsx b/src/renderer/components/item-object-list/item-list-layout.tsx index 5eeb7b2f11..dc0604017f 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"; From 22176219f07b44037f10aebaf412e39833c9296a Mon Sep 17 00:00:00 2001 From: Jari Kolehmainen Date: Thu, 11 Feb 2021 12:23:33 +0200 Subject: [PATCH 04/32] Don't show warning badge if cluster is offline (#2121) Signed-off-by: Jari Kolehmainen --- src/renderer/components/cluster-icon/cluster-icon.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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} From b873ba76bde9a77719889db3fec7d2c4e99f79c1 Mon Sep 17 00:00:00 2001 From: Roman Date: Thu, 11 Feb 2021 13:56:20 +0200 Subject: [PATCH 05/32] Fix: compact-mode for Workloads overview -> Events (#2113) fix: proper event-type column + default sorting by warning events Signed-off-by: Roman --- src/renderer/api/endpoints/events.api.ts | 2 +- src/renderer/components/+events/events.tsx | 50 +++++++++++++------ .../kube-object/kube-object-list-layout.tsx | 4 +- 3 files changed, 39 insertions(+), 17 deletions(-) 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/components/+events/events.tsx b/src/renderer/components/+events/events.tsx index 8e2af5d9a8..f8c48cfe09 100644 --- a/src/renderer/components/+events/events.tsx +++ b/src/renderer/components/+events/events.tsx @@ -11,6 +11,7 @@ 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,19 +37,45 @@ const defaultProps: Partial = { export class Events extends React.Component { static defaultProps = defaultProps as object; + get store() { + return eventStore; + } + + get items() { + return eventStore.contextItems; + } + render() { + const { store, items } = this; const { compact, compactLimit, className, ...layoutProps } = this.props; + const visibleItems = compact ? items.slice(0, compactLimit) : items; + const allEventsAreShown = visibleItems.length === items.length; + + const compactModeHeader = <> + Events ({visibleItems.length} of {items.length}) + ; + const events = ( event.getNs(), - [columnId.type]: (event: KubeEvent) => event.involvedObject.kind, + [columnId.type]: (event: KubeEvent) => event.type, [columnId.object]: (event: KubeEvent) => event.involvedObject.name, [columnId.count]: (event: KubeEvent) => event.count, [columnId.age]: (event: KubeEvent) => event.metadata.creationTimestamp, @@ -59,7 +86,6 @@ export class Events extends React.Component { (event: KubeEvent) => event.getSource(), (event: KubeEvent) => event.involvedObject.name, ]} - renderHeaderTitle="Events" customizeHeader={({ title, info }) => ( compact ? title : ({ info: ( @@ -69,16 +95,16 @@ export class Events extends React.Component { small material="help_outline" className="help-icon" - tooltip={`Limited to ${eventStore.limit}`} + tooltip={`Limited to ${store.limit}`} /> ) }) )} 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 }, @@ -86,12 +112,11 @@ export class Events extends React.Component { ]} 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 +129,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/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 Date: Thu, 11 Feb 2021 17:34:14 +0200 Subject: [PATCH 06/32] Improve kube watch error handling (#2112) * improve kube watch error handling Signed-off-by: Jari Kolehmainen * cleanup Signed-off-by: Jari Kolehmainen * review fixes Signed-off-by: Jari Kolehmainen * no more object.assign Signed-off-by: Jari Kolehmainen * keep only items from non-loaded namespaces Signed-off-by: Jari Kolehmainen * cleanup Signed-off-by: Jari Kolehmainen * fix sleep/resume Signed-off-by: Jari Kolehmainen * fallback retry only on error Signed-off-by: Jari Kolehmainen --- src/renderer/api/kube-api.ts | 53 +++++++++-------- src/renderer/api/kube-object.ts | 23 ++++++++ src/renderer/api/kube-watch-api.ts | 12 +--- src/renderer/kube-object.store.ts | 95 +++++++++++++++++++++++------- 4 files changed, 125 insertions(+), 58 deletions(-) diff --git a/src/renderer/api/kube-api.ts b/src/renderer/api/kube-api.ts index 0be497a8f1..97cfb0522b 100644 --- a/src/renderer/api/kube-api.ts +++ b/src/renderer/api/kube-api.ts @@ -8,7 +8,7 @@ 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"; @@ -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,8 @@ export class KubeApi { if (!opts.abortController) { opts.abortController = new AbortController(); } + let errorReceived = false; const { abortController, namespace, callback } = opts; - const watchUrl = this.getWatchUrl(namespace); const responsePromise = this.request.getResponse(watchUrl, null, { signal: abortController.signal @@ -379,49 +381,50 @@ 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); + + nodeStream.on("end", () => { + if (errorReceived) return; // kubernetes errors should be handled in a callback + + 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..d16d65b6da 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() 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/kube-object.store.ts b/src/renderer/kube-object.store.ts index 9a2a3da4e3..943aae7ad0 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"; @@ -149,8 +149,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 +178,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 +269,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 From f14f3b3287cfc29ad91babb6193d2aa306e442e4 Mon Sep 17 00:00:00 2001 From: Jari Kolehmainen Date: Thu, 11 Feb 2021 17:36:47 +0200 Subject: [PATCH 07/32] Cleanup iframe when cluster is disconnected (#2120) Signed-off-by: Jari Kolehmainen --- src/renderer/components/cluster-manager/lens-views.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/renderer/components/cluster-manager/lens-views.ts b/src/renderer/components/cluster-manager/lens-views.ts index 17e6c9f00d..da7efb377c 100644 --- a/src/renderer/components/cluster-manager/lens-views.ts +++ b/src/renderer/components/cluster-manager/lens-views.ts @@ -40,7 +40,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); From f7f2d445a193b6d5607b6aaa64ff3665cbfe583b Mon Sep 17 00:00:00 2001 From: Jari Kolehmainen Date: Fri, 12 Feb 2021 07:35:12 +0200 Subject: [PATCH 08/32] Release v4.1.0-rc.1 (#2129) Signed-off-by: Jari Kolehmainen --- package.json | 2 +- static/RELEASE_NOTES.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 6c53701b72..f663accadc 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.2", + "version": "4.1.0-rc.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 0d472ec5cd..68363fba07 100644 --- a/static/RELEASE_NOTES.md +++ b/static/RELEASE_NOTES.md @@ -2,9 +2,9 @@ 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.2 (current version) +## 4.1.0-rc.1 (current version) -- Change: list views default to a namespace (insted of listing resources from all namespaces) +- 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 From d40bd0c7d0119a5722ad87e5721b37757daa1005 Mon Sep 17 00:00:00 2001 From: Alex Andreev Date: Fri, 12 Feb 2021 10:19:52 +0300 Subject: [PATCH 09/32] Making error boundary layout responsive (#2124) Signed-off-by: Alex Andreev --- .../components/error-boundary/error-boundary.scss | 11 +++++++++++ .../components/error-boundary/error-boundary.tsx | 2 +- 2 files changed, 12 insertions(+), 1 deletion(-) 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} From 317c4cf072bb79094c8b089d4f3eec556df3de7d Mon Sep 17 00:00:00 2001 From: Jari Kolehmainen Date: Fri, 12 Feb 2021 14:25:17 +0200 Subject: [PATCH 10/32] Fix accessible namespaces functionality under restrictive RBAC (#2138) Signed-off-by: Jari Kolehmainen --- src/main/cluster.ts | 2 ++ src/renderer/components/context.ts | 17 ++++++++++++++++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/src/main/cluster.ts b/src/main/cluster.ts index cd92ab2650..5cf5a58bd9 100644 --- a/src/main/cluster.ts +++ b/src/main/cluster.ts @@ -399,6 +399,8 @@ export class Cluster implements ClusterModel, ClusterState { this.accessible = false; this.ready = false; this.activated = false; + this.allowedNamespaces = []; + this.accessibleNamespaces = []; this.resourceAccessStatuses.clear(); this.pushState(); } 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[] { From 4bc4fe3a24d4b3b39b4c7085166678952e4cbd9b Mon Sep 17 00:00:00 2001 From: Jari Kolehmainen Date: Fri, 12 Feb 2021 16:24:02 +0200 Subject: [PATCH 11/32] Fix accessible namespaces reset on cluster disconnect (#2140) Signed-off-by: Jari Kolehmainen --- src/main/cluster.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/main/cluster.ts b/src/main/cluster.ts index 5cf5a58bd9..13c74a285e 100644 --- a/src/main/cluster.ts +++ b/src/main/cluster.ts @@ -400,7 +400,6 @@ export class Cluster implements ClusterModel, ClusterState { this.ready = false; this.activated = false; this.allowedNamespaces = []; - this.accessibleNamespaces = []; this.resourceAccessStatuses.clear(); this.pushState(); } From 557d96d4849e65e378b321f081a78af27d75080d Mon Sep 17 00:00:00 2001 From: Jari Kolehmainen Date: Fri, 12 Feb 2021 16:37:41 +0200 Subject: [PATCH 12/32] Handle suspend/resume error from watch stream read (#2136) * handle suspend/resume error from stream read Signed-off-by: Jari Kolehmainen * add missing types Signed-off-by: Jari Kolehmainen --- package.json | 3 +- src/renderer/api/kube-api.ts | 23 +++++--- src/renderer/utils/readableStream.ts | 87 ++++++++++++++++++++++++++++ yarn.lock | 8 --- 4 files changed, 105 insertions(+), 16 deletions(-) create mode 100644 src/renderer/utils/readableStream.ts diff --git a/package.json b/package.json index f663accadc..05da6d14a5 100644 --- a/package.json +++ b/package.json @@ -222,7 +222,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", @@ -277,6 +277,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", diff --git a/src/renderer/api/kube-api.ts b/src/renderer/api/kube-api.ts index 97cfb0522b..a880cc2406 100644 --- a/src/renderer/api/kube-api.ts +++ b/src/renderer/api/kube-api.ts @@ -10,8 +10,8 @@ import { createKubeApiURL, parseKubeApi } from "./kube-api-parse"; import { KubeJsonApi, KubeJsonApiData, KubeJsonApiDataList } from "./kube-json-api"; import { IKubeObjectConstructor, KubeObject, KubeStatus } from "./kube-object"; import byline from "byline"; -import { ReadableWebToNodeStream } from "readable-web-to-node-stream"; import { IKubeWatchEvent } from "./kube-watch-api"; +import { ReadableWebToNodeStream } from "../utils/readableStream"; export interface IKubeApiOptions { /** @@ -373,7 +373,13 @@ export class KubeApi { 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 @@ -387,14 +393,17 @@ export class KubeApi { } const nodeStream = new ReadableWebToNodeStream(response.body); - nodeStream.on("end", () => { - if (errorReceived) return; // kubernetes errors should be handled in a callback + ["end", "close", "error"].forEach((eventName) => { + nodeStream.on(eventName, () => { + if (errorReceived) return; // kubernetes errors should be handled in a callback - setTimeout(() => { // we did not get any kubernetes errors so let's retry - if (abortController.signal.aborted) return; + 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); + this.watch({...opts, namespace, callback}); + }, 1000); + }); }); const stream = byline(nodeStream); 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/yarn.lock b/yarn.lock index cd3384eba5..16ac2ca4f8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11464,14 +11464,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" From 4513a08b1fc0c00a0eb32f1421c2861c826e47f4 Mon Sep 17 00:00:00 2001 From: Sebastian Malton Date: Fri, 12 Feb 2021 10:59:58 -0500 Subject: [PATCH 13/32] don't use list all optimization when operating in accessible namespaces mode (#2142) Signed-off-by: Sebastian Malton --- src/renderer/kube-object.store.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderer/kube-object.store.ts b/src/renderer/kube-object.store.ts index 943aae7ad0..023048cb73 100644 --- a/src/renderer/kube-object.store.ts +++ b/src/renderer/kube-object.store.ts @@ -111,7 +111,7 @@ export abstract class KubeObjectStore extends ItemSt const isLoadingAll = this.context.allNamespaces.every(ns => namespaces.includes(ns)); - if (isLoadingAll) { + if (isLoadingAll && this.context.cluster.accessibleNamespaces.length === 0) { this.loadedNamespaces = []; return api.list({}, this.query); From ebf91770986743d3c560c3087d7292c51b8d4a61 Mon Sep 17 00:00:00 2001 From: Lauri Nevala Date: Fri, 12 Feb 2021 19:04:12 +0200 Subject: [PATCH 14/32] Activate survey extension only on main renderer (#2145) Signed-off-by: Lauri Nevala --- extensions/survey/renderer.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) 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(); + } } } From f4a307492626e169a04a72dc83365592ffa4e952 Mon Sep 17 00:00:00 2001 From: Jari Kolehmainen Date: Sat, 13 Feb 2021 09:33:04 +0200 Subject: [PATCH 15/32] Fix events list default sort column (#2149) Signed-off-by: Jari Kolehmainen --- src/renderer/components/+events/events.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/renderer/components/+events/events.tsx b/src/renderer/components/+events/events.tsx index f8c48cfe09..f385555aaa 100644 --- a/src/renderer/components/+events/events.tsx +++ b/src/renderer/components/+events/events.tsx @@ -69,7 +69,7 @@ export class Events extends React.Component { tableProps={{ sortSyncWithUrl: false, sortByDefault: { - sortBy: columnId.type, + sortBy: columnId.age, orderBy: "desc", // show "Warning" events at the top }, }} @@ -108,7 +108,7 @@ export class Events extends React.Component { { 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; From 8d74f1c75906b9e0b474631f45c038d4e4c692c5 Mon Sep 17 00:00:00 2001 From: Sebastian Malton Date: Mon, 15 Feb 2021 00:25:34 -0500 Subject: [PATCH 16/32] Fix tailCount renderering in removeItemsDialog (#2148) Signed-off-by: Sebastian Malton --- src/renderer/components/item-object-list/item-list-layout.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 dc0604017f..59bd18cd5e 100644 --- a/src/renderer/components/item-object-list/item-list-layout.tsx +++ b/src/renderer/components/item-object-list/item-list-layout.tsx @@ -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, From 1e5d682b9bc60d5bfbc61ebecbe4e937fe6a2928 Mon Sep 17 00:00:00 2001 From: Jari Kolehmainen Date: Mon, 15 Feb 2021 11:29:04 +0200 Subject: [PATCH 17/32] Release v4.1.0-rc.2 (#2154) Signed-off-by: Jari Kolehmainen --- package.json | 2 +- static/RELEASE_NOTES.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 05da6d14a5..1e5c73a9b5 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-rc.1", + "version": "4.1.0-rc.2", "main": "static/build/main.js", "copyright": "© 2020, Mirantis, Inc.", "license": "MIT", diff --git a/static/RELEASE_NOTES.md b/static/RELEASE_NOTES.md index 68363fba07..0fe2fa4c4d 100644 --- a/static/RELEASE_NOTES.md +++ b/static/RELEASE_NOTES.md @@ -2,7 +2,7 @@ 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-rc.1 (current version) +## 4.1.0-rc.2 (current version) - Change: list views default to a namespace (instead of listing resources from all namespaces) - Command palette From 3f2d9128164d0b6b60595bf9fa19fdaecdd3387c Mon Sep 17 00:00:00 2001 From: Lauri Nevala Date: Tue, 16 Feb 2021 14:49:51 +0200 Subject: [PATCH 18/32] Register existing store with new apiBase when re-registering Kube API (#2157) * Update store key after api's apiBase change Signed-off-by: Lauri Nevala * Re-register existing store when registering API Signed-off-by: Lauri Nevala * Revert kube-api changes Signed-off-by: Lauri Nevala --- src/renderer/api/api-manager.ts | 6 ++++++ 1 file changed, 6 insertions(+) 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); } } From d3cedb49aae052c51ff517b65ca31b863f0d54ca Mon Sep 17 00:00:00 2001 From: Alex Andreev Date: Tue, 16 Feb 2021 17:49:27 +0300 Subject: [PATCH 19/32] Adding paddings to auto-update notification elements (#2163) Signed-off-by: Alex Andreev --- src/renderer/components/notifications/notifications.scss | 2 +- src/renderer/ipc/index.tsx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/renderer/components/notifications/notifications.scss b/src/renderer/components/notifications/notifications.scss index 4b300edda6..7388abb1e8 100644 --- a/src/renderer/components/notifications/notifications.scss +++ b/src/renderer/components/notifications/notifications.scss @@ -15,7 +15,7 @@ .notification { flex: 0 0; - padding: $padding; + padding: $padding * 1.5; border-radius: 3px; min-width: 350px; max-width: 35vw; 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() { From 436bb7683d1a2595ba592e196a91aefccd9a309f Mon Sep 17 00:00:00 2001 From: Sebastian Malton Date: Tue, 16 Feb 2021 09:50:11 -0500 Subject: [PATCH 20/32] Listen for before-quit-for-update to allow will-quit to use default flow (#2147) Signed-off-by: Sebastian Malton --- src/main/index.ts | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/src/main/index.ts b/src/main/index.ts index 50571a8862..c98595fa35 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, powerMonitor } from "electron"; +import { app, autoUpdater, dialog, powerMonitor } from "electron"; import { appName } from "../common/vars"; import path from "path"; import { LensProxy } from "./lens-proxy"; @@ -167,14 +167,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); + // Quit app on Cmd+Q (MacOS) app.on("will-quit", (event) => { 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 + } }); // Extensions-api runtime exports From 2c196ec3ad56dadcf2f36bd5daf80c756bd3acf6 Mon Sep 17 00:00:00 2001 From: Alex Andreev Date: Tue, 16 Feb 2021 17:53:11 +0300 Subject: [PATCH 21/32] Fix: load releases for all namespaces at once (#2139) * Load releases for all namespaces at once Signed-off-by: Alex Andreev * Check for accessibleNamespaces length Signed-off-by: Alex Andreev * Replacing loadAll() to loadFromContextNamespaces() Signed-off-by: Alex Andreev --- .../components/+apps-releases/release.store.ts | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) 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) { From 8692bdefa273e92fd096478e759f284c4f1f4d23 Mon Sep 17 00:00:00 2001 From: Jari Kolehmainen Date: Tue, 16 Feb 2021 19:25:38 +0200 Subject: [PATCH 22/32] Release v4.1.0 (#2162) Signed-off-by: Jari Kolehmainen --- package.json | 2 +- static/RELEASE_NOTES.md | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 1e5c73a9b5..c76cdc2835 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-rc.2", + "version": "4.1.0", "main": "static/build/main.js", "copyright": "© 2020, Mirantis, Inc.", "license": "MIT", diff --git a/static/RELEASE_NOTES.md b/static/RELEASE_NOTES.md index 0fe2fa4c4d..8c43fcda18 100644 --- a/static/RELEASE_NOTES.md +++ b/static/RELEASE_NOTES.md @@ -2,7 +2,9 @@ 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-rc.2 (current version) +## 4.1.0 (current version) + +**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 From 0724819915585c21f4d5670f3f47c459127c768d Mon Sep 17 00:00:00 2001 From: Sebastian Malton Date: Tue, 16 Feb 2021 13:11:25 -0500 Subject: [PATCH 23/32] return undefined instead of localhost from getClusterIdFromHost (#2143) * return undefined instead of localhost from getClusterIdFromHost Signed-off-by: Sebastian Malton --- src/common/__tests__/cluster-store.test.ts | 26 +++++++++++++++++++++- src/common/cluster-store.ts | 9 ++++---- 2 files changed, 30 insertions(+), 5 deletions(-) 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 { From b8c78cd18f00e51cee4267b554639c709d6caa32 Mon Sep 17 00:00:00 2001 From: Roman Date: Wed, 17 Feb 2021 13:39:27 +0200 Subject: [PATCH 24/32] fix: keep last selected row during navigation in the details panel (#2165) Signed-off-by: Roman --- .../kube-object/kube-object-details.tsx | 30 ++++++++++++------- src/renderer/navigation/page-param.ts | 2 +- 2 files changed, 21 insertions(+), 11 deletions(-) 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/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; From 174bba98d78ac8de5700938e0a641de1cc250e13 Mon Sep 17 00:00:00 2001 From: Jari Kolehmainen Date: Wed, 17 Feb 2021 22:28:33 +0200 Subject: [PATCH 25/32] Fix isLoadingAll within KubeObjectStore.loadItems (#2174) * fix isLoadingAll within KubeObjectStore.loadItems Signed-off-by: Jari Kolehmainen * fix isLoadingAll within KubeObjectStore.loadItems Signed-off-by: Jari Kolehmainen --- src/renderer/kube-object.store.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/renderer/kube-object.store.ts b/src/renderer/kube-object.store.ts index 023048cb73..987112a25c 100644 --- a/src/renderer/kube-object.store.ts +++ b/src/renderer/kube-object.store.ts @@ -109,9 +109,11 @@ 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.context.cluster.accessibleNamespaces.length === 0) { + if (isLoadingAll) { this.loadedNamespaces = []; return api.list({}, this.query); From 32908883f811739da3a6c22a70c6eacb95df18d5 Mon Sep 17 00:00:00 2001 From: Jari Kolehmainen Date: Thu, 18 Feb 2021 07:30:48 +0200 Subject: [PATCH 26/32] Windows: use SHELL for terminal if set (#2168) Signed-off-by: Jari Kolehmainen --- src/main/shell-session.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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; From 67395253bea325fbbb7176be58cdce7feb927cf6 Mon Sep 17 00:00:00 2001 From: Lauri Nevala Date: Thu, 18 Feb 2021 07:31:40 +0200 Subject: [PATCH 27/32] Add test for re-registering store (#2161) Signed-off-by: Lauri Nevala --- .../api/__tests__/api-manager.test.ts | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 src/renderer/api/__tests__/api-manager.test.ts 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); + }); + }); +}); From 4d6cba474133d21f4b380fc5b69b2fc4c2c3091b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 18 Feb 2021 08:36:29 +0200 Subject: [PATCH 28/32] Bump xterm from 4.6.0 to 4.10.0 (#2072) Bumps [xterm](https://github.com/xtermjs/xterm.js) from 4.6.0 to 4.10.0. - [Release notes](https://github.com/xtermjs/xterm.js/releases) - [Commits](https://github.com/xtermjs/xterm.js/compare/4.6.0...4.10.0) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index c76cdc2835..793ca9de47 100644 --- a/package.json +++ b/package.json @@ -359,7 +359,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/yarn.lock b/yarn.lock index 16ac2ca4f8..2cc97b2383 100644 --- a/yarn.lock +++ b/yarn.lock @@ -14384,10 +14384,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" From 5029196d15247ebcbd2aa19b3883d148e76be6bf Mon Sep 17 00:00:00 2001 From: Roman Date: Thu, 18 Feb 2021 12:21:41 +0200 Subject: [PATCH 29/32] Fix: events sorting with compact=true is broken (#2141) * fix: use default sorting for as on "timeline" (fresh on top) Signed-off-by: Roman * responding to comments Signed-off-by: Roman * convert comments to jsdoc (table.tsx) Signed-off-by: Roman --- src/renderer/api/kube-object.ts | 11 +- .../components/+events/event.store.ts | 4 +- src/renderer/components/+events/events.tsx | 116 +++++++++++------- .../deployment-scale-dialog.test.tsx | 5 +- .../replicaset-scale-dialog.test.tsx | 6 +- .../statefulset-scale-dialog.test.tsx | 5 +- .../item-object-list/item-list-layout.tsx | 4 +- src/renderer/components/table/table.tsx | 11 +- 8 files changed, 101 insertions(+), 61 deletions(-) diff --git a/src/renderer/api/kube-object.ts b/src/renderer/api/kube-object.ts index d16d65b6da..4ffd464d91 100644 --- a/src/renderer/api/kube-object.ts +++ b/src/renderer/api/kube-object.ts @@ -122,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/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 f385555aaa..4b7b64dd15 100644 --- a/src/renderer/components/+events/events.tsx +++ b/src/renderer/components/+events/events.tsx @@ -1,11 +1,15 @@ 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"; @@ -37,23 +41,79 @@ const defaultProps: Partial = { export class Events extends React.Component { static defaultProps = defaultProps as object; - get store() { + @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; } - get items() { - return eventStore.contextItems; + @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); } - render() { - const { store, items } = this; - const { compact, compactLimit, className, ...layoutProps } = this.props; - const visibleItems = compact ? items.slice(0, compactLimit) : items; + @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; - const compactModeHeader = <> - Events ({visibleItems.length} of {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 = ( { tableId="events" store={store} className={cssNames("Events", className, { compact })} + renderHeaderTitle="Events" + customizeHeader={this.customizeHeader} isSelectable={false} items={visibleItems} virtual={!compact} - renderHeaderTitle={compact && !allEventsAreShown ? compactModeHeader : "Events"} - tableProps={{ - sortSyncWithUrl: false, - sortByDefault: { - sortBy: columnId.age, - orderBy: "desc", // show "Warning" events at the top - }, - }} - sortingCallbacks={{ - [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.metadata.creationTimestamp, - }} + tableProps={this.tableConfiguration} + sortingCallbacks={this.sortingCallbacks} searchFilters={[ (event: KubeEvent) => event.getSearchFields(), (event: KubeEvent) => event.message, (event: KubeEvent) => event.getSource(), (event: KubeEvent) => event.involvedObject.name, ]} - 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 }, 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-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/item-object-list/item-list-layout.tsx b/src/renderer/components/item-object-list/item-list-layout.tsx index 59bd18cd5e..895c275fbb 100644 --- a/src/renderer/components/item-object-list/item-list-layout.tsx +++ b/src/renderer/components/item-object-list/item-list-layout.tsx @@ -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; @@ -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/table/table.tsx b/src/renderer/components/table/table.tsx index 9b5d396e85..19bd5b03e5 100644 --- a/src/renderer/components/table/table.tsx +++ b/src/renderer/components/table/table.tsx @@ -16,6 +16,7 @@ export type TableSortBy = string; export type TableOrderBy = "asc" | "desc" | string; export type TableSortParams = { sortBy: TableSortBy; orderBy: TableOrderBy }; export type TableSortCallback = (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 From 4ce618b4d4161efacc4cb5074b1d4798b1a01299 Mon Sep 17 00:00:00 2001 From: Jari Kolehmainen Date: Thu, 18 Feb 2021 13:36:35 +0200 Subject: [PATCH 30/32] Trigger quitAndInstall after update-downloaded event (#2181) Signed-off-by: Jari Kolehmainen --- src/main/app-updater.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/main/app-updater.ts b/src/main/app-updater.ts index 618f714b49..5d9509c6e4 100644 --- a/src/main/app-updater.ts +++ b/src/main/app-updater.ts @@ -9,9 +9,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; From 9f15bb9626558731bf8fe40aca44d5a1417b19d1 Mon Sep 17 00:00:00 2001 From: Jari Kolehmainen Date: Thu, 18 Feb 2021 15:11:03 +0200 Subject: [PATCH 31/32] Wait for cluster to be ready before loading iframe (#2183) Signed-off-by: Jari Kolehmainen --- src/renderer/components/cluster-manager/lens-views.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/renderer/components/cluster-manager/lens-views.ts b/src/renderer/components/cluster-manager/lens-views.ts index da7efb377c..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"); From 41f1cdff653025dda71d76596309dad5eb7d95a0 Mon Sep 17 00:00:00 2001 From: Rafed Ramzi <68276604+rafedramzi@users.noreply.github.com> Date: Thu, 18 Feb 2021 21:42:23 +0700 Subject: [PATCH 32/32] Fix Documentation: fix broken link in getting-started page (#2155) Signed-off-by: Rafed Ramzi --- docs/getting-started/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/getting-started/README.md b/docs/getting-started/README.md index 704e59d97b..bfb990378d 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