diff --git a/.github/release-drafter.yml b/.github/release-drafter.yml index 4f61dc9d36..02ae36727e 100644 --- a/.github/release-drafter.yml +++ b/.github/release-drafter.yml @@ -12,6 +12,7 @@ categories: - 'chore' - 'area/ci' - 'area/tests' + - 'dependencies' template: | ## Changes since $PREVIOUS_TAG @@ -20,8 +21,10 @@ template: | ### Download - - [Lens v$RESOLVED_VERSION - Linux](https://snapcraft.io/kontena-lens) - - [AppImage](https://github.com/lensapp/lens/releases/download/v$RESOLVED_VERSION/Lens-$RESOLVED_VERSION.AppImage) + - Lens v$RESOLVED_VERSION - Linux + - [AppImage](https://github.com/lensapp/lens/releases/download/v$RESOLVED_VERSION/Lens-$RESOLVED_VERSION.x86_64.AppImage) + - [DEB](https://github.com/lensapp/lens/releases/download/v$RESOLVED_VERSION/Lens-$RESOLVED_VERSION.amd64.deb) + - [RPM](https://github.com/lensapp/lens/releases/download/v$RESOLVED_VERSION/Lens-$RESOLVED_VERSION.x86_64.rpm) - [Snapcraft](https://snapcraft.io/kontena-lens) - [Lens v$RESOLVED_VERSION - MacOS](https://github.com/lensapp/lens/releases/download/v$RESOLVED_VERSION/Lens-$RESOLVED_VERSION.dmg) - [Lens v$RESOLVED_VERSION - Windows](https://github.com/lensapp/lens/releases/download/v$RESOLVED_VERSION/Lens-Setup-$RESOLVED_VERSION.exe) diff --git a/package.json b/package.json index d39009b267..2391c435e0 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "kontena-lens", "productName": "Lens", "description": "Lens - The Kubernetes IDE", - "version": "4.1.0-alpha.1", + "version": "4.1.0-alpha.2", "main": "static/build/main.js", "copyright": "© 2020, Mirantis, Inc.", "license": "MIT", @@ -103,7 +103,6 @@ ], "linux": { "category": "Network", - "executableName": "lens", "artifactName": "${productName}-${version}.${arch}.${ext}", "target": [ "deb", @@ -162,6 +161,9 @@ "oneClick": false, "allowToChangeInstallationDirectory": true }, + "snap": { + "confinement": "classic" + }, "publish": [ { "provider": "github", @@ -169,9 +171,6 @@ "owner": "lensapp" } ], - "snap": { - "confinement": "classic" - }, "protocols": { "name": "Lens Protocol Handler", "schemes": [ @@ -302,7 +301,7 @@ "@types/webpack-dev-server": "^3.11.1", "@types/webpack-env": "^1.15.2", "@types/webpack-node-externals": "^1.7.1", - "@typescript-eslint/eslint-plugin": "^4.12.0", + "@typescript-eslint/eslint-plugin": "^4.14.2", "@typescript-eslint/parser": "^4.0.0", "ace-builds": "^1.4.11", "ansi_up": "^4.0.4", diff --git a/src/main/__test__/cluster.test.ts b/src/main/__test__/cluster.test.ts index b3f0442cc2..4b11a19879 100644 --- a/src/main/__test__/cluster.test.ts +++ b/src/main/__test__/cluster.test.ts @@ -126,6 +126,7 @@ describe("create clusters", () => { }; jest.spyOn(Cluster.prototype, "isClusterAdmin").mockReturnValue(Promise.resolve(true)); + jest.spyOn(Cluster.prototype, "canUseWatchApi").mockReturnValue(Promise.resolve(true)); jest.spyOn(Cluster.prototype, "canI") .mockImplementation((attr: V1ResourceAttributes): Promise => { expect(attr.namespace).toBe("default"); diff --git a/src/main/cluster.ts b/src/main/cluster.ts index 956164e10c..cd92ab2650 100644 --- a/src/main/cluster.ts +++ b/src/main/cluster.ts @@ -48,6 +48,7 @@ export interface ClusterState { isAdmin: boolean; allowedNamespaces: string[] allowedResources: string[] + isGlobalWatchEnabled: boolean; } /** @@ -91,7 +92,6 @@ export class Cluster implements ClusterModel, ClusterState { */ @observable initializing = false; - /** * Is cluster object initialized * @@ -177,6 +177,12 @@ export class Cluster implements ClusterModel, ClusterState { * @observable */ @observable isAdmin = false; + /** + * Global watch-api accessibility , e.g. "/api/v1/services?watch=1" + * + * @observable + */ + @observable isGlobalWatchEnabled = false; /** * Preferences * @@ -353,9 +359,7 @@ export class Cluster implements ClusterModel, ClusterState { await this.refreshConnectionStatus(); if (this.accessible) { - await this.refreshAllowedResources(); - this.isAdmin = await this.isClusterAdmin(); - this.ready = true; + await this.refreshAccessibility(); this.ensureKubectl(); } this.activated = true; @@ -410,13 +414,11 @@ export class Cluster implements ClusterModel, ClusterState { await this.refreshConnectionStatus(); if (this.accessible) { - this.isAdmin = await this.isClusterAdmin(); - await this.refreshAllowedResources(); + await this.refreshAccessibility(); if (opts.refreshMetadata) { this.refreshMetadata(); } - this.ready = true; } this.pushState(); } @@ -433,6 +435,18 @@ export class Cluster implements ClusterModel, ClusterState { this.metadata = Object.assign(existingMetadata, metadata); } + /** + * @internal + */ + private async refreshAccessibility(): Promise { + this.isAdmin = await this.isClusterAdmin(); + this.isGlobalWatchEnabled = await this.canUseWatchApi({ resource: "*" }); + + await this.refreshAllowedResources(); + + this.ready = true; + } + /** * @internal */ @@ -571,6 +585,17 @@ export class Cluster implements ClusterModel, ClusterState { }); } + /** + * @internal + */ + async canUseWatchApi(customizeResource: V1ResourceAttributes = {}): Promise { + return this.canI({ + verb: "watch", + resource: "*", + ...customizeResource, + }); + } + toJSON(): ClusterModel { const model: ClusterModel = { id: this.id, @@ -604,6 +629,7 @@ export class Cluster implements ClusterModel, ClusterState { isAdmin: this.isAdmin, allowedNamespaces: this.allowedNamespaces, allowedResources: this.allowedResources, + isGlobalWatchEnabled: this.isGlobalWatchEnabled, }; return toJS(state, { diff --git a/src/main/tray.ts b/src/main/tray.ts index 47f641ad72..44a22d27bf 100644 --- a/src/main/tray.ts +++ b/src/main/tray.ts @@ -62,16 +62,6 @@ function buildTray(icon: string | NativeImage, menu: Menu, windowManager: Window function createTrayMenu(windowManager: WindowManager): Menu { return Menu.buildFromTemplate([ - { - label: "About Lens", - async click() { - // note: argument[1] (browserWindow) not available when app is not focused / hidden - const browserWindow = await windowManager.ensureMainWindow(); - - showAbout(browserWindow); - }, - }, - { type: "separator" }, { label: "Open Lens", async click() { @@ -124,6 +114,15 @@ function createTrayMenu(windowManager: WindowManager): Menu { } }, }, + { + label: "About Lens", + async click() { + // note: argument[1] (browserWindow) not available when app is not focused / hidden + const browserWindow = await windowManager.ensureMainWindow(); + + showAbout(browserWindow); + }, + }, { type: "separator" }, { label: "Quit App", diff --git a/src/renderer/api/kube-watch-api.ts b/src/renderer/api/kube-watch-api.ts index 8adf58676f..d3b2b4512a 100644 --- a/src/renderer/api/kube-watch-api.ts +++ b/src/renderer/api/kube-watch-api.ts @@ -5,12 +5,11 @@ import type { Cluster } from "../../main/cluster"; import type { IKubeWatchEvent, IKubeWatchEventStreamEnd, IWatchRoutePayload } from "../../main/routes/watch-route"; import type { KubeObject } from "./kube-object"; import type { KubeObjectStore } from "../kube-object.store"; -import type { NamespaceStore } from "../components/+namespaces/namespace.store"; import plimit from "p-limit"; import debounce from "lodash/debounce"; -import { comparer, computed, observable, reaction } from "mobx"; -import { autobind, EventEmitter } from "../utils"; +import { autorun, comparer, computed, IReactionDisposer, observable, reaction } from "mobx"; +import { autobind, EventEmitter, noop } from "../utils"; import { ensureObjectSelfLink, KubeApi, parseKubeApi } from "./kube-api"; import { KubeJsonApiData, KubeJsonApiError } from "./kube-json-api"; import { apiPrefix, isDebugging, isProduction } from "../../common/vars"; @@ -19,6 +18,7 @@ import { apiManager } from "./api-manager"; export { IKubeWatchEvent, IKubeWatchEventStreamEnd }; export interface IKubeWatchMessage { + namespace?: string; data?: IKubeWatchEvent error?: IKubeWatchEvent; api?: KubeApi; @@ -28,7 +28,7 @@ export interface IKubeWatchMessage { export interface IKubeWatchSubscribeStoreOptions { preload?: boolean; // preload store items, default: true waitUntilLoaded?: boolean; // subscribe only after loading all stores, default: true - cacheLoading?: boolean; // when enabled loading store will be skipped, default: false + loadOnce?: boolean; // check store.isLoaded to skip loading if done already, default: false } export interface IKubeWatchReconnectOptions { @@ -43,50 +43,49 @@ export interface IKubeWatchLog { @autobind() export class KubeWatchApi { - private cluster: Cluster; - private namespaceStore: NamespaceStore; - private requestId = 0; - private isConnected = false; private reader: ReadableStreamReader; - private subscribers = observable.map(); - - // events public onMessage = new EventEmitter<[IKubeWatchMessage]>(); + @observable.ref private cluster: Cluster; + @observable.ref private namespaces: string[] = []; + @observable subscribers = observable.map(); + @observable isConnected = false; + + @computed get isReady(): boolean { + return Boolean(this.cluster && this.namespaces); + } + @computed get isActive(): boolean { return this.apis.length > 0; } @computed get apis(): string[] { - const { cluster, namespaceStore } = this; - const activeApis = Array.from(this.subscribers.keys()); + if (!this.isReady) { + return []; + } - return activeApis.map(api => { - if (!cluster.isAllowedResource(api.kind)) { + return Array.from(this.subscribers.keys()).map(api => { + if (!this.isAllowedApi(api)) { return []; } - if (api.isNamespaced) { - return namespaceStore.getContextNamespaces().map(namespace => api.getWatchUrl(namespace)); - } else { - return api.getWatchUrl(); + if (api.isNamespaced && !this.cluster.isGlobalWatchEnabled) { + return this.namespaces.map(namespace => api.getWatchUrl(namespace)); } + + return api.getWatchUrl(); }).flat(); } - constructor() { - this.init(); - } - - private async init() { - const { getHostedCluster } = await import("../../common/cluster-store"); - const { namespaceStore } = await import("../components/+namespaces/namespace.store"); - - await namespaceStore.whenReady; - - this.cluster = getHostedCluster(); - this.namespaceStore = namespaceStore; + async init({ getCluster, getNamespaces }: { + getCluster: () => Cluster, + getNamespaces: () => string[], + }): Promise { + autorun(() => { + this.cluster = getCluster(); + this.namespaces = getNamespaces(); + }); this.bindAutoConnect(); } @@ -108,7 +107,7 @@ export class KubeWatchApi { } isAllowedApi(api: KubeApi): boolean { - return !!this?.cluster.isAllowedResource(api.kind); + return Boolean(this?.cluster.isAllowedResource(api.kind)); } subscribeApi(api: KubeApi | KubeApi[]): () => void { @@ -129,45 +128,66 @@ export class KubeWatchApi { }; } - subscribeStores(stores: KubeObjectStore[], options: IKubeWatchSubscribeStoreOptions = {}): () => void { - const { preload = true, waitUntilLoaded = true, cacheLoading = false } = options; + preloadStores(stores: KubeObjectStore[], { loadOnce = false } = {}) { const limitRequests = plimit(1); // load stores one by one to allow quick skipping when fast clicking btw pages const preloading: Promise[] = []; + + for (const store of stores) { + preloading.push(limitRequests(async () => { + if (store.isLoaded && loadOnce) return; // skip + + return store.loadAll(this.namespaces); + })); + } + + return { + loading: Promise.allSettled(preloading), + cancelLoading: () => limitRequests.clearQueue(), + }; + } + + subscribeStores(stores: KubeObjectStore[], options: IKubeWatchSubscribeStoreOptions = {}): () => void { + const { preload = true, waitUntilLoaded = true, loadOnce = false } = options; const apis = new Set(stores.map(store => store.getSubscribeApis()).flat()); const unsubscribeList: (() => void)[] = []; let isUnsubscribed = false; + const load = () => this.preloadStores(stores, { loadOnce }); + let preloading = preload && load(); + let cancelReloading: IReactionDisposer = noop; + const subscribe = () => { if (isUnsubscribed) return; apis.forEach(api => unsubscribeList.push(this.subscribeApi(api))); }; - if (preload) { - for (const store of stores) { - preloading.push(limitRequests(async () => { - if (cacheLoading && store.isLoaded) return; // skip - - return store.loadAll(); - })); - } - } - - if (waitUntilLoaded) { - Promise.all(preloading).then(subscribe, error => { - this.log({ - message: new Error("Loading stores has failed"), - meta: { stores, error, options }, + if (preloading) { + if (waitUntilLoaded) { + preloading.loading.then(subscribe, error => { + this.log({ + message: new Error("Loading stores has failed"), + meta: { stores, error, options }, + }); }); + } else { + subscribe(); + } + + // reload when context namespaces changes + cancelReloading = reaction(() => this.namespaces, () => { + preloading?.cancelLoading(); + preloading = load(); + }, { + equals: comparer.shallow, }); - } else { - subscribe(); } // unsubscribe return () => { if (isUnsubscribed) return; isUnsubscribed = true; - limitRequests.clearQueue(); + cancelReloading(); + preloading?.cancelLoading(); unsubscribeList.forEach(unsubscribe => unsubscribe()); }; } @@ -254,6 +274,10 @@ export class KubeWatchApi { const kubeEvent: IKubeWatchEvent = JSON.parse(json); const message = this.getMessage(kubeEvent); + if (!this.namespaces.includes(message.namespace)) { + continue; // skip updates from non-watching resources context + } + this.onMessage.emit(message); } catch (error) { return json; @@ -286,6 +310,7 @@ export class KubeWatchApi { message.api = api; message.store = apiManager.getStore(api); + message.namespace = namespace; } break; } diff --git a/src/renderer/components/+apps-releases/release.store.ts b/src/renderer/components/+apps-releases/release.store.ts index 6f7ed39fed..0ca6f45b39 100644 --- a/src/renderer/components/+apps-releases/release.store.ts +++ b/src/renderer/components/+apps-releases/release.store.ts @@ -58,11 +58,11 @@ export class ReleaseStore extends ItemStore { } @action - async loadAll() { + async loadAll(namespaces = namespaceStore.allowedNamespaces) { this.isLoading = true; try { - const items = await this.loadItems(namespaceStore.getContextNamespaces()); + const items = await this.loadItems(namespaces); this.items.replace(this.sortItems(items)); this.isLoaded = true; @@ -73,6 +73,10 @@ export class ReleaseStore extends ItemStore { } } + async loadSelectedNamespaces(): Promise { + return this.loadAll(namespaceStore.getContextNamespaces()); + } + async loadItems(namespaces: string[]) { return Promise .all(namespaces.map(namespace => helmReleasesApi.list(namespace))) @@ -82,7 +86,7 @@ export class ReleaseStore extends ItemStore { async create(payload: IReleaseCreatePayload) { const response = await helmReleasesApi.create(payload); - if (this.isLoaded) this.loadAll(); + if (this.isLoaded) this.loadSelectedNamespaces(); return response; } @@ -90,7 +94,7 @@ export class ReleaseStore extends ItemStore { async update(name: string, namespace: string, payload: IReleaseUpdatePayload) { const response = await helmReleasesApi.update(name, namespace, payload); - if (this.isLoaded) this.loadAll(); + if (this.isLoaded) this.loadSelectedNamespaces(); return response; } @@ -98,7 +102,7 @@ export class ReleaseStore extends ItemStore { async rollback(name: string, namespace: string, revision: number) { const response = await helmReleasesApi.rollback(name, namespace, revision); - if (this.isLoaded) this.loadAll(); + if (this.isLoaded) this.loadSelectedNamespaces(); return response; } diff --git a/src/renderer/components/+custom-resources/crd-resources.tsx b/src/renderer/components/+custom-resources/crd-resources.tsx index b9008b410d..2bae92b8d4 100644 --- a/src/renderer/components/+custom-resources/crd-resources.tsx +++ b/src/renderer/components/+custom-resources/crd-resources.tsx @@ -30,7 +30,7 @@ export class CrdResources extends React.Component { const { store } = this; if (store && !store.isLoading && !store.isLoaded) { - store.loadAll(); + store.loadSelectedNamespaces(); } }) ]); diff --git a/src/renderer/components/+events/kube-event-details.tsx b/src/renderer/components/+events/kube-event-details.tsx index 34b16103f0..60821d416d 100644 --- a/src/renderer/components/+events/kube-event-details.tsx +++ b/src/renderer/components/+events/kube-event-details.tsx @@ -14,7 +14,7 @@ export interface KubeEventDetailsProps { @observer export class KubeEventDetails extends React.Component { async componentDidMount() { - eventStore.loadAll(); + eventStore.loadSelectedNamespaces(); } render() { diff --git a/src/renderer/components/+namespaces/namespace-details.tsx b/src/renderer/components/+namespaces/namespace-details.tsx index 5dfa93cea7..e7397b6a5e 100644 --- a/src/renderer/components/+namespaces/namespace-details.tsx +++ b/src/renderer/components/+namespaces/namespace-details.tsx @@ -32,8 +32,8 @@ export class NamespaceDetails extends React.Component { } componentDidMount() { - resourceQuotaStore.loadAll(); - limitRangeStore.loadAll(); + resourceQuotaStore.loadSelectedNamespaces(); + limitRangeStore.loadSelectedNamespaces(); } render() { diff --git a/src/renderer/components/+namespaces/namespace-select.tsx b/src/renderer/components/+namespaces/namespace-select.tsx index 6ee7ea2d57..5bcf07e1cf 100644 --- a/src/renderer/components/+namespaces/namespace-select.tsx +++ b/src/renderer/components/+namespaces/namespace-select.tsx @@ -13,17 +13,14 @@ import { kubeWatchApi } from "../../api/kube-watch-api"; interface Props extends SelectProps { showIcons?: boolean; - showClusterOption?: boolean; // show cluster option on the top (default: false) - clusterOptionLabel?: React.ReactNode; // label for cluster option (default: "Cluster") - customizeOptions?(nsOptions: SelectOption[]): SelectOption[]; + showClusterOption?: boolean; // show "Cluster" option on the top (default: false) + showAllNamespacesOption?: boolean; // show "All namespaces" option on the top (default: false) + customizeOptions?(options: SelectOption[]): SelectOption[]; } const defaultProps: Partial = { showIcons: true, showClusterOption: false, - get clusterOptionLabel() { - return `Cluster`; - }, }; @observer @@ -39,13 +36,17 @@ export class NamespaceSelect extends React.Component { } @computed get options(): SelectOption[] { - const { customizeOptions, showClusterOption, clusterOptionLabel } = this.props; + const { customizeOptions, showClusterOption, showAllNamespacesOption } = this.props; let options: SelectOption[] = namespaceStore.items.map(ns => ({ value: ns.getName() })); - options = customizeOptions ? customizeOptions(options) : options; + if (showAllNamespacesOption) { + options.unshift({ label: "All Namespaces", value: "" }); + } else if (showClusterOption) { + options.unshift({ label: "Cluster", value: "" }); + } - if (showClusterOption) { - options.unshift({ value: null, label: clusterOptionLabel }); + if (customizeOptions) { + options = customizeOptions(options); } return options; @@ -64,7 +65,7 @@ export class NamespaceSelect extends React.Component { }; render() { - const { className, showIcons, showClusterOption, clusterOptionLabel, customizeOptions, ...selectProps } = this.props; + const { className, showIcons, customizeOptions, ...selectProps } = this.props; return (