From 4161ee832c02ec6407317d21b1bd1b0a73345b3c Mon Sep 17 00:00:00 2001 From: Jari Kolehmainen Date: Tue, 1 Dec 2020 15:27:54 +0200 Subject: [PATCH 01/26] Hide disabled workspaces/clusters (#1573) Signed-off-by: Jari Kolehmainen --- src/common/__tests__/cluster-store.test.ts | 24 +++++- src/common/__tests__/workspace-store.test.ts | 8 +- src/common/cluster-store.ts | 56 +++++++++++++- src/common/workspace-store.ts | 76 +++++++++++++++++-- src/main/cluster.ts | 2 + .../components/+workspaces/workspaces.tsx | 2 +- .../cluster-manager/clusters-menu.tsx | 44 ++++++----- 7 files changed, 180 insertions(+), 32 deletions(-) diff --git a/src/common/__tests__/cluster-store.test.ts b/src/common/__tests__/cluster-store.test.ts index 603a7c1b9e..d2d31302d1 100644 --- a/src/common/__tests__/cluster-store.test.ts +++ b/src/common/__tests__/cluster-store.test.ts @@ -7,6 +7,20 @@ import { workspaceStore } from "../workspace-store"; const testDataIcon = fs.readFileSync("test-data/cluster-store-migration-icon.png"); +jest.mock("electron", () => { + return { + app: { + getVersion: () => "99.99.99", + getPath: () => "tmp", + getLocale: () => "en" + }, + ipcMain: { + handle: jest.fn(), + on: jest.fn() + } + }; +}); + let clusterStore: ClusterStore; describe("empty config", () => { @@ -48,6 +62,7 @@ describe("empty config", () => { expect(storedCluster.id).toBe("foo"); expect(storedCluster.preferences.terminalCWD).toBe("/tmp"); expect(storedCluster.preferences.icon).toBe("data:image/jpeg;base64, iVBORw0KGgoAAAANSUhEUgAAA1wAAAKoCAYAAABjkf5"); + expect(storedCluster.enabled).toBe(true); }); it("adds cluster to default workspace", () => { @@ -170,7 +185,8 @@ describe("config with existing clusters", () => { kubeConfig: "foo", contextName: "foo", preferences: { terminalCWD: "/foo" }, - workspace: "foo" + workspace: "foo", + ownerRef: "foo" }, ] }) @@ -208,6 +224,12 @@ describe("config with existing clusters", () => { expect(storedClusters[1].preferences.terminalCWD).toBe("/foo2"); expect(storedClusters[2].id).toBe("cluster3"); }); + + it("marks owned cluster disabled by default", () => { + const storedClusters = clusterStore.clustersList; + expect(storedClusters[0].enabled).toBe(true); + expect(storedClusters[2].enabled).toBe(false); + }); }); describe("pre 2.0 config with an existing cluster", () => { diff --git a/src/common/__tests__/workspace-store.test.ts b/src/common/__tests__/workspace-store.test.ts index e50bb23c99..a15128f4e3 100644 --- a/src/common/__tests__/workspace-store.test.ts +++ b/src/common/__tests__/workspace-store.test.ts @@ -6,6 +6,10 @@ jest.mock("electron", () => { getVersion: () => "99.99.99", getPath: () => "tmp", getLocale: () => "en" + }, + ipcMain: { + handle: jest.fn(), + on: jest.fn() } }; }); @@ -60,7 +64,9 @@ describe("workspace store tests", () => { name: "foobar", })); - expect(ws.getById("123").name).toBe("foobar"); + const workspace = ws.getById("123"); + expect(workspace.name).toBe("foobar"); + expect(workspace.enabled).toBe(true); }); it("cannot set a non-existent workspace to be active", () => { diff --git a/src/common/cluster-store.ts b/src/common/cluster-store.ts index 35ec663a55..5a9827eee5 100644 --- a/src/common/cluster-store.ts +++ b/src/common/cluster-store.ts @@ -11,7 +11,7 @@ import { appEventBus } from "./event-bus"; import { dumpConfigYaml } from "./kube-helpers"; import { saveToAppFiles } from "./utils/saveToAppFiles"; import { KubeConfig } from "@kubernetes/client-node"; -import { subscribeToBroadcast, unsubscribeAllFromBroadcast } from "./ipc"; +import { handleRequest, requestMain, subscribeToBroadcast, unsubscribeAllFromBroadcast } from "./ipc"; import _ from "lodash"; import move from "array-move"; import type { WorkspaceId } from "./workspace-store"; @@ -40,13 +40,30 @@ export interface ClusterStoreModel { export type ClusterId = string; export interface ClusterModel { + /** Unique id for a cluster */ id: ClusterId; + + /** Path to cluster kubeconfig */ kubeConfigPath: string; + + /** Workspace id */ workspace?: WorkspaceId; + + /** User context in kubeconfig */ contextName?: string; + + /** Preferences */ preferences?: ClusterPreferences; + + /** Metadata */ metadata?: ClusterMetadata; + + /** + * If extension sets ownerRef it has to explicitly mark a cluster as enabled during onActive (or when cluster is saved) + */ ownerRef?: string; + + /** List of accessible namespaces */ accessibleNamespaces?: string[]; /** @deprecated */ @@ -89,6 +106,8 @@ export class ClusterStore extends BaseStore { @observable removedClusters = observable.map(); @observable clusters = observable.map(); + private static stateRequestChannel = "cluster:states"; + private constructor() { super({ configName: "lens-cluster-store", @@ -102,8 +121,40 @@ export class ClusterStore extends BaseStore { this.pushStateToViewsAutomatically(); } + async load() { + await super.load(); + type clusterStateSync = { + id: string; + state: ClusterState; + }; + if (ipcRenderer) { + logger.info("[CLUSTER-STORE] requesting initial state sync"); + const clusterStates: clusterStateSync[] = await requestMain(ClusterStore.stateRequestChannel); + clusterStates.forEach((clusterState) => { + const cluster = this.getById(clusterState.id); + if (cluster) { + cluster.setState(clusterState.state); + } + }); + } else { + handleRequest(ClusterStore.stateRequestChannel, (): clusterStateSync[] => { + const states: clusterStateSync[] = []; + this.clustersList.forEach((cluster) => { + states.push({ + state: cluster.getState(), + id: cluster.id + }); + }); + return states; + }); + } + } + protected pushStateToViewsAutomatically() { if (!ipcRenderer) { + reaction(() => this.enabledClustersList, () => { + this.pushState(); + }); reaction(() => this.connectedClustersList, () => { this.pushState(); }); @@ -205,6 +256,9 @@ export class ClusterStore extends BaseStore { if (!(model instanceof Cluster)) { cluster = new Cluster(model); } + if (!cluster.isManaged) { + cluster.enabled = true; + } this.clusters.set(model.id, cluster); return cluster; } diff --git a/src/common/workspace-store.ts b/src/common/workspace-store.ts index 75ab36f19e..4d5af6da98 100644 --- a/src/common/workspace-store.ts +++ b/src/common/workspace-store.ts @@ -3,7 +3,7 @@ import { action, computed, observable, toJS, reaction } from "mobx"; import { BaseStore } from "./base-store"; import { clusterStore } from "./cluster-store"; import { appEventBus } from "./event-bus"; -import { broadcastMessage } from "../common/ipc"; +import { broadcastMessage, handleRequest, requestMain } from "../common/ipc"; import logger from "../main/logger"; import type { ClusterId } from "./cluster-store"; @@ -27,11 +27,45 @@ export interface WorkspaceState { } export class Workspace implements WorkspaceModel, WorkspaceState { + /** + * Unique id for workspace + * + * @observable + */ @observable id: WorkspaceId; + /** + * Workspace name + * + * @observable + */ @observable name: string; + /** + * Workspace description + * + * @observable + */ @observable description?: string; + /** + * Workspace owner reference + * + * If extension sets ownerRef then it needs to explicitly mark workspace as enabled onActivate (or when workspace is saved) + * + * @observable + */ @observable ownerRef?: string; + /** + * Is workspace enabled + * + * Workspaces that don't have ownerRef will be enabled by default. Workspaces with ownerRef need to explicitly enable a workspace. + * + * @observable + */ @observable enabled: boolean; + /** + * Last active cluster id + * + * @observable + */ @observable lastActiveClusterId?: ClusterId; constructor(data: WorkspaceModel) { @@ -49,9 +83,9 @@ export class Workspace implements WorkspaceModel, WorkspaceState { } getState(): WorkspaceState { - return { + return toJS({ enabled: this.enabled - }; + }); } pushState(state = this.getState()) { @@ -77,16 +111,40 @@ export class Workspace implements WorkspaceModel, WorkspaceState { export class WorkspaceStore extends BaseStore { static readonly defaultId: WorkspaceId = "default"; + private static stateRequestChannel = "workspace:states"; private constructor() { super({ configName: "lens-workspace-store", }); + } - if (!ipcRenderer) { - setInterval(() => { - this.pushState(); - }, 5000); + async load() { + await super.load(); + type workspaceStateSync = { + id: string; + state: WorkspaceState; + }; + if (ipcRenderer) { + logger.info("[WORKSPACE-STORE] requesting initial state sync"); + const workspaceStates: workspaceStateSync[] = await requestMain(WorkspaceStore.stateRequestChannel); + workspaceStates.forEach((workspaceState) => { + const workspace = this.getById(workspaceState.id); + if (workspace) { + workspace.setState(workspaceState.state); + } + }); + } else { + handleRequest(WorkspaceStore.stateRequestChannel, (): workspaceStateSync[] => { + const states: workspaceStateSync[] = []; + this.workspacesList.forEach((workspace) => { + states.push({ + state: workspace.getState(), + id: workspace.id + }); + }); + return states; + }); } } @@ -157,6 +215,10 @@ export class WorkspaceStore extends BaseStore { return; } this.workspaces.set(id, workspace); + if (!workspace.isManaged) { + workspace.enabled = true; + } + appEventBus.emit({name: "workspace", action: "add"}); return workspace; } diff --git a/src/main/cluster.ts b/src/main/cluster.ts index 5ee7457529..3f03f90716 100644 --- a/src/main/cluster.ts +++ b/src/main/cluster.ts @@ -37,6 +37,7 @@ export type ClusterRefreshOptions = { export interface ClusterState { initialized: boolean; + enabled: boolean; apiUrl: string; online: boolean; disconnected: boolean; @@ -351,6 +352,7 @@ export class Cluster implements ClusterModel, ClusterState { getState(): ClusterState { const state: ClusterState = { initialized: this.initialized, + enabled: this.enabled, apiUrl: this.apiUrl, online: this.online, ready: this.ready, diff --git a/src/renderer/components/+workspaces/workspaces.tsx b/src/renderer/components/+workspaces/workspaces.tsx index ccffcca3b7..4dd3cd14b5 100644 --- a/src/renderer/components/+workspaces/workspaces.tsx +++ b/src/renderer/components/+workspaces/workspaces.tsx @@ -21,7 +21,7 @@ export class Workspaces extends React.Component { @computed get workspaces(): Workspace[] { const currentWorkspaces: Map = new Map(); - workspaceStore.workspacesList.forEach((w) => { + workspaceStore.enabledWorkspacesList.forEach((w) => { currentWorkspaces.set(w.id, w); }); const allWorkspaces = new Map([ diff --git a/src/renderer/components/cluster-manager/clusters-menu.tsx b/src/renderer/components/cluster-manager/clusters-menu.tsx index ab608815aa..c3c710f8d9 100644 --- a/src/renderer/components/cluster-manager/clusters-menu.tsx +++ b/src/renderer/components/cluster-manager/clusters-menu.tsx @@ -65,26 +65,28 @@ export class ClustersMenu extends React.Component { } })); } - menu.append(new MenuItem({ - label: _i18n._(t`Remove`), - click: () => { - ConfirmDialog.open({ - okButtonProps: { - primary: false, - accent: true, - label: _i18n._(t`Remove`), - }, - ok: () => { - if (clusterStore.activeClusterId === cluster.id) { - navigate(landingURL()); - clusterStore.setActive(null); - } - clusterStore.removeById(cluster.id); - }, - message:

Are you sure want to remove cluster {cluster.contextName}?

, - }); - } - })); + if (!cluster.isManaged) { + menu.append(new MenuItem({ + label: _i18n._(t`Remove`), + click: () => { + ConfirmDialog.open({ + okButtonProps: { + primary: false, + accent: true, + label: _i18n._(t`Remove`), + }, + ok: () => { + if (clusterStore.activeClusterId === cluster.id) { + navigate(landingURL()); + clusterStore.setActive(null); + } + clusterStore.removeById(cluster.id); + }, + message:

Are you sure want to remove cluster {cluster.contextName}?

, + }); + } + })); + } menu.popup({ window: remote.getCurrentWindow() }); @@ -106,7 +108,7 @@ export class ClustersMenu extends React.Component { const { className } = this.props; const { newContexts } = userStore; const workspace = workspaceStore.getById(workspaceStore.currentWorkspaceId); - const clusters = clusterStore.getByWorkspaceId(workspace.id); + const clusters = clusterStore.getByWorkspaceId(workspace.id).filter(cluster => cluster.enabled); const activeClusterId = clusterStore.activeCluster; return (
From 63ead8e65abc581b56772885052f6e3ec13fc02c Mon Sep 17 00:00:00 2001 From: Jim Ehrismann <40840436+jim-docker@users.noreply.github.com> Date: Tue, 1 Dec 2020 08:57:03 -0500 Subject: [PATCH 02/26] appPreferences guide (#1584) Signed-off-by: Jim Ehrismann --- docs/extensions/guides/renderer-extension.md | 122 +++++++++++++------ docs/extensions/guides/stores.md | 8 ++ 2 files changed, 90 insertions(+), 40 deletions(-) diff --git a/docs/extensions/guides/renderer-extension.md b/docs/extensions/guides/renderer-extension.md index 1e595d004f..7a1393de16 100644 --- a/docs/extensions/guides/renderer-extension.md +++ b/docs/extensions/guides/renderer-extension.md @@ -225,7 +225,7 @@ import { HelpIcon, HelpPage } from "./page" import React from "react" export default class HelpExtension extends LensRendererExtension { - clusterPages = [ + globalPages = [ { id: "help", components: { @@ -379,6 +379,74 @@ The `uninstall()` method is implemented in the example above by utilizing the [` The `updateStatus()` method is implemented above by using the [`K8sApi`](tbd) as well, this time to get information from the `example-pod` pod, in particular to determine if it is installed, what version is associated with it, and if it can be upgraded. How the status is updated for a specific cluster feature is up to the implementation. +### `appPreferences` + +The Preferences page is a built-in global page. Extensions can add custom preferences to the Preferences page, thus providing a single location for users to configure global options, for Lens and extensions alike. The following example demonstrates adding a custom preference: + +``` typescript +import { LensRendererExtension } from "@k8slens/extensions"; +import { ExamplePreference, ExamplePreferenceHint, ExamplePreferenceInput } from "./src/example-preference"; +import { observable } from "mobx"; +import React from "react"; + +export default class ExampleRendererExtension extends LensRendererExtension { + + @observable preference: ExamplePreference = { enabled: false }; + + appPreferences = [ + { + title: "Example Preferences", + components: { + Input: () => , + Hint: () => + } + } + ]; +} +``` + +App preferences are objects matching the `AppPreferenceRegistration` interface. The `title` field specifies the text to show as the heading on the Preferences page. The `components` field specifies two `React.Component` objects defining the interface for the preference. `Input` should specify an interactive input element for your preference and `Hint` should provide descriptive information for the preference, which is shown below the `Input` element. `ExamplePreferenceInput` expects its React props set to an `ExamplePreference` instance, which is how `ExampleRendererExtension` handles the state of the preference input. `ExampleRendererExtension` has the field `preference`, which is provided to `ExamplePreferenceInput` when it is created. In this example `ExamplePreferenceInput`, `ExamplePreferenceHint`, and `ExamplePreference` are defined in `./src/example-preference.tsx`: + +``` typescript +import { Component } from "@k8slens/extensions"; +import { observer } from "mobx-react"; +import React from "react"; + +export type ExamplePreference = { + enabled: boolean; +} + +@observer +export class ExamplePreferenceInput extends React.Component<{preference: ExamplePreference}, {}> { + + render() { + const { preference } = this.props; + return ( + { preference.enabled = v; }} + /> + ); + } +} + +export class ExamplePreferenceHint extends React.Component { + render() { + return ( + This is an example of an appPreference for extensions. + ); + } +} +``` + +`ExamplePreferenceInput` implements a simple checkbox (using Lens' `Component.Checkbox`). It provides `label` as the text to display next to the checkbox and an `onChange` function, which reacts to the checkbox state change. The checkbox's `value` is initially set to `preference.enabled`. `ExamplePreferenceInput` is defined with React props of `ExamplePreference` type, which has a single field, `enabled`. This is used to indicate the state of the preference, and is bound to the checkbox state in `onChange`. `ExamplePreferenceHint` is a simple text span. Note that the input and the hint could comprise of more sophisticated elements, according to the needs of the extension. + +Note that the above example introduces decorators `observable` and `observer` from the [`mobx`](https://mobx.js.org/README.html) and [`mobx-react`](https://github.com/mobxjs/mobx-react#mobx-react) packages. `mobx` simplifies state management and without it this example would not visually update the checkbox properly when the user activates it. [Lens uses `mobx` extensively for state management](../working-with-mobx) of its own UI elements and it is recommended that extensions rely on it too. Alternatively, React's state management can be used instead, though `mobx` is typically simpler to use. + +Also note that an extension's state data can be managed using an `ExtensionStore` object, which conveniently handles persistence and synchronization. The example above defined an `ExamplePreference` type to hold the extension's state to simplify the code for this guide, but it is recommended to manage your extension's state data using [`ExtensionStore`](../stores#extensionstore) + + ********************************************************************* WIP below! @@ -386,62 +454,36 @@ WIP below! - -### `appPreferences` - -The Preferences page is essentially a global page. Extensions can add custom preferences to the Preferences page, thus providing a single location for users to configure global, for Lens and extensions alike. - -``` typescript -import React from "react" -import { LensRendererExtension } from "@k8slens/extensions" -import { myCustomPreferencesStore } from "./src/my-custom-preferences-store" -import { MyCustomPreferenceHint, MyCustomPreferenceInput } from "./src/my-custom-preference" - - -export default class ExampleRendererExtension extends LensRendererExtension { - appPreferences = [ - { - title: "My Custom Preference", - components: { - Hint: () => , - Input: () => - } - } - ]; -} -``` - ### `statusBarItems` -The Status bar is the blue strip along the bottom of the Lens UI. Status bar items are `React.ReactNode` types, which can be used to convey status information, or act as a link to a global page. +The Status bar is the blue strip along the bottom of the Lens UI. Status bar items are `React.ReactNode` types, which can be used to convey status information, or act as a link to a global page, or even an external page. -The following example adds a status bar item definition, as well as a global page definition, to a `LensRendererExtension` subclass, and configures the status bar item to navigate to the global upon a mouse click: +The following example adds a status bar item definition, as well as a global page definition, to a `LensRendererExtension` subclass, and configures the status bar item to navigate to the global page upon a mouse click: ``` typescript -import { LensRendererExtension, Navigation } from '@k8slens/extensions'; -import { MyStatusBarIcon, MyPage } from './page'; +import { LensRendererExtension } from '@k8slens/extensions'; +import { HelpIcon, HelpPage } from "./page" import React from 'react'; -export default class ExtensionRenderer extends LensRendererExtension { +export default class HelpExtension extends LensRendererExtension { globalPages = [ { - path: "/my-extension-path", - hideInMenu: true, + id: "help", components: { - Page: () => , - }, - }, + Page: () => , + } + } ]; statusBarItems = [ { item: (
Navigation.navigate(this.globalPages[0].path)} + className="flex align-center gaps" + onClick={() => this.navigate("help")} > - - My Status Bar Item + + My Status Bar Item
), }, diff --git a/docs/extensions/guides/stores.md b/docs/extensions/guides/stores.md index cc7b84e256..13982179e0 100644 --- a/docs/extensions/guides/stores.md +++ b/docs/extensions/guides/stores.md @@ -1,3 +1,11 @@ --- WIP --- + +# Stores + +## ClusterStore + +## WorkspaceStore + +## ExtensionStore \ No newline at end of file From 99c3a0072111926e82954e6d2ddfb48eb1306e27 Mon Sep 17 00:00:00 2001 From: Sebastian Malton Date: Tue, 1 Dec 2020 09:53:44 -0500 Subject: [PATCH 03/26] fix symlinking extensions into .k8slens/extensions folder (#1579) * fix symlinking extensions into .k8slens/extensions folder Signed-off-by: Sebastian Malton --- src/extensions/extension-discovery.ts | 8 ++++---- src/renderer/components/+extensions/extensions.tsx | 7 +++++-- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src/extensions/extension-discovery.ts b/src/extensions/extension-discovery.ts index 10597c3ceb..699e37042f 100644 --- a/src/extensions/extension-discovery.ts +++ b/src/extensions/extension-discovery.ts @@ -113,8 +113,8 @@ export class ExtensionDiscovery { // chokidar works better than fs.watch chokidar.watch(this.localFolderPath, { - // Dont watch recursively into subdirectories - depth: 0, + // For adding and removing symlinks to work, the depth has to be 1. + depth: 1, // Try to wait until the file has been completely copied. // The OS might emit an event for added file even it's not completely written to the filesysten. awaitWriteFinish: { @@ -123,7 +123,7 @@ export class ExtensionDiscovery { stabilityThreshold: 300 } }) - // Extension add is detected by watching "package.json" add + // Extension add is detected by watching "/package.json" add .on("add", this.handleWatchFileAdd) // Extension remove is detected by watching " unlink .on("unlinkDir", this.handleWatchUnlinkDir); @@ -189,7 +189,7 @@ export class ExtensionDiscovery { */ async uninstallExtension(absolutePath: string) { logger.info(`${logModule} Uninstalling ${absolutePath}`); - + const exists = await fs.pathExists(absolutePath); if (!exists) { diff --git a/src/renderer/components/+extensions/extensions.tsx b/src/renderer/components/+extensions/extensions.tsx index 920febe2e9..c84c04374c 100644 --- a/src/renderer/components/+extensions/extensions.tsx +++ b/src/renderer/components/+extensions/extensions.tsx @@ -386,8 +386,11 @@ export class Extensions extends React.Component {
- {search &&

No search results found

} - {!search &&

There are no installed extensions. See list of available extensions.

} + { + search + ?

No search results found

+ :

There are no installed extensions. See list of available extensions.

+ }
); From 111c518bf5dba93df2f9807ebceb08a0dab2380f Mon Sep 17 00:00:00 2001 From: Sebastian Malton Date: Tue, 1 Dec 2020 11:30:01 -0500 Subject: [PATCH 04/26] add basic usage docs for extensions (#1583) Signed-off-by: Sebastian Malton --- docs/extensions/usage/README.md | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/docs/extensions/usage/README.md b/docs/extensions/usage/README.md index 4020e64d65..02b2db223a 100644 --- a/docs/extensions/usage/README.md +++ b/docs/extensions/usage/README.md @@ -3,21 +3,24 @@ The features that Lens includes out-of-the-box are just the start. Lens extensions let you add new features to your installation to support your workflow. Rich extensibility model lets extension authors plug directly into the Lens UI and contribute functionality through the same APIs used by Lens itself. +The start using Lens Extensions go to **File** (or **Lens** on macOS) > **Extensions** in the application menu. +This is the `Extensions` management page where all the management of the extensions you want to use is done. ![Extensions](images/extensions.png) ## Installing an Extension -You can install a dowloaded extension .tgz package by going to **File** > **Extensions** (**Lens** > **Extensions** on Mac). Alternatively you can point an URL to .tgz file. An installed extension is enabled automatically. +There are three ways to install extensions. +If you have the extension as a `.tgz` file then dragging and dropping it in the extension management page will install it for you. +If it is hosted on the web, you can paste the URL and click `Install` and Lens will download and install it. +The third way is to move the extension into your `~/.k8slens/extensions` (or `C:\Users\\.k8slens\extensions`) folder and Lens will automatically detect it and install the extension. -## Enabling an Extension +## Enabling or Disabling an Extension -Go to **File** > **Extensions** (**Lens** > **Extensions** on Mac) and click "Enable" button. - -## Disabling an Extension - -Go to **File** > **Extensions** (**Lens** > **Extensions** on Mac) and click "Disable" button. +Go to the extension management page and click either the `Enable` or `Disable` buttons. +Extensions will be enabled by default when you first install them. +A disabled extension is not loaded by Lens and is not run. ## Uninstalling an Extension -Go to **File** > **Extensions** (**Lens** > **Extensions** on Mac) and click "Uninstall" button. +If, for whatever reason, you wish to remove the installation of an extension simple click the `Uninstall` button. This will remove all the files that Lens would need to run the extension. From 06568fa232a2f5025901929d5ddc39f970860d86 Mon Sep 17 00:00:00 2001 From: Alex Andreev Date: Wed, 2 Dec 2020 09:53:50 +0300 Subject: [PATCH 05/26] Fixing tray icon color on macOS Big Sur (#1595) * Using trayTemplate icon Signed-off-by: Alex Andreev * Clean up nativeTheme import Signed-off-by: Alex Andreev * Using light icon for tray Signed-off-by: Alex Andreev * Removing unused tray icons Signed-off-by: Alex Andreev --- .../{tray_icon_dark.png => trayIconTemplate.png} | Bin ..._icon_dark@2x.png => trayIconTemplate@2x.png} | Bin ..._icon_dark@3x.png => trayIconTemplate@3x.png} | Bin build/tray/tray_icon.png | Bin 448 -> 0 bytes build/tray/tray_icon@2x.png | Bin 973 -> 0 bytes build/tray/tray_icon@3x.png | Bin 1479 -> 0 bytes src/main/tray.ts | 9 +++------ 7 files changed, 3 insertions(+), 6 deletions(-) rename build/tray/{tray_icon_dark.png => trayIconTemplate.png} (100%) rename build/tray/{tray_icon_dark@2x.png => trayIconTemplate@2x.png} (100%) rename build/tray/{tray_icon_dark@3x.png => trayIconTemplate@3x.png} (100%) delete mode 100644 build/tray/tray_icon.png delete mode 100644 build/tray/tray_icon@2x.png delete mode 100644 build/tray/tray_icon@3x.png diff --git a/build/tray/tray_icon_dark.png b/build/tray/trayIconTemplate.png similarity index 100% rename from build/tray/tray_icon_dark.png rename to build/tray/trayIconTemplate.png diff --git a/build/tray/tray_icon_dark@2x.png b/build/tray/trayIconTemplate@2x.png similarity index 100% rename from build/tray/tray_icon_dark@2x.png rename to build/tray/trayIconTemplate@2x.png diff --git a/build/tray/tray_icon_dark@3x.png b/build/tray/trayIconTemplate@3x.png similarity index 100% rename from build/tray/tray_icon_dark@3x.png rename to build/tray/trayIconTemplate@3x.png diff --git a/build/tray/tray_icon.png b/build/tray/tray_icon.png deleted file mode 100644 index 73c7346d33abfc2e53b2f9a1e3043ba09ba3a5f0..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 448 zcmV;x0YCnUP)@aCkc-s@pssQA;?2Y_@g&J;0K0VngPfZs zj0q2k3tX6lGB4TvUo+2Z^|I_8mZOka-L=SB-5b-%r2}?h12$nB8cdSgz)2?cn^RgG zpbJjn01inv^B6Aqs@hD2DIThT-%hct;2)pR4RLys|}_Dw4Q0000p2Uj) diff --git a/build/tray/tray_icon@2x.png b/build/tray/tray_icon@2x.png deleted file mode 100644 index 71206802ac07cfb5dac96338fc0b29dc5a78cb38..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 973 zcmV;;12X)HP)`iRvwty-*#-PA`bWTAp!r67pVg#_JL z!JRwNO=|^T2x1j1E`+*Kx)JJ=ZUhk#5kW*j(M?fTA`}F5kv6T3+B|MN6aNXnC-){d z$-vj<&YhX_pEGA3*Kz)p79moGR-sO45ZVH?XPSj&!fIiiQ14JN#=ixJD%nGhz z5$~=d0>6CPn&*^|A;YhPe)9M>+M0JiVLSh44wbG_?$Ut9gFa>4wn?kL&xKtuyhV7R z#>~O#k^)@y+$o&I?-Utk!H6J3b3C1QHCdl%`m)M zc;R8#RZbQJv?0rLYEEsNvfNI`YvnN;(nUvPm2gRzCm|^gDU+{L^7 zH3+Z~yGO~#Temq4mudl7YIGU{G*l_T_8X>U$cNhk=fIrLb{VTdgiXXgp98MsX_%qJ zSzpB}u;Di$ZWq3J9OIs+Vbd;f!qoNPfC&=%0kx#ZkP8dy zv@HlQ9vu^o3xn7`HMah&!bIg9MW=@haSoaCQcY_?Kx-A(Y|TX&`QVXki?A`OBJ*Dk z#OVs23Wz-|Ct&=J@|@>;kFZxQao<%?4i4id7#~!g>!o}_SgF1l%I*VxWaw^a?t(ki zHw3q}LZ3nC6bVRFx(qcj%xxk-+a#`jH&k6D$j2!&3ol3D(c>~7stI7eBen_$(S9B_ vTbv3$&-pXmZaVF_U`A3jP)UIq2$??$ zrf_C3doY0&JrMj+f)P4n1zJXpM5IHcg3}Kp3yrAgfmx9~h%CdPq?+;GIcHj%zExjq z_IciO-0*SlIcM*+*V=2Xz1Lbh2>xj`LA#({0PVqltzH3Ypna_1c$(+m<~mFh+#~1` zbPIYi!1Y@!xCL}h5-h^|tTfW-F9do8O9WR6+5*$s!TeZ341&%bEzn5A*MfUV1Matj z)!v1UBZ6i|j9|=S(0Qf+4Ij-0F}__ej~4p~XlB1)4GYkX0ggzN18&S^p!04)8WD|A z#CdN;I34KR>`dEM-GkKCa-S-gxe-a!im?Zd(zDEgTBl_Po-?s>^C&niWmSP_5 zGmPKWMFkk6iMN8rwT8hcC*#hKW)b733zoxFc+QZd)B7*~3YG6Yb9b3tRH(M%HK&IFzpTtJL>z$yPqU|i|7Bmfg~jGPJ@dx+UM z#t#VkvC7&m5e#@V6DIhRSAgyu8W_dzM;L#unb0!6ANG72F@BX`gPE&o{3MQ#aRRh^ zG!SL5|CcbnlL#~8dj*eUCkb$Z&Jk?#YzSExj}@Se4zflHp@=lF2;*0pKHp={o@h?| z^98%9-$=}vF_u>VJ(@pDa75o0IRj%q!SimM%Ey@Cu13#pv2GK5XiRc6hZe>Opc70L z{9)|e5(;URj}n|L*a06pJc04R^L(q|y#j*73J@Ufu)B%x0NR576EZMvTirant@s_Y zNJ)M%P5>8Y-yk>}&&y&=i3M0{gs%trn;mnL;H?aTcz2-(1@9BF*JGSoO>1@wjHe0W z7IVn6^F>J}bQfN(z`NsU$7mY+cR|~kF?QWba|?MQJzo>E<$5*HO`SUN{z5w1Lt6b0 zg7TX##0!FTf-}saH|Pl@`+cE=+L*@p3p`=Lni9>&no%$2aKYSjf^(>k3;JH6MCw_e z0Qzwca@+tBC&%HP6$qet?iI#lH1O*%f!}5__x}knN;bBY25rW>gMy39q-C0rJH3CP zx!7z5Xm0h>*l1LRSWYROBm4_8uqv7^$Pg{2O@5pyzlta}ND_c!{@W?*T3^BLWBuSU zZ0tA13%1w5QRi z;l^tvI_=BE-~tNle171KM(-|I_I|PjEvOIOZ{jcw(&DVbs{xm2|0wue@MQ*kf!}X% z>7Wd?rNAF~ zZJxAMSNz79nro~HEu<}SqyUB!prx8mDaUv^Cg-+Q?}t5pkhqP872R<;6S>^nrTI!X zD!>r>&hk`761UL?5pETnhTe&}X3%aECY<*BP;;7q|15CJ_XBBn2UgYn36+kd(&bEy zd((`mOaLGA+sSDMJ$9c|{eH;gnAtXgvU0YdzoAaYQ&11*ATxCNKICgsq_5`8r%ftt>##t?rAsL(uZ#TJX0D h%+Eg*xozdN{s!je5-4cpd;S0b002ovPDHLkV1f{0ueSgI diff --git a/src/main/tray.ts b/src/main/tray.ts index 2c5ba736f2..d006ae6b43 100644 --- a/src/main/tray.ts +++ b/src/main/tray.ts @@ -1,6 +1,6 @@ import path from "path"; import packageInfo from "../../package.json"; -import { dialog, Menu, NativeImage, nativeTheme, Tray } from "electron"; +import { dialog, Menu, NativeImage, Tray } from "electron"; import { autorun } from "mobx"; import { showAbout } from "./menu"; import { AppUpdater } from "./app-updater"; @@ -16,14 +16,11 @@ import { exitApp } from "./exit-app"; // note: instance of Tray should be saved somewhere, otherwise it disappears export let tray: Tray; -// refresh icon when MacOS dark/light theme has changed -nativeTheme?.on("updated", () => tray?.setImage(getTrayIcon())); - -export function getTrayIcon(isDark = nativeTheme.shouldUseDarkColors): string { +export function getTrayIcon(): string { return path.resolve( __static, isDevelopment ? "../build/tray" : "icons", // copied within electron-builder extras - `tray_icon${isDark ? "_dark" : ""}.png` + "trayIconTemplate.png" ); } From 7b77f183769b17ebba93ab6b53b78c79ed3fc402 Mon Sep 17 00:00:00 2001 From: Lauri Nevala Date: Wed, 2 Dec 2020 09:08:44 +0200 Subject: [PATCH 06/26] Fix extensions installation on Windows (#1596) * Fix extensions installation on Windows Signed-off-by: Lauri Nevala * Get rid of readFileSync Signed-off-by: Lauri Nevala * Add missing semicolon Signed-off-by: Lauri Nevala --- src/common/utils/tar.ts | 6 ++++-- .../components/+extensions/extensions.tsx | 19 +++++++++++++------ 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/src/common/utils/tar.ts b/src/common/utils/tar.ts index 004fa354dc..bec7b5b3f2 100644 --- a/src/common/utils/tar.ts +++ b/src/common/utils/tar.ts @@ -15,7 +15,7 @@ export function readFileFromTar({ tarPath, filePath, parseJson }: Re await tar.list({ file: tarPath, - filter: path => path === filePath, + filter: entryPath => path.normalize(entryPath) === filePath, onentry(entry: FileStat) { entry.on("data", chunk => { fileChunks.push(chunk); @@ -41,7 +41,9 @@ export async function listTarEntries(filePath: string): Promise { const entries: string[] = []; await tar.list({ file: filePath, - onentry: (entry: FileStat) => entries.push(entry.path as any as string), + onentry: (entry: FileStat) => { + entries.push(path.normalize(entry.path as any as string)); + }, }); return entries; } diff --git a/src/renderer/components/+extensions/extensions.tsx b/src/renderer/components/+extensions/extensions.tsx index c84c04374c..6ff32943a5 100644 --- a/src/renderer/components/+extensions/extensions.tsx +++ b/src/renderer/components/+extensions/extensions.tsx @@ -47,7 +47,7 @@ interface ExtensionState { @observer export class Extensions extends React.Component { - private supportedFormats = [".tar", ".tgz"]; + private supportedFormats = ["tar", "tgz"]; private installPathValidator: InputValidator = { message: Invalid URL or absolute path, @@ -187,15 +187,17 @@ export class Extensions extends React.Component { await Promise.all( requests .filter(req => !req.data && req.filePath) - .map(request => { - return fse.readFile(request.filePath).then(data => { + .map(async request => { + try { + const data = await fse.readFile(request.filePath); request.data = data; preloadedRequests.push(request); - }).catch(error => { + return request; + } catch(error) { if (showError) { Notifications.error(`Error while reading "${request.filePath}": ${String(error)}`); } - }); + } }) ); @@ -206,7 +208,12 @@ export class Extensions extends React.Component { const tarFiles = await listTarEntries(filePath); // tarball from npm contains single root folder "package/*" - const rootFolder = tarFiles[0].split("/")[0]; + const firstFile = tarFiles[0]; + if (!firstFile) { + throw new Error(`invalid extension bundle, ${manifestFilename} not found`); + } + + const rootFolder = path.normalize(firstFile).split(path.sep)[0]; const packedInRootFolder = tarFiles.every(entry => entry.startsWith(rootFolder)); const manifestLocation = packedInRootFolder ? path.join(rootFolder, manifestFilename) : manifestFilename; From dcf253e7d5fb1eba2ddaaffcd62e4f93a4d40f33 Mon Sep 17 00:00:00 2001 From: Panu Horsmalahti Date: Wed, 2 Dec 2020 09:55:52 +0200 Subject: [PATCH 07/26] Add eslint rule padding-line-between-statements (#1593) Signed-off-by: Panu Horsmalahti --- .eslintrc.js | 27 +++++++++++++ build/build_tray_icon.ts | 4 +- build/download_kubectl.ts | 10 +++++ build/notarize.js | 2 + extensions/example-extension/page.tsx | 2 + .../kube-object-event-status/src/resolver.tsx | 7 ++++ .../src/metrics-feature.ts | 2 + extensions/node-menu/src/node-menu.tsx | 2 + extensions/pod-menu/src/logs-menu.tsx | 4 ++ extensions/pod-menu/src/shell-menu.tsx | 5 +++ .../telemetry/src/telemetry-preference.tsx | 1 + extensions/telemetry/src/tracker.ts | 10 +++++ integration/__tests__/app.tests.ts | 15 ++++--- integration/helpers/utils.ts | 2 + src/common/__tests__/cluster-store.test.ts | 31 +++++++++++++++ src/common/__tests__/event-bus.test.ts | 1 + src/common/__tests__/search-store.test.ts | 2 +- src/common/__tests__/user-store.test.ts | 1 + src/common/__tests__/workspace-store.test.ts | 2 +- src/common/base-store.ts | 7 ++++ src/common/cluster-ipc.ts | 7 ++++ src/common/cluster-store.ts | 21 ++++++++++ src/common/custom-errors.ts | 1 + src/common/event-emitter.ts | 3 ++ src/common/ipc.ts | 5 +++ src/common/kube-helpers.ts | 11 +++++- src/common/prometheus-providers.ts | 1 + src/common/rbac.ts | 2 + src/common/register-protocol.ts | 1 + src/common/request.ts | 1 + src/common/search-store.ts | 10 +++++ src/common/system-ca.ts | 2 + src/common/user-store.ts | 5 +++ src/common/utils/autobind.ts | 3 +- src/common/utils/buildUrl.ts | 2 + src/common/utils/camelCase.ts | 2 + src/common/utils/debouncePromise.ts | 1 + src/common/utils/downloadFile.ts | 1 + src/common/utils/getRandId.ts | 1 + src/common/utils/saveToAppFiles.ts | 2 + src/common/utils/singleton.ts | 1 + src/common/utils/splitArray.ts | 2 + src/common/utils/tar.ts | 3 ++ src/common/vars.ts | 1 + src/common/workspace-store.ts | 14 +++++++ src/extensions/cluster-feature.ts | 3 ++ src/extensions/core-api/app.ts | 1 + src/extensions/extension-discovery.ts | 6 ++- src/extensions/extension-installer.ts | 1 + src/extensions/extension-loader.ts | 5 +++ src/extensions/extension-store.ts | 2 + src/extensions/extensions-store.ts | 4 ++ src/extensions/lens-extension.ts | 2 + src/extensions/lens-main-extension.ts | 1 + src/extensions/lens-renderer-extension.ts | 1 + .../__tests__/page-registry.test.ts | 3 ++ src/extensions/registries/base-registry.ts | 2 + .../registries/kube-object-detail-registry.ts | 2 + .../registries/page-menu-registry.ts | 4 ++ src/extensions/registries/page-registry.ts | 6 +++ src/extensions/stores/cluster-store.ts | 1 + src/main/__test__/cluster.test.ts | 7 ++++ src/main/__test__/kube-auth-proxy.test.ts | 6 +++ src/main/__test__/kubeconfig-manager.test.ts | 4 +- src/main/app-updater.ts | 1 + .../base-cluster-detector.ts | 1 + .../cluster-detectors/cluster-id-detector.ts | 3 ++ .../cluster-detectors/detector-registry.ts | 6 +++ .../distribution-detector.ts | 11 ++++++ .../cluster-detectors/last-seen-detector.ts | 1 + .../cluster-detectors/nodes-count-detector.ts | 2 + .../cluster-detectors/version-detector.ts | 2 + src/main/cluster-manager.ts | 5 +++ src/main/cluster.ts | 30 ++++++++++++++ src/main/context-handler.ts | 15 +++++++ src/main/exit-app.ts | 1 + src/main/extension-filesystem.ts | 3 ++ src/main/helm/helm-chart-manager.ts | 9 +++++ src/main/helm/helm-cli.ts | 1 + src/main/helm/helm-release-manager.ts | 15 ++++++- src/main/helm/helm-repo-manager.ts | 14 +++++++ src/main/helm/helm-service.ts | 17 ++++++++ src/main/index.ts | 5 +++ src/main/kube-auth-proxy.ts | 6 +++ src/main/kubeconfig-manager.ts | 5 ++- src/main/kubectl.ts | 36 +++++++++++++++-- src/main/kubectl_spec.ts | 6 +++ src/main/lens-binary.ts | 11 +++++- src/main/lens-proxy.ts | 19 +++++++++ src/main/logger.ts | 3 -- src/main/menu.ts | 15 ++++--- src/main/node-shell-session.ts | 11 +++++- src/main/port.ts | 3 ++ src/main/port_spec.ts | 2 + src/main/prometheus/helm.ts | 3 ++ src/main/prometheus/lens.ts | 2 + src/main/prometheus/operator.ts | 4 ++ src/main/prometheus/provider-registry.ts | 1 + src/main/prometheus/stacklight.ts | 2 + src/main/resource-applier.ts | 13 +++++++ src/main/router.ts | 12 ++++++ src/main/routes/helm-route.ts | 21 ++++++++++ src/main/routes/kubeconfig-route.ts | 4 +- src/main/routes/metrics-route.ts | 8 ++++ src/main/routes/port-forward-route.ts | 8 +++- src/main/routes/resource-applier-route.ts | 2 + src/main/routes/watch-route.ts | 4 ++ src/main/shell-session.ts | 10 +++++ src/main/shell-sync.ts | 3 +- src/main/tray.ts | 7 ++++ src/main/window-manager.ts | 5 +++ src/migrations/cluster-store/2.0.0-beta.2.ts | 1 + src/migrations/cluster-store/2.4.1.ts | 2 + src/migrations/cluster-store/2.6.0-beta.2.ts | 3 ++ src/migrations/cluster-store/2.6.0-beta.3.ts | 7 ++++ src/migrations/cluster-store/2.7.0-beta.0.ts | 2 + src/migrations/cluster-store/2.7.0-beta.1.ts | 5 +++ src/migrations/cluster-store/3.6.0-beta.1.ts | 1 + src/migrations/cluster-store/snap.ts | 3 ++ src/renderer/api/api-manager.ts | 2 + src/renderer/api/endpoints/cluster.api.ts | 1 + src/renderer/api/endpoints/crd.api.ts | 5 +++ src/renderer/api/endpoints/cron-job.api.ts | 3 ++ src/renderer/api/endpoints/daemon-set.api.ts | 1 + src/renderer/api/endpoints/deployment.api.ts | 3 ++ src/renderer/api/endpoints/endpoint.api.ts | 5 +++ src/renderer/api/endpoints/events.api.ts | 3 ++ src/renderer/api/endpoints/helm-charts.api.ts | 2 + .../api/endpoints/helm-releases.api.ts | 17 ++++++++ src/renderer/api/endpoints/hpa.api.ts | 6 +++ src/renderer/api/endpoints/ingress.api.ts | 8 +++- src/renderer/api/endpoints/job.api.ts | 4 ++ src/renderer/api/endpoints/metrics.api.ts | 8 ++++ .../api/endpoints/network-policy.api.ts | 2 + src/renderer/api/endpoints/nodes.api.ts | 9 +++++ .../endpoints/persistent-volume-claims.api.ts | 5 +++ .../api/endpoints/persistent-volume.api.ts | 5 +++ .../api/endpoints/poddisruptionbudget.api.ts | 1 + src/renderer/api/endpoints/pods.api.ts | 27 +++++++++++++ .../api/endpoints/podsecuritypolicy.api.ts | 1 + src/renderer/api/endpoints/replica-set.api.ts | 1 + .../api/endpoints/resource-applier.api.ts | 3 ++ .../api/endpoints/resource-quota.api.ts | 1 + .../endpoints/selfsubjectrulesreviews.api.ts | 4 ++ src/renderer/api/endpoints/service.api.ts | 4 ++ .../api/endpoints/stateful-set.api.ts | 1 + .../api/endpoints/storage-class.api.ts | 1 + src/renderer/api/json-api.ts | 11 ++++++ src/renderer/api/kube-api-parse.ts | 7 +++- src/renderer/api/kube-api.ts | 14 +++++++ src/renderer/api/kube-json-api.ts | 2 + src/renderer/api/kube-object.ts | 7 ++++ src/renderer/api/kube-watch-api.ts | 14 +++++++ src/renderer/api/terminal-api.ts | 11 ++++++ src/renderer/api/websocket-api.ts | 7 ++++ src/renderer/api/workload-kube-object.ts | 5 +++ src/renderer/bootstrap.tsx | 1 + .../components/+add-cluster/add-cluster.tsx | 20 ++++++++++ .../+apps-helm-charts/helm-chart-details.tsx | 4 ++ .../+apps-helm-charts/helm-chart.store.ts | 5 +++ .../+apps-helm-charts/helm-charts.tsx | 1 + .../+apps-releases/release-details.tsx | 15 +++++++ .../+apps-releases/release-menu.tsx | 4 ++ .../release-rollback-dialog.tsx | 5 +++ .../+apps-releases/release.store.ts | 13 +++++++ .../components/+apps-releases/releases.tsx | 3 ++ src/renderer/components/+apps/apps.tsx | 1 + .../+cluster-settings/cluster-settings.tsx | 2 + .../components/cluster-icon-setting.tsx | 3 ++ .../components/cluster-prometheus-setting.tsx | 6 +++ .../components/install-feature.tsx | 2 + .../components/remove-cluster-button.tsx | 2 + .../components/+cluster-settings/features.tsx | 1 + .../components/+cluster-settings/status.tsx | 2 + .../components/+cluster/cluster-issues.tsx | 7 ++++ .../+cluster/cluster-metric-switchers.tsx | 1 + .../components/+cluster/cluster-metrics.tsx | 4 ++ .../+cluster/cluster-pie-charts.tsx | 5 +++ .../components/+cluster/cluster.store.ts | 5 +++ src/renderer/components/+cluster/cluster.tsx | 2 + .../+config-autoscalers/hpa-details.tsx | 6 +++ .../components/+config-autoscalers/hpa.tsx | 2 + .../+config-maps/config-map-details.tsx | 4 ++ .../pod-disruption-budgets-details.tsx | 2 + .../add-quota-dialog.tsx | 4 ++ .../resource-quota-details.tsx | 3 ++ .../+config-secrets/add-secret-dialog.tsx | 9 +++++ .../+config-secrets/secret-details.tsx | 7 ++++ src/renderer/components/+config/config.tsx | 6 +++ .../+custom-resources/crd-details.tsx | 4 ++ .../components/+custom-resources/crd-list.tsx | 5 +++ .../crd-resource-details.tsx | 2 + .../+custom-resources/crd-resources.tsx | 6 +++ .../components/+custom-resources/crd.store.ts | 5 +++ .../components/+events/event-details.tsx | 2 + .../components/+events/event.store.ts | 5 +++ src/renderer/components/+events/events.tsx | 3 ++ .../components/+events/kube-event-details.tsx | 3 ++ .../components/+events/kube-event-icon.tsx | 3 ++ .../components/+extensions/extensions.tsx | 13 +++++++ .../components/+landing-page/landing-page.tsx | 1 + .../+namespaces/add-namespace-dialog.tsx | 2 + .../+namespaces/namespace-details.tsx | 3 ++ .../+namespaces/namespace-select.tsx | 8 ++++ .../components/+namespaces/namespace.store.ts | 5 +++ .../+network-endpoints/endpoint-details.tsx | 2 + .../endpoint-subset-list.tsx | 3 ++ .../+network-endpoints/endpoints.tsx | 1 + .../+network-ingresses/ingress-charts.tsx | 1 + .../+network-ingresses/ingress-details.tsx | 4 ++ .../+network-ingresses/ingresses.tsx | 1 + .../network-policy-details.tsx | 15 +++++++ .../service-details-endpoint.tsx | 3 ++ .../+network-services/service-details.tsx | 2 + .../service-port-component.tsx | 3 ++ src/renderer/components/+network/network.tsx | 5 +++ .../components/+nodes/node-charts.tsx | 1 + .../components/+nodes/node-details.tsx | 3 ++ src/renderer/components/+nodes/nodes.store.ts | 3 ++ src/renderer/components/+nodes/nodes.tsx | 9 +++++ .../pod-security-policy-details.tsx | 3 ++ .../components/+preferences/preferences.tsx | 7 ++++ .../storage-class-details.tsx | 2 + .../volume-claim-details.tsx | 2 + .../+storage-volume-claims/volume-claims.tsx | 1 + .../+storage-volumes/volume-details.tsx | 2 + .../components/+storage-volumes/volumes.tsx | 1 + src/renderer/components/+storage/storage.tsx | 1 + .../add-role-binding-dialog.tsx | 12 ++++++ .../role-binding-details.tsx | 5 +++ .../role-bindings.store.ts | 3 ++ .../add-role-dialog.tsx | 2 + .../+user-management-roles/role-details.tsx | 2 + .../+user-management-roles/roles.store.ts | 1 + .../create-service-account-dialog.tsx | 3 ++ .../service-accounts-details.tsx | 10 +++++ .../service-accounts-secret.tsx | 2 + .../service-accounts.store.ts | 1 + .../service-accounts.tsx | 1 + .../+user-management/user-management.tsx | 3 ++ .../components/+whats-new/whats-new.tsx | 1 + .../+workloads-cronjobs/cronjob-details.tsx | 3 ++ .../cronjob-trigger-dialog.tsx | 3 ++ .../+workloads-cronjobs/cronjob.store.ts | 4 ++ .../+workloads-cronjobs/cronjobs.tsx | 1 + .../daemonset-details.tsx | 2 + .../+workloads-daemonsets/daemonsets.store.ts | 4 ++ .../deployment-details.tsx | 4 ++ .../deployment-scale-dialog.test.tsx | 9 +++++ .../deployment-scale-dialog.tsx | 5 +++ .../deployments.store.ts | 4 ++ .../+workloads-deployments/deployments.tsx | 3 ++ .../+workloads-jobs/job-details.tsx | 3 ++ .../components/+workloads-jobs/job.store.ts | 3 ++ .../components/+workloads-jobs/jobs.tsx | 1 + .../+workloads-overview/overview-statuses.tsx | 1 + .../overview-workload-status.tsx | 3 ++ .../+workloads-overview/overview.tsx | 10 +++++ .../+workloads-pods/pod-container-env.tsx | 12 ++++++ .../+workloads-pods/pod-container-port.tsx | 3 ++ .../pod-details-affinities.tsx | 2 + .../+workloads-pods/pod-details-container.tsx | 5 +++ .../+workloads-pods/pod-details-list.tsx | 8 ++++ .../+workloads-pods/pod-details-secrets.tsx | 1 + .../+workloads-pods/pod-details-statuses.tsx | 2 + .../pod-details-tolerations.tsx | 3 ++ .../+workloads-pods/pod-details.tsx | 5 +++ .../components/+workloads-pods/pods.store.ts | 9 +++++ .../components/+workloads-pods/pods.tsx | 2 + .../replicaset-details.tsx | 2 + .../replicasets.store.ts | 1 + .../+workloads-replicasets/replicasets.tsx | 2 + .../statefulset-details.tsx | 2 + .../statefulset-scale-dialog.test.tsx | 9 +++++ .../statefulset-scale-dialog.tsx | 5 +++ .../statefulset.store.ts | 4 ++ .../+workloads-statefulsets/statefulsets.tsx | 2 + .../components/+workloads/workloads.tsx | 7 ++++ .../components/+workspaces/workspace-menu.tsx | 3 ++ .../components/+workspaces/workspaces.tsx | 10 +++++ .../components/ace-editor/ace-editor.tsx | 5 +++ .../add-remove-buttons/add-remove-buttons.tsx | 2 + src/renderer/components/animate/animate.tsx | 3 ++ src/renderer/components/app-init/app-init.tsx | 3 ++ src/renderer/components/app.tsx | 15 +++++++ src/renderer/components/badge/badge.tsx | 1 + src/renderer/components/button/button.tsx | 1 + src/renderer/components/chart/bar-chart.tsx | 14 +++++++ src/renderer/components/chart/chart.tsx | 12 ++++++ src/renderer/components/chart/pie-chart.tsx | 5 +++ .../components/chart/useRealTimeMetrics.ts | 2 + .../components/chart/zebra-stripes.plugin.ts | 3 ++ src/renderer/components/checkbox/checkbox.tsx | 2 + .../components/clipboard/clipboard.tsx | 4 ++ .../components/cluster-icon/cluster-icon.tsx | 1 + .../components/cluster-manager/bottom-bar.tsx | 2 + .../cluster-manager/cluster-manager.tsx | 2 + .../cluster-manager/cluster-status.tsx | 3 ++ .../cluster-manager/cluster-view.tsx | 1 + .../cluster-manager/clusters-menu.tsx | 7 ++++ .../components/cluster-manager/lens-views.ts | 4 ++ .../confirm-dialog/confirm-dialog.tsx | 1 + src/renderer/components/dialog/dialog.tsx | 8 ++++ .../components/dialog/logs-dialog.tsx | 1 + .../components/dock/create-resource.tsx | 5 +++ .../components/dock/dock-tab.store.ts | 4 ++ src/renderer/components/dock/dock-tab.tsx | 1 + src/renderer/components/dock/dock.store.ts | 12 ++++++ src/renderer/components/dock/dock.tsx | 10 +++++ .../components/dock/edit-resource.store.ts | 6 +++ .../components/dock/edit-resource.tsx | 6 +++ src/renderer/components/dock/editor-panel.tsx | 3 ++ src/renderer/components/dock/info-panel.tsx | 5 +++ .../components/dock/install-chart.store.ts | 6 ++- .../components/dock/install-chart.tsx | 6 +++ .../components/dock/pod-log-controls.tsx | 4 ++ src/renderer/components/dock/pod-log-list.tsx | 17 ++++++++ .../components/dock/pod-logs.store.ts | 15 +++++++ src/renderer/components/dock/pod-logs.tsx | 5 +++ .../components/dock/terminal-window.tsx | 1 + .../components/dock/terminal.store.ts | 10 ++++- src/renderer/components/dock/terminal.ts | 8 ++++ .../components/dock/upgrade-chart.store.ts | 15 +++++++ .../components/dock/upgrade-chart.tsx | 8 ++++ .../components/drawer/drawer-item-labels.tsx | 2 + .../components/drawer/drawer-item.tsx | 1 + .../drawer/drawer-param-toggler.tsx | 1 + .../components/drawer/drawer-title.tsx | 1 + src/renderer/components/drawer/drawer.tsx | 8 ++++ .../error-boundary/error-boundary.tsx | 3 ++ .../components/file-picker/file-picker.tsx | 9 +++++ src/renderer/components/icon/icon.tsx | 8 ++++ .../components/input/drop-file-input.tsx | 6 +++ src/renderer/components/input/file-input.tsx | 5 +++ src/renderer/components/input/input.tsx | 20 ++++++++++ .../components/input/input_validators.ts | 2 + .../components/input/search-input-url.tsx | 2 + .../components/input/search-input.tsx | 4 ++ .../item-object-list/filter-icon.tsx | 1 + .../item-object-list/item-list-layout.tsx | 39 +++++++++++++++++++ .../item-object-list/page-filters-list.tsx | 3 ++ .../item-object-list/page-filters-select.tsx | 9 +++++ .../item-object-list/page-filters.store.ts | 8 ++++ .../kube-object-status-icon.tsx | 6 +++ .../kube-object/kube-object-details.tsx | 8 ++++ .../kube-object/kube-object-list-layout.tsx | 1 + .../kube-object/kube-object-menu.tsx | 8 ++++ .../kube-object/kube-object-meta.tsx | 3 ++ .../kubeconfig-dialog/kubeconfig-dialog.tsx | 3 ++ .../components/layout/login-layout.tsx | 1 + .../components/layout/main-layout.tsx | 2 + .../components/layout/page-layout.tsx | 2 + src/renderer/components/layout/sidebar.tsx | 12 ++++++ src/renderer/components/layout/sub-header.tsx | 2 + src/renderer/components/layout/sub-title.tsx | 2 + src/renderer/components/layout/tab-layout.tsx | 2 + .../components/layout/wizard-layout.tsx | 1 + .../line-progress/line-progress.tsx | 2 + .../markdown-viewer/markdown-viewer.tsx | 1 + src/renderer/components/menu/menu-actions.tsx | 6 +++ src/renderer/components/menu/menu.tsx | 24 ++++++++++++ src/renderer/components/no-items/no-items.tsx | 1 + .../notifications/notifications.store.ts | 5 +++ .../notifications/notifications.tsx | 4 ++ src/renderer/components/radio/radio.tsx | 5 +++ .../resizing-anchor/resizing-anchor.tsx | 8 ++++ .../resource-metrics-text.tsx | 1 + src/renderer/components/select/select.tsx | 7 ++++ src/renderer/components/slider/slider.tsx | 1 + .../components/spinner/cube-spinner.tsx | 1 + .../components/status-brick/status-brick.tsx | 1 + src/renderer/components/stepper/stepper.tsx | 3 ++ src/renderer/components/table/table-cell.tsx | 6 +++ src/renderer/components/table/table-head.tsx | 1 + src/renderer/components/table/table-row.tsx | 1 + src/renderer/components/table/table.tsx | 19 +++++++++ src/renderer/components/tabs/tabs.tsx | 8 ++++ src/renderer/components/tooltip/tooltip.tsx | 9 +++++ .../components/tooltip/withTooltip.tsx | 3 ++ .../components/virtual-list/virtual-list.tsx | 7 ++++ src/renderer/components/wizard/wizard.tsx | 9 +++++ src/renderer/hooks/useInterval.ts | 1 + src/renderer/hooks/useStorage.ts | 1 + src/renderer/i18n.ts | 2 + src/renderer/item.store.ts | 15 +++++++ src/renderer/kube-object.store.ts | 15 +++++++ src/renderer/navigation.ts | 7 ++++ src/renderer/theme.store.ts | 5 +++ .../utils/__tests__/formatDuration.test.ts | 1 + src/renderer/utils/cancelableFetch.ts | 6 ++- src/renderer/utils/convertCpu.ts | 2 + src/renderer/utils/convertMemory.ts | 2 + src/renderer/utils/copyToClipboard.ts | 4 ++ src/renderer/utils/createStorage.ts | 6 +++ src/renderer/utils/cssNames.ts | 2 + src/renderer/utils/cssVar.ts | 1 + src/renderer/utils/formatDuration.ts | 1 - src/renderer/utils/interval.ts | 3 ++ src/renderer/utils/metricUnitsToNumber.ts | 1 + src/renderer/utils/prevDefault.ts | 1 + src/renderer/utils/saveFile.ts | 1 + 401 files changed, 2018 insertions(+), 40 deletions(-) diff --git a/.eslintrc.js b/.eslintrc.js index d59ea493e5..fad2b04a6a 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -49,6 +49,15 @@ module.exports = { "object-shorthand": "error", "prefer-template": "error", "template-curly-spacing": "error", + "padding-line-between-statements": [ + "error", + { "blankLine": "always", "prev": "*", "next": "return" }, + { "blankLine": "always", "prev": "*", "next": "block-like" }, + { "blankLine": "always", "prev": "*", "next": "function" }, + { "blankLine": "always", "prev": "*", "next": "class" }, + { "blankLine": "always", "prev": ["const", "let", "var"], "next": "*" }, + { "blankLine": "any", "prev": ["const", "let", "var"], "next": ["const", "let", "var"]}, + ] } }, { @@ -94,6 +103,15 @@ module.exports = { "object-shorthand": "error", "prefer-template": "error", "template-curly-spacing": "error", + "padding-line-between-statements": [ + "error", + { "blankLine": "always", "prev": "*", "next": "return" }, + { "blankLine": "always", "prev": "*", "next": "block-like" }, + { "blankLine": "always", "prev": "*", "next": "function" }, + { "blankLine": "always", "prev": "*", "next": "class" }, + { "blankLine": "always", "prev": ["const", "let", "var"], "next": "*" }, + { "blankLine": "any", "prev": ["const", "let", "var"], "next": ["const", "let", "var"]}, + ] }, }, { @@ -146,6 +164,15 @@ module.exports = { "object-shorthand": "error", "prefer-template": "error", "template-curly-spacing": "error", + "padding-line-between-statements": [ + "error", + { "blankLine": "always", "prev": "*", "next": "return" }, + { "blankLine": "always", "prev": "*", "next": "block-like" }, + { "blankLine": "always", "prev": "*", "next": "function" }, + { "blankLine": "always", "prev": "*", "next": "class" }, + { "blankLine": "always", "prev": ["const", "let", "var"], "next": "*" }, + { "blankLine": "any", "prev": ["const", "let", "var"], "next": ["const", "let", "var"]}, + ] }, } ] diff --git a/build/build_tray_icon.ts b/build/build_tray_icon.ts index e02d884412..3aded6fdf5 100644 --- a/build/build_tray_icon.ts +++ b/build/build_tray_icon.ts @@ -17,14 +17,15 @@ export async function generateTrayIcon( outputFilename += shouldUseDarkColors ? "_dark" : ""; dpiSuffix = dpiSuffix !== "1x" ? `@${dpiSuffix}` : ""; const pngIconDestPath = path.resolve(outputFolder, `${outputFilename}${dpiSuffix}.png`); + try { // Modify .SVG colors const trayIconColor = shouldUseDarkColors ? "white" : "black"; const svgDom = await jsdom.JSDOM.fromFile(svgIconPath); const svgRoot = svgDom.window.document.body.getElementsByTagName("svg")[0]; + svgRoot.innerHTML += ``; const svgIconBuffer = Buffer.from(svgRoot.outerHTML); - // Resize and convert to .PNG const pngIconBuffer: Buffer = await sharp(svgIconBuffer) .resize({ width: pixelSize, height: pixelSize }) @@ -45,6 +46,7 @@ const iconSizes: Record = { "2x": 32, "3x": 48, }; + Object.entries(iconSizes).forEach(([dpiSuffix, pixelSize]) => { generateTrayIcon({ dpiSuffix, pixelSize, shouldUseDarkColors: false }); generateTrayIcon({ dpiSuffix, pixelSize, shouldUseDarkColors: true }); diff --git a/build/download_kubectl.ts b/build/download_kubectl.ts index 8c405dee5a..f046b23e30 100644 --- a/build/download_kubectl.ts +++ b/build/download_kubectl.ts @@ -15,6 +15,7 @@ class KubectlDownloader { constructor(clusterVersion: string, platform: string, arch: string, target: string) { this.kubectlVersion = clusterVersion; const binaryName = platform === "windows" ? "kubectl.exe" : "kubectl"; + this.url = `https://storage.googleapis.com/kubernetes-release/release/v${this.kubectlVersion}/bin/${platform}/${arch}/${binaryName}`; this.dirname = path.dirname(target); this.path = target; @@ -30,16 +31,20 @@ class KubectlDownloader { if (response.headers["etag"]) { return response.headers["etag"].replace(/"/g, ""); } + return ""; } public async checkBinary() { const exists = await pathExists(this.path); + if (exists) { const hash = md5File.sync(this.path); const etag = await this.urlEtag(); + if(hash == etag) { console.log("Kubectl md5sum matches the remote etag"); + return true; } @@ -52,13 +57,16 @@ class KubectlDownloader { public async downloadKubectl() { const exists = await this.checkBinary(); + if(exists) { console.log("Already exists and is valid"); + return; } await ensureDir(path.dirname(this.path), 0o755); const file = fs.createWriteStream(this.path); + console.log(`Downloading kubectl ${this.kubectlVersion} from ${this.url} to ${this.path}`); const requestOpts: request.UriOptions & request.CoreOptions = { uri: this.url, @@ -78,6 +86,7 @@ class KubectlDownloader { fs.unlink(this.path, () => {}); throw(error); }); + return new Promise((resolve, reject) => { file.on("close", () => { console.log("kubectl binary download closed"); @@ -103,6 +112,7 @@ const downloads = [ downloads.forEach((dlOpts) => { console.log(dlOpts); const downloader = new KubectlDownloader(downloadVersion, dlOpts.platform, dlOpts.arch, dlOpts.target); + console.log(`Downloading: ${JSON.stringify(dlOpts)}`); downloader.downloadKubectl().then(() => downloader.checkBinary().then(() => console.log("Download complete"))); }); diff --git a/build/notarize.js b/build/notarize.js index 5b3bcba4b7..ef4144993c 100644 --- a/build/notarize.js +++ b/build/notarize.js @@ -2,9 +2,11 @@ const { notarize } = require("electron-notarize"); exports.default = async function notarizing(context) { const { electronPlatformName, appOutDir } = context; + if (electronPlatformName !== "darwin") { return; } + if (!process.env.APPLEID || !process.env.APPLEIDPASS) { return; } diff --git a/extensions/example-extension/page.tsx b/extensions/example-extension/page.tsx index e9eb361ceb..3318cafc00 100644 --- a/extensions/example-extension/page.tsx +++ b/extensions/example-extension/page.tsx @@ -10,6 +10,7 @@ export function ExampleIcon(props: Component.IconProps) { export class ExamplePage extends React.Component<{ extension: LensRendererExtension }> { deactivate = () => { const { extension } = this.props; + extension.disable(); }; @@ -17,6 +18,7 @@ export class ExamplePage extends React.Component<{ extension: LensRendererExtens const doodleStyle = { width: "200px" }; + return (
diff --git a/extensions/kube-object-event-status/src/resolver.tsx b/extensions/kube-object-event-status/src/resolver.tsx index e3921a9cd0..69691c2e79 100644 --- a/extensions/kube-object-event-status/src/resolver.tsx +++ b/extensions/kube-object-event-status/src/resolver.tsx @@ -4,10 +4,12 @@ export function resolveStatus(object: K8sApi.KubeObject): K8sApi.KubeObjectStatu const eventStore = K8sApi.apiManager.getStore(K8sApi.eventApi); const events = (eventStore as K8sApi.EventStore).getEventsByObject(object); const warnings = events.filter(evt => evt.isWarning()); + if (!events.length || !warnings.length) { return null; } const event = [...warnings, ...events][0]; // get latest event + return { level: K8sApi.KubeObjectStatusLevel.WARNING, text: `${event.message}`, @@ -22,10 +24,12 @@ export function resolveStatusForPods(pod: K8sApi.Pod): K8sApi.KubeObjectStatus { const eventStore = K8sApi.apiManager.getStore(K8sApi.eventApi); const events = (eventStore as K8sApi.EventStore).getEventsByObject(pod); const warnings = events.filter(evt => evt.isWarning()); + if (!events.length || !warnings.length) { return null; } const event = [...warnings, ...events][0]; // get latest event + return { level: K8sApi.KubeObjectStatusLevel.WARNING, text: `${event.message}`, @@ -37,13 +41,16 @@ export function resolveStatusForCronJobs(cronJob: K8sApi.CronJob): K8sApi.KubeOb const eventStore = K8sApi.apiManager.getStore(K8sApi.eventApi); let events = (eventStore as K8sApi.EventStore).getEventsByObject(cronJob); const warnings = events.filter(evt => evt.isWarning()); + if (cronJob.isNeverRun()) { events = events.filter(event => event.reason != "FailedNeedsStart"); } + if (!events.length || !warnings.length) { return null; } const event = [...warnings, ...events][0]; // get latest event + return { level: K8sApi.KubeObjectStatusLevel.WARNING, text: `${event.message}`, diff --git a/extensions/metrics-cluster-feature/src/metrics-feature.ts b/extensions/metrics-cluster-feature/src/metrics-feature.ts index 904591d2a3..e29a9156bd 100644 --- a/extensions/metrics-cluster-feature/src/metrics-feature.ts +++ b/extensions/metrics-cluster-feature/src/metrics-feature.ts @@ -53,6 +53,7 @@ export class MetricsFeature extends ClusterFeature.Feature { // Check if there are storageclasses const storageClassApi = K8sApi.forCluster(cluster, K8sApi.StorageClass); const scs = await storageClassApi.list(); + this.templateContext.persistence.enabled = scs.some(sc => ( sc.metadata?.annotations?.["storageclass.kubernetes.io/is-default-class"] === "true" || sc.metadata?.annotations?.["storageclass.beta.kubernetes.io/is-default-class"] === "true" @@ -69,6 +70,7 @@ export class MetricsFeature extends ClusterFeature.Feature { try { const statefulSet = K8sApi.forCluster(cluster, K8sApi.StatefulSet); const prometheus = await statefulSet.get({name: "prometheus", namespace: "lens-metrics"}); + if (prometheus?.kind) { this.status.installed = true; this.status.currentVersion = prometheus.spec.template.spec.containers[0].image.split(":")[1]; diff --git a/extensions/node-menu/src/node-menu.tsx b/extensions/node-menu/src/node-menu.tsx index 7284518ac4..cd4075f532 100644 --- a/extensions/node-menu/src/node-menu.tsx +++ b/extensions/node-menu/src/node-menu.tsx @@ -6,6 +6,7 @@ export interface NodeMenuProps extends Component.KubeObjectMenuProps { const command = `kubectl drain ${nodeName} --delete-local-data --ignore-daemonsets --force`; + Component.ConfirmDialog.open({ ok: () => sendToTerminal(command), labelOk: `Drain Node`, diff --git a/extensions/pod-menu/src/logs-menu.tsx b/extensions/pod-menu/src/logs-menu.tsx index 2556691ca2..dfe3870d12 100644 --- a/extensions/pod-menu/src/logs-menu.tsx +++ b/extensions/pod-menu/src/logs-menu.tsx @@ -8,6 +8,7 @@ export class PodLogsMenu extends React.Component { showLogs(container: K8sApi.IPodContainer) { Navigation.hideDetails(); const pod = this.props.object; + Component.createPodLogsTab({ pod, containers: pod.getContainers(), @@ -22,7 +23,9 @@ export class PodLogsMenu extends React.Component { const { object: pod, toolbar } = this.props; const containers = pod.getAllContainers(); const statuses = pod.getContainerStatuses(); + if (!containers.length) return null; + return ( this.showLogs(containers[0]))}> @@ -40,6 +43,7 @@ export class PodLogsMenu extends React.Component { className={Util.cssNames(Object.keys(status.state)[0], { ready: status.ready })} /> ) : null; + return ( this.showLogs(container))} className="flex align-center"> {brick} diff --git a/extensions/pod-menu/src/shell-menu.tsx b/extensions/pod-menu/src/shell-menu.tsx index d33db02d8c..a93739e89f 100644 --- a/extensions/pod-menu/src/shell-menu.tsx +++ b/extensions/pod-menu/src/shell-menu.tsx @@ -12,9 +12,11 @@ export class PodShellMenu extends React.Component { const { object: pod } = this.props; const containerParam = container ? `-c ${container}` : ""; let command = `kubectl exec -i -t -n ${pod.getNs()} ${pod.getName()} ${containerParam} "--"`; + if (window.navigator.platform !== "Win32") { command = `exec ${command}`; } + if (pod.getSelectedNodeOs() === "windows") { command = `${command} powershell`; } else { @@ -34,7 +36,9 @@ export class PodShellMenu extends React.Component { render() { const { object, toolbar } = this.props; const containers = object.getRunningContainers(); + if (!containers.length) return null; + return ( this.execShell(containers[0].name))}> @@ -46,6 +50,7 @@ export class PodShellMenu extends React.Component { { containers.map(container => { const { name } = container; + return ( this.execShell(name))} className="flex align-center"> diff --git a/extensions/telemetry/src/telemetry-preference.tsx b/extensions/telemetry/src/telemetry-preference.tsx index f96ed43fb8..b1ae12e5c3 100644 --- a/extensions/telemetry/src/telemetry-preference.tsx +++ b/extensions/telemetry/src/telemetry-preference.tsx @@ -7,6 +7,7 @@ import { TelemetryPreferencesStore } from "./telemetry-preferences-store"; export class TelemetryPreferenceInput extends React.Component<{telemetry: TelemetryPreferencesStore}, {}> { render() { const { telemetry } = this.props; + return ( { this.event(ev.name, ev.action, ev.params); }; + this.eventHandlers.push(handler); EventBus.appEventBus.addListener(handler); } watchExtensions() { let previousExtensions = App.getEnabledExtensions(); + this.disposers.push(reaction(() => App.getEnabledExtensions(), (currentExtensions) => { const removedExtensions = previousExtensions.filter(x => !currentExtensions.includes(x)); + removedExtensions.forEach(ext => { this.event("extension", "disable", { extension: ext }); }); const newExtensions = currentExtensions.filter(x => !previousExtensions.includes(x)); + newExtensions.forEach(ext => { this.event("extension", "enable", { extension: ext }); }); @@ -82,6 +87,7 @@ export class Tracker extends Util.Singleton { for (const handler of this.eventHandlers) { EventBus.appEventBus.removeListener(handler); } + if (this.reportInterval) { clearInterval(this.reportInterval); } @@ -125,12 +131,14 @@ export class Tracker extends Util.Singleton { protected resolveOS() { let os = ""; + if (App.isMac) { os = "MacOS"; } else if(App.isWindows) { os = "Windows"; } else if (App.isLinux) { os = "Linux"; + if (App.isSnap) { os += "; Snap"; } else { @@ -139,12 +147,14 @@ export class Tracker extends Util.Singleton { } else { os = "Unknown"; } + return os; } protected async event(eventCategory: string, eventAction: string, otherParams = {}) { try { const allowed = await this.isTelemetryAllowed(); + if (!allowed) { return; } diff --git a/integration/__tests__/app.tests.ts b/integration/__tests__/app.tests.ts index 814465d426..8672076226 100644 --- a/integration/__tests__/app.tests.ts +++ b/integration/__tests__/app.tests.ts @@ -14,9 +14,7 @@ jest.setTimeout(60000); describe("Lens integration tests", () => { const TEST_NAMESPACE = "integration-tests"; const BACKSPACE = "\uE003"; - let app: Application; - const appStart = async () => { app = util.setup(); await app.start(); @@ -25,19 +23,19 @@ describe("Lens integration tests", () => { await app.client.windowByIndex(0); await app.client.waitUntilWindowLoaded(); }; - const clickWhatsNew = async (app: Application) => { await app.client.waitUntilTextExists("h1", "What's new?"); await app.client.click("button.primary"); await app.client.waitUntilTextExists("h1", "Welcome"); }; - const minikubeReady = (): boolean => { // determine if minikube is running { const { status } = spawnSync("minikube status", { shell: true }); + if (status !== 0) { console.warn("minikube not running"); + return false; } } @@ -45,6 +43,7 @@ describe("Lens integration tests", () => { // Remove TEST_NAMESPACE if it already exists { const { status } = spawnSync(`minikube kubectl -- get namespace ${TEST_NAMESPACE}`, { shell: true }); + if (status === 0) { console.warn(`Removing existing ${TEST_NAMESPACE} namespace`); @@ -52,8 +51,10 @@ describe("Lens integration tests", () => { `minikube kubectl -- delete namespace ${TEST_NAMESPACE}`, { shell: true }, ); + if (status !== 0) { console.warn(`Error removing ${TEST_NAMESPACE} namespace: ${stderr.toString()}`); + return false; } @@ -86,6 +87,7 @@ describe("Lens integration tests", () => { describe("preferences page", () => { it('shows "preferences"', async () => { const appName: string = process.platform === "darwin" ? "Lens" : "File"; + await app.electron.ipcRenderer.send("test-menu-item-click", appName, "Preferences"); await app.client.waitUntilTextExists("h2", "Preferences"); }); @@ -153,13 +155,13 @@ describe("Lens integration tests", () => { await app.client.waitUntilTextExists("div", "Select kubeconfig file"); await app.client.click("div.Select__control"); // show the context drop-down list await app.client.waitUntilTextExists("div", "minikube"); + if (!await app.client.$("button.primary").isEnabled()) { await app.client.click("div.minikube"); // select minikube context } // else the only context, which must be 'minikube', is automatically selected await app.client.click("div.Select__control"); // hide the context drop-down list (it might be obscuring the Add cluster(s) button) await app.client.click("button.primary"); // add minikube cluster }; - const waitForMinikubeDashboard = async (app: Application) => { await app.client.waitUntilTextExists("pre.kube-auth-out", "Authentication proxy started"); await app.client.waitForExist(`iframe[name="minikube"]`); @@ -169,7 +171,6 @@ describe("Lens integration tests", () => { util.describeIf(ready)("cluster tests", () => { let clusterAdded = false; - const addCluster = async () => { await clickWhatsNew(app); await addMinikubeCluster(app); @@ -443,6 +444,7 @@ describe("Lens integration tests", () => { expectedText: "Custom Resources" }] }]; + tests.forEach(({ drawer = "", drawerId = "", pages }) => { if (drawer !== "") { it(`shows ${drawer} drawer`, async () => { @@ -458,6 +460,7 @@ describe("Lens integration tests", () => { await app.client.waitUntilTextExists(expectedSelector, expectedText); }); }); + if (drawer !== "") { // hide the drawer it(`hides ${drawer} drawer`, async () => { diff --git a/integration/helpers/utils.ts b/integration/helpers/utils.ts index 263caa50a7..f445a9ae48 100644 --- a/integration/helpers/utils.ts +++ b/integration/helpers/utils.ts @@ -30,7 +30,9 @@ type AsyncPidGetter = () => Promise; export async function tearDown(app: Application) { const pid = await (app.mainProcess.pid as any as AsyncPidGetter)(); + await app.stop(); + try { process.kill(pid, "SIGKILL"); } catch (e) { diff --git a/src/common/__tests__/cluster-store.test.ts b/src/common/__tests__/cluster-store.test.ts index d2d31302d1..fcbd5ebd6b 100644 --- a/src/common/__tests__/cluster-store.test.ts +++ b/src/common/__tests__/cluster-store.test.ts @@ -31,8 +31,10 @@ describe("empty config", () => { "lens-cluster-store.json": JSON.stringify({}) } }; + mockFs(mockOpts); clusterStore = ClusterStore.getInstance(); + return clusterStore.load(); }); @@ -59,6 +61,7 @@ describe("empty config", () => { it("adds new cluster to store", async () => { const storedCluster = clusterStore.getById("foo"); + expect(storedCluster.id).toBe("foo"); expect(storedCluster.preferences.terminalCWD).toBe("/tmp"); expect(storedCluster.preferences.icon).toBe("data:image/jpeg;base64, iVBORw0KGgoAAAANSUhEUgAAA1wAAAKoCAYAAABjkf5"); @@ -67,6 +70,7 @@ describe("empty config", () => { it("adds cluster to default workspace", () => { const storedCluster = clusterStore.getById("foo"); + expect(storedCluster.workspace).toBe("default"); }); @@ -114,6 +118,7 @@ describe("empty config", () => { it("gets clusters by workspaces", () => { const wsClusters = clusterStore.getByWorkspaceId("workstation"); const defaultClusters = clusterStore.getByWorkspaceId("default"); + expect(defaultClusters.length).toBe(0); expect(wsClusters.length).toBe(2); expect(wsClusters[0].id).toBe("prod"); @@ -122,6 +127,7 @@ describe("empty config", () => { it("check if cluster's kubeconfig file saved", () => { const file = ClusterStore.embedCustomKubeConfig("boo", "kubeconfig"); + expect(fs.readFileSync(file, "utf8")).toBe("kubeconfig"); }); @@ -129,6 +135,7 @@ describe("empty config", () => { clusterStore.swapIconOrders("workstation", 1, 1); const clusters = clusterStore.getByWorkspaceId("workstation"); + expect(clusters[0].id).toBe("prod"); expect(clusters[0].preferences.iconOrder).toBe(0); expect(clusters[1].id).toBe("dev"); @@ -139,6 +146,7 @@ describe("empty config", () => { clusterStore.swapIconOrders("workstation", 0, 1); const clusters = clusterStore.getByWorkspaceId("workstation"); + expect(clusters[0].id).toBe("dev"); expect(clusters[0].preferences.iconOrder).toBe(0); expect(clusters[1].id).toBe("prod"); @@ -192,8 +200,10 @@ describe("config with existing clusters", () => { }) } }; + mockFs(mockOpts); clusterStore = ClusterStore.getInstance(); + return clusterStore.load(); }); @@ -203,6 +213,7 @@ describe("config with existing clusters", () => { it("allows to retrieve a cluster", () => { const storedCluster = clusterStore.getById("cluster1"); + expect(storedCluster.id).toBe("cluster1"); expect(storedCluster.preferences.terminalCWD).toBe("/foo"); }); @@ -210,13 +221,16 @@ describe("config with existing clusters", () => { it("allows to delete a cluster", () => { clusterStore.removeById("cluster2"); const storedCluster = clusterStore.getById("cluster1"); + expect(storedCluster).toBeTruthy(); const storedCluster2 = clusterStore.getById("cluster2"); + expect(storedCluster2).toBeUndefined(); }); it("allows getting all of the clusters", async () => { const storedClusters = clusterStore.clustersList; + expect(storedClusters.length).toBe(3); expect(storedClusters[0].id).toBe("cluster1"); expect(storedClusters[0].preferences.terminalCWD).toBe("/foo"); @@ -227,6 +241,7 @@ describe("config with existing clusters", () => { it("marks owned cluster disabled by default", () => { const storedClusters = clusterStore.clustersList; + expect(storedClusters[0].enabled).toBe(true); expect(storedClusters[2].enabled).toBe(false); }); @@ -247,8 +262,10 @@ describe("pre 2.0 config with an existing cluster", () => { }) } }; + mockFs(mockOpts); clusterStore = ClusterStore.getInstance(); + return clusterStore.load(); }); @@ -258,6 +275,7 @@ describe("pre 2.0 config with an existing cluster", () => { it("migrates to modern format with kubeconfig in a file", async () => { const config = clusterStore.clustersList[0].kubeConfigPath; + expect(fs.readFileSync(config, "utf8")).toBe("kubeconfig content"); }); }); @@ -279,8 +297,10 @@ describe("pre 2.6.0 config with a cluster that has arrays in auth config", () => }) } }; + mockFs(mockOpts); clusterStore = ClusterStore.getInstance(); + return clusterStore.load(); }); @@ -292,6 +312,7 @@ describe("pre 2.6.0 config with a cluster that has arrays in auth config", () => const file = clusterStore.clustersList[0].kubeConfigPath; const config = fs.readFileSync(file, "utf8"); const kc = yaml.safeLoad(config); + expect(kc.users[0].user["auth-provider"].config["access-token"]).toBe("should be string"); expect(kc.users[0].user["auth-provider"].config["expiry"]).toBe("should be string"); }); @@ -319,8 +340,10 @@ describe("pre 2.6.0 config with a cluster icon", () => { "icon_path": testDataIcon, } }; + mockFs(mockOpts); clusterStore = ClusterStore.getInstance(); + return clusterStore.load(); }); @@ -330,6 +353,7 @@ describe("pre 2.6.0 config with a cluster icon", () => { it("moves the icon into preferences", async () => { const storedClusterData = clusterStore.clustersList[0]; + expect(storedClusterData.hasOwnProperty("icon")).toBe(false); expect(storedClusterData.preferences.hasOwnProperty("icon")).toBe(true); expect(storedClusterData.preferences.icon.startsWith("data:;base64,")).toBe(true); @@ -356,8 +380,10 @@ describe("for a pre 2.7.0-beta.0 config without a workspace", () => { }) } }; + mockFs(mockOpts); clusterStore = ClusterStore.getInstance(); + return clusterStore.load(); }); @@ -367,6 +393,7 @@ describe("for a pre 2.7.0-beta.0 config without a workspace", () => { it("adds cluster to default workspace", async () => { const storedClusterData = clusterStore.clustersList[0]; + expect(storedClusterData.workspace).toBe("default"); }); }); @@ -396,8 +423,10 @@ describe("pre 3.6.0-beta.1 config with an existing cluster", () => { "icon_path": testDataIcon, } }; + mockFs(mockOpts); clusterStore = ClusterStore.getInstance(); + return clusterStore.load(); }); @@ -407,11 +436,13 @@ describe("pre 3.6.0-beta.1 config with an existing cluster", () => { it("migrates to modern format with kubeconfig in a file", async () => { const config = clusterStore.clustersList[0].kubeConfigPath; + expect(fs.readFileSync(config, "utf8")).toBe("kubeconfig content"); }); it("migrates to modern format with icon not in file", async () => { const { icon } = clusterStore.clustersList[0].preferences; + expect(icon.startsWith("data:;base64,")).toBe(true); }); }); diff --git a/src/common/__tests__/event-bus.test.ts b/src/common/__tests__/event-bus.test.ts index cfd82f54a3..e8159acaf0 100644 --- a/src/common/__tests__/event-bus.test.ts +++ b/src/common/__tests__/event-bus.test.ts @@ -4,6 +4,7 @@ describe("event bus tests", () => { describe("emit", () => { it("emits an event", () => { let event: AppEvent = null; + appEventBus.addListener((data) => { event = data; }); diff --git a/src/common/__tests__/search-store.test.ts b/src/common/__tests__/search-store.test.ts index 3f4125d1c0..7939ef1d8c 100644 --- a/src/common/__tests__/search-store.test.ts +++ b/src/common/__tests__/search-store.test.ts @@ -5,7 +5,6 @@ import { SearchStore } from "../search-store"; let searchStore: SearchStore = null; - const logs = [ "1:M 30 Oct 2020 16:17:41.553 # Connection with replica 172.17.0.12:6379 lost", "1:M 30 Oct 2020 16:17:41.623 * Replica 172.17.0.12:6379 asks for synchronization", @@ -64,6 +63,7 @@ describe("search store tests", () => { it("escapes string for using in regex", () => { const regex = searchStore.escapeRegex("some.interesting-query\\#?()[]"); + expect(regex).toBe("some\\.interesting\\-query\\\\\\#\\?\\(\\)\\[\\]"); }); diff --git a/src/common/__tests__/user-store.test.ts b/src/common/__tests__/user-store.test.ts index 2a05cd9adb..08ca359ce5 100644 --- a/src/common/__tests__/user-store.test.ts +++ b/src/common/__tests__/user-store.test.ts @@ -59,6 +59,7 @@ describe("user store tests", () => { it("correctly resets theme to default value", async () => { const us = UserStore.getInstance(); + us.isLoaded = true; us.preferences.colorTheme = "some other theme"; diff --git a/src/common/__tests__/workspace-store.test.ts b/src/common/__tests__/workspace-store.test.ts index a15128f4e3..e69ebda0aa 100644 --- a/src/common/__tests__/workspace-store.test.ts +++ b/src/common/__tests__/workspace-store.test.ts @@ -44,7 +44,6 @@ describe("workspace store tests", () => { it("can update workspace description", () => { const ws = WorkspaceStore.getInstance(); - const workspace = ws.addWorkspace(new Workspace({ id: "foobar", name: "foobar", @@ -65,6 +64,7 @@ describe("workspace store tests", () => { })); const workspace = ws.getById("123"); + expect(workspace.name).toBe("foobar"); expect(workspace.enabled).toBe(true); }); diff --git a/src/common/base-store.ts b/src/common/base-store.ts index f376236697..f7ad946bfd 100644 --- a/src/common/base-store.ts +++ b/src/common/base-store.ts @@ -55,6 +55,7 @@ export abstract class BaseStore extends Singleton { if (this.params.autoLoad) { await this.load(); } + if (this.params.syncEnabled) { await this.whenLoaded; this.enableSync(); @@ -63,6 +64,7 @@ export abstract class BaseStore extends Singleton { async load() { const { autoLoad, syncEnabled, ...confOptions } = this.params; + this.storeConfig = new Config({ ...confOptions, projectName: "lens", @@ -90,19 +92,23 @@ export abstract class BaseStore extends Singleton { this.syncDisposers.push( reaction(() => this.toJSON(), model => this.onModelChange(model), this.params.syncOptions), ); + if (ipcMain) { const callback = (event: IpcMainEvent, model: T) => { logger.silly(`[STORE]: SYNC ${this.name} from renderer`, { model }); this.onSync(model); }; + subscribeToBroadcast(this.syncMainChannel, callback); this.syncDisposers.push(() => unsubscribeFromBroadcast(this.syncMainChannel, callback)); } + if (ipcRenderer) { const callback = (event: IpcRendererEvent, model: T) => { logger.silly(`[STORE]: SYNC ${this.name} from main`, { model }); this.onSyncFromMain(model); }; + subscribeToBroadcast(this.syncRendererChannel, callback); this.syncDisposers.push(() => unsubscribeFromBroadcast(this.syncRendererChannel, callback)); } @@ -127,6 +133,7 @@ export abstract class BaseStore extends Singleton { protected applyWithoutSync(callback: () => void) { this.disableSync(); runInAction(callback); + if (this.params.syncEnabled) { this.enableSync(); } diff --git a/src/common/cluster-ipc.ts b/src/common/cluster-ipc.ts index 18c0ea0a12..48626f5307 100644 --- a/src/common/cluster-ipc.ts +++ b/src/common/cluster-ipc.ts @@ -15,6 +15,7 @@ export const clusterKubectlApplyAllHandler = "cluster:kubectl-apply-all"; if (ipcMain) { handleRequest(clusterActivateHandler, (event, clusterId: ClusterId, force = false) => { const cluster = clusterStore.getById(clusterId); + if (cluster) { return cluster.activate(force); } @@ -22,20 +23,24 @@ if (ipcMain) { handleRequest(clusterSetFrameIdHandler, (event, clusterId: ClusterId, frameId: number) => { const cluster = clusterStore.getById(clusterId); + if (cluster) { clusterFrameMap.set(cluster.id, frameId); + return cluster.pushState(); } }); handleRequest(clusterRefreshHandler, (event, clusterId: ClusterId) => { const cluster = clusterStore.getById(clusterId); + if (cluster) return cluster.refresh({ refreshMetadata: true }); }); handleRequest(clusterDisconnectHandler, (event, clusterId: ClusterId) => { appEventBus.emit({name: "cluster", action: "stop"}); const cluster = clusterStore.getById(clusterId); + if (cluster) { cluster.disconnect(); clusterFrameMap.delete(cluster.id); @@ -45,8 +50,10 @@ if (ipcMain) { handleRequest(clusterKubectlApplyAllHandler, (event, clusterId: ClusterId, resources: string[]) => { appEventBus.emit({name: "cluster", action: "kubectl-apply-all"}); const cluster = clusterStore.getById(clusterId); + if (cluster) { const applier = new ResourceApplier(cluster); + applier.kubectlApplyAll(resources); } else { throw `${clusterId} is not a valid cluster id`; diff --git a/src/common/cluster-store.ts b/src/common/cluster-store.ts index 5a9827eee5..d8bd28f1e8 100644 --- a/src/common/cluster-store.ts +++ b/src/common/cluster-store.ts @@ -98,7 +98,9 @@ export class ClusterStore extends BaseStore { static embedCustomKubeConfig(clusterId: ClusterId, kubeConfig: KubeConfig | string): string { const filePath = ClusterStore.getCustomKubeConfigPath(clusterId); const fileContents = typeof kubeConfig == "string" ? kubeConfig : dumpConfigYaml(kubeConfig); + saveToAppFiles(filePath, fileContents, { mode: 0o600 }); + return filePath; } @@ -127,11 +129,14 @@ export class ClusterStore extends BaseStore { id: string; state: ClusterState; }; + if (ipcRenderer) { logger.info("[CLUSTER-STORE] requesting initial state sync"); const clusterStates: clusterStateSync[] = await requestMain(ClusterStore.stateRequestChannel); + clusterStates.forEach((clusterState) => { const cluster = this.getById(clusterState.id); + if (cluster) { cluster.setState(clusterState.state); } @@ -139,12 +144,14 @@ export class ClusterStore extends BaseStore { } else { handleRequest(ClusterStore.stateRequestChannel, (): clusterStateSync[] => { const states: clusterStateSync[] = []; + this.clustersList.forEach((cluster) => { states.push({ state: cluster.getState(), id: cluster.id }); }); + return states; }); } @@ -207,6 +214,7 @@ export class ClusterStore extends BaseStore { @action setActive(id: ClusterId) { const clusterId = this.clusters.has(id) ? id : null; + this.activeCluster = clusterId; workspaceStore.setLastActiveClusterId(clusterId); } @@ -214,11 +222,13 @@ export class ClusterStore extends BaseStore { @action swapIconOrders(workspace: WorkspaceId, from: number, to: number) { const clusters = this.getByWorkspaceId(workspace); + if (from < 0 || to < 0 || from >= clusters.length || to >= clusters.length || isNaN(from) || isNaN(to)) { throw new Error(`invalid from<->to arguments`); } move.mutate(clusters, from, to); + for (const i in clusters) { // This resets the iconOrder to the current display order clusters[i].preferences.iconOrder = +i; @@ -236,12 +246,14 @@ export class ClusterStore extends BaseStore { getByWorkspaceId(workspaceId: string): Cluster[] { const clusters = Array.from(this.clusters.values()) .filter(cluster => cluster.workspace === workspaceId); + return _.sortBy(clusters, cluster => cluster.preferences.iconOrder); } @action addClusters(...models: ClusterModel[]): Cluster[] { const clusters: Cluster[] = []; + models.forEach(model => { clusters.push(this.addCluster(model)); }); @@ -253,13 +265,16 @@ export class ClusterStore extends BaseStore { addCluster(model: ClusterModel | Cluster): Cluster { appEventBus.emit({ name: "cluster", action: "add" }); let cluster = model as Cluster; + if (!(model instanceof Cluster)) { cluster = new Cluster(model); } + if (!cluster.isManaged) { cluster.enabled = true; } this.clusters.set(model.id, cluster); + return cluster; } @@ -271,11 +286,14 @@ export class ClusterStore extends BaseStore { async removeById(clusterId: ClusterId) { appEventBus.emit({ name: "cluster", action: "remove" }); const cluster = this.getById(clusterId); + if (cluster) { this.clusters.delete(clusterId); + if (this.activeCluster === clusterId) { this.setActive(null); } + // remove only custom kubeconfigs (pasted as text) if (cluster.kubeConfigPath == ClusterStore.getCustomKubeConfigPath(clusterId)) { unlink(cluster.kubeConfigPath).catch(() => null); @@ -299,10 +317,12 @@ export class ClusterStore extends BaseStore { // update new clusters for (const clusterModel of clusters) { let cluster = currentClusters.get(clusterModel.id); + if (cluster) { cluster.updateModel(clusterModel); } else { cluster = new Cluster(clusterModel); + if (!cluster.isManaged) { cluster.enabled = true; } @@ -336,6 +356,7 @@ export const clusterStore = ClusterStore.getInstance(); export function getClusterIdFromHost(hostname: string): ClusterId { const subDomains = hostname.split(":")[0].split("."); + return subDomains.slice(-2)[0]; // e.g host == "%clusterId.localhost:45345" } diff --git a/src/common/custom-errors.ts b/src/common/custom-errors.ts index 3c7750487a..9bcf3a998a 100644 --- a/src/common/custom-errors.ts +++ b/src/common/custom-errors.ts @@ -2,6 +2,7 @@ export class ExecValidationNotFoundError extends Error { constructor(execPath: string, isAbsolute: boolean) { super(`User Exec command "${execPath}" not found on host.`); let message = `User Exec command "${execPath}" not found on host.`; + if (!isAbsolute) { message += ` Please ensure binary is found in PATH or use absolute path to binary in Kubeconfig`; } diff --git a/src/common/event-emitter.ts b/src/common/event-emitter.ts index d018dce337..2c0cfafebc 100644 --- a/src/common/event-emitter.ts +++ b/src/common/event-emitter.ts @@ -13,6 +13,7 @@ export class EventEmitter { addListener(callback: Callback, options: Options = {}) { if (options.prepend) { const listeners = [...this.listeners]; + listeners.unshift([callback, options]); this.listeners = new Map(listeners); } @@ -33,7 +34,9 @@ export class EventEmitter { [...this.listeners].every(([callback, options]) => { if (options.once) this.removeListener(callback); const result = callback(...data); + if (result === false) return; // break cycle + return true; }); } diff --git a/src/common/ipc.ts b/src/common/ipc.ts index 2d7852681d..628aa503f8 100644 --- a/src/common/ipc.ts +++ b/src/common/ipc.ts @@ -16,18 +16,22 @@ export async function requestMain(channel: string, ...args: any[]) { async function getSubFrames(): Promise { const subFrames: number[] = []; + clusterFrameMap.forEach(frameId => { subFrames.push(frameId); }); + return subFrames; } export function broadcastMessage(channel: string, ...args: any[]) { const views = (webContents || remote?.webContents)?.getAllWebContents(); + if (!views) return; views.forEach(webContent => { const type = webContent.getType(); + logger.silly(`[IPC]: broadcasting "${channel}" to ${type}=${webContent.id}`, { args }); webContent.send(channel, ...args); getSubFrames().then((frames) => { @@ -36,6 +40,7 @@ export function broadcastMessage(channel: string, ...args: any[]) { }); }).catch((e) => e); }); + if (ipcRenderer) { ipcRenderer.send(channel, ...args); } else { diff --git a/src/common/kube-helpers.ts b/src/common/kube-helpers.ts index b8d88c2227..bb0e6b86d2 100644 --- a/src/common/kube-helpers.ts +++ b/src/common/kube-helpers.ts @@ -13,6 +13,7 @@ function resolveTilde(filePath: string) { if (filePath[0] === "~" && (filePath[1] === "/" || filePath.length === 1)) { return filePath.replace("~", os.homedir()); } + return filePath; } @@ -40,12 +41,15 @@ export function validateConfig(config: KubeConfig | string): KubeConfig { config = loadConfig(config); } logger.debug(`validating kube config: ${JSON.stringify(config)}`); + if (!config.users || config.users.length == 0) { throw new Error("No users provided in config"); } + if (!config.clusters || config.clusters.length == 0) { throw new Error("No clusters provided in config"); } + if (!config.contexts || config.contexts.length == 0) { throw new Error("No contexts provided in config"); } @@ -58,11 +62,13 @@ export function validateConfig(config: KubeConfig | string): KubeConfig { */ export function splitConfig(kubeConfig: KubeConfig): KubeConfig[] { const configs: KubeConfig[] = []; + if (!kubeConfig.contexts) { return configs; } kubeConfig.contexts.forEach(ctx => { const kc = new KubeConfig(); + kc.clusters = [kubeConfig.getCluster(ctx.cluster)].filter(n => n); kc.users = [kubeConfig.getUser(ctx.user)].filter(n => n); kc.contexts = [kubeConfig.getContextObject(ctx.name)].filter(n => n); @@ -70,6 +76,7 @@ export function splitConfig(kubeConfig: KubeConfig): KubeConfig[] { configs.push(kc); }); + return configs; } @@ -153,11 +160,13 @@ export function validateKubeConfig (config: KubeConfig) { logger.debug(`validateKubeConfig: validating kubeconfig - ${JSON.stringify(config)}`); // Validate the User Object - const user = config.getCurrentUser(); + const user = config.getCurrentUser(); + if (user.exec) { const execCommand = user.exec["command"]; // check if the command is absolute or not const isAbsolute = path.isAbsolute(execCommand); + // validate the exec struct in the user object, start with the command field logger.debug(`validateKubeConfig: validating user exec command - ${JSON.stringify(execCommand)}`); diff --git a/src/common/prometheus-providers.ts b/src/common/prometheus-providers.ts index 6f6b379ec7..a5c515b338 100644 --- a/src/common/prometheus-providers.ts +++ b/src/common/prometheus-providers.ts @@ -6,6 +6,7 @@ import { PrometheusProviderRegistry } from "../main/prometheus/provider-registry [PrometheusLens, PrometheusHelm, PrometheusOperator, PrometheusStacklight].forEach(providerClass => { const provider = new providerClass(); + PrometheusProviderRegistry.registerProvider(provider.id, provider); }); diff --git a/src/common/rbac.ts b/src/common/rbac.ts index 4097de95c9..702d87d394 100644 --- a/src/common/rbac.ts +++ b/src/common/rbac.ts @@ -42,10 +42,12 @@ export function isAllowedResource(resources: KubeResource | KubeResource[]) { resources = [resources]; } const { allowedResources = [] } = getHostedCluster() || {}; + for (const resource of resources) { if (!allowedResources.includes(resource)) { return false; } } + return true; } diff --git a/src/common/register-protocol.ts b/src/common/register-protocol.ts index b8e9bb3690..be09991488 100644 --- a/src/common/register-protocol.ts +++ b/src/common/register-protocol.ts @@ -7,6 +7,7 @@ export function registerFileProtocol(name: string, basePath: string) { protocol.registerFileProtocol(name, (request, callback) => { const filePath = request.url.replace(`${name}://`, ""); const absPath = path.resolve(basePath, filePath); + callback({ path: absPath }); }); } diff --git a/src/common/request.ts b/src/common/request.ts index 1ebec8c33f..ca34f4a961 100644 --- a/src/common/request.ts +++ b/src/common/request.ts @@ -7,6 +7,7 @@ import { userStore } from "./user-store"; function getDefaultRequestOpts(): Partial { const { httpsProxy, allowUntrustedCAs } = userStore.preferences; + return { proxy: httpsProxy || undefined, rejectUnauthorized: !allowUntrustedCAs, diff --git a/src/common/search-store.ts b/src/common/search-store.ts index 3288bbb3a0..a3aba9dcbe 100644 --- a/src/common/search-store.ts +++ b/src/common/search-store.ts @@ -14,8 +14,10 @@ export class SearchStore { @action onSearch(text: string[], query = this.searchQuery) { this.searchQuery = query; + if (!query) { this.reset(); + return; } this.occurrences = this.findOccurences(text, query); @@ -36,11 +38,14 @@ export class SearchStore { findOccurences(text: string[], query: string) { if (!text) return []; const occurences: number[] = []; + text.forEach((line, index) => { const regex = new RegExp(this.escapeRegex(query), "gi"); const matches = [...line.matchAll(regex)]; + matches.forEach(() => occurences.push(index)); }); + return occurences; } @@ -51,9 +56,11 @@ export class SearchStore { */ getNextOverlay(loopOver = false) { const next = this.activeOverlayIndex + 1; + if (next > this.occurrences.length - 1) { return loopOver ? 0 : this.activeOverlayIndex; } + return next; } @@ -64,9 +71,11 @@ export class SearchStore { */ getPrevOverlay(loopOver = false) { const prev = this.activeOverlayIndex - 1; + if (prev < 0) { return loopOver ? this.occurrences.length - 1 : this.activeOverlayIndex; } + return prev; } @@ -104,6 +113,7 @@ export class SearchStore { @autobind() isActiveOverlay(line: number, occurence: number) { const firstLineIndex = this.occurrences.findIndex(item => item === line); + return firstLineIndex + occurence === this.activeOverlayIndex; } diff --git a/src/common/system-ca.ts b/src/common/system-ca.ts index 3beb135449..6119436fe6 100644 --- a/src/common/system-ca.ts +++ b/src/common/system-ca.ts @@ -6,9 +6,11 @@ import logger from "../main/logger"; if (isMac) { for (const crt of macca.all()) { const attributes = crt.issuer?.attributes?.map((a: any) => `${a.name}=${a.value}`); + logger.debug(`Using host CA: ${attributes.join(",")}`); } } + if (isWindows) { winca.inject("+"); // see: https://github.com/ukoloff/win-ca#caveats } diff --git a/src/common/user-store.ts b/src/common/user-store.ts index 8b694c4f04..0195fcce70 100644 --- a/src/common/user-store.ts +++ b/src/common/user-store.ts @@ -102,6 +102,7 @@ export class UserStore extends BaseStore { protected refreshNewContexts = async () => { try { const kubeConfig = await readFile(this.kubeConfigPath, "utf8"); + if (kubeConfig) { this.newContexts.clear(); loadConfig(kubeConfig).getContexts() @@ -118,6 +119,7 @@ export class UserStore extends BaseStore { @action markNewContextsAsSeen() { const { seenContexts, newContexts } = this; + this.seenContexts.replace([...seenContexts, ...newContexts]); this.newContexts.clear(); } @@ -133,9 +135,11 @@ export class UserStore extends BaseStore { @action protected async fromStore(data: Partial = {}) { const { lastSeenAppVersion, seenContexts = [], preferences, kubeConfigPath } = data; + if (lastSeenAppVersion) { this.lastSeenAppVersion = lastSeenAppVersion; } + if (kubeConfigPath) { this.kubeConfigPath = kubeConfigPath; } @@ -150,6 +154,7 @@ export class UserStore extends BaseStore { seenContexts: Array.from(this.seenContexts), preferences: this.preferences, }; + return toJS(model, { recurseEverything: true, }); diff --git a/src/common/utils/autobind.ts b/src/common/utils/autobind.ts index 8daca1ab85..b5c706e362 100644 --- a/src/common/utils/autobind.ts +++ b/src/common/utils/autobind.ts @@ -12,7 +12,6 @@ export function autobind() { function bindClass(constructor: T) { const proto = constructor.prototype; const descriptors = Object.getOwnPropertyDescriptors(proto); - const skipMethod = (methodName: string) => { return methodName === "constructor" || typeof descriptors[methodName].value !== "function"; @@ -21,6 +20,7 @@ function bindClass(constructor: T) { Object.keys(descriptors).forEach(prop => { if (skipMethod(prop)) return; const boundDescriptor = bindMethod(proto, prop, descriptors[prop]); + Object.defineProperty(proto, prop, boundDescriptor); }); } @@ -38,6 +38,7 @@ function bindMethod(target: object, prop?: string, descriptor?: PropertyDescript get() { if (this === target) return func; // direct access from prototype if (!boundFunc.has(this)) boundFunc.set(this, func.bind(this)); + return boundFunc.get(this); } }); diff --git a/src/common/utils/buildUrl.ts b/src/common/utils/buildUrl.ts index c26e054491..e3bad9b302 100644 --- a/src/common/utils/buildUrl.ts +++ b/src/common/utils/buildUrl.ts @@ -7,8 +7,10 @@ export interface IURLParams

{ export function buildURL

(path: string | any) { const pathBuilder = compile(String(path)); + return function ({ params, query }: IURLParams = {}) { const queryParams = query ? new URLSearchParams(Object.entries(query)).toString() : ""; + return pathBuilder(params) + (queryParams ? `?${queryParams}` : ""); }; } diff --git a/src/common/utils/camelCase.ts b/src/common/utils/camelCase.ts index 90c048cad5..306cb45190 100644 --- a/src/common/utils/camelCase.ts +++ b/src/common/utils/camelCase.ts @@ -8,7 +8,9 @@ export function toCamelCase(obj: Record): any { else if (isPlainObject(obj)) { return Object.keys(obj).reduce((result, key) => { const value = obj[key]; + result[camelCase(key)] = typeof value === "object" ? toCamelCase(value) : value; + return result; }, {} as any); } diff --git a/src/common/utils/debouncePromise.ts b/src/common/utils/debouncePromise.ts index e03c0e76bd..b5ad88d000 100755 --- a/src/common/utils/debouncePromise.ts +++ b/src/common/utils/debouncePromise.ts @@ -2,6 +2,7 @@ export function debouncePromise(func: (...args: F) => T | Promise, timeout = 0): (...args: F) => Promise { let timer: NodeJS.Timeout; + return (...params: any[]) => new Promise(resolve => { clearTimeout(timer); timer = global.setTimeout(() => resolve(func.apply(this, params)), timeout); diff --git a/src/common/utils/downloadFile.ts b/src/common/utils/downloadFile.ts index 4c65901d3d..dfa549da07 100644 --- a/src/common/utils/downloadFile.ts +++ b/src/common/utils/downloadFile.ts @@ -26,6 +26,7 @@ export function downloadFile({ url, timeout, gzip = true }: DownloadFileOptions) resolve(Buffer.concat(fileChunks)); }); }); + return { url, promise, diff --git a/src/common/utils/getRandId.ts b/src/common/utils/getRandId.ts index afe075085d..ef02e2f0eb 100644 --- a/src/common/utils/getRandId.ts +++ b/src/common/utils/getRandId.ts @@ -2,5 +2,6 @@ export function getRandId({ prefix = "", suffix = "", sep = "_" } = {}) { const randId = () => Math.random().toString(16).substr(2); + return [prefix, randId(), suffix].filter(s => s).join(sep); } diff --git a/src/common/utils/saveToAppFiles.ts b/src/common/utils/saveToAppFiles.ts index 57c47f0d70..87d09290c0 100644 --- a/src/common/utils/saveToAppFiles.ts +++ b/src/common/utils/saveToAppFiles.ts @@ -6,7 +6,9 @@ import { WriteFileOptions } from "fs"; export function saveToAppFiles(filePath: string, contents: any, options?: WriteFileOptions): string { const absPath = path.resolve((app || remote.app).getPath("userData"), filePath); + ensureDirSync(path.dirname(absPath)); writeFileSync(absPath, contents, options); + return absPath; } diff --git a/src/common/utils/singleton.ts b/src/common/utils/singleton.ts index ed3f0cc962..61269d10b1 100644 --- a/src/common/utils/singleton.ts +++ b/src/common/utils/singleton.ts @@ -16,6 +16,7 @@ class Singleton { if (!Singleton.instances.has(this)) { Singleton.instances.set(this, Reflect.construct(this, args)); } + return Singleton.instances.get(this) as T; } diff --git a/src/common/utils/splitArray.ts b/src/common/utils/splitArray.ts index f93392f736..7be367ebe6 100644 --- a/src/common/utils/splitArray.ts +++ b/src/common/utils/splitArray.ts @@ -12,8 +12,10 @@ */ export function splitArray(array: T[], element: T): [T[], T[], boolean] { const index = array.indexOf(element); + if (index < 0) { return [array, [], false]; } + return [array.slice(0, index), array.slice(index + 1, array.length), true]; } diff --git a/src/common/utils/tar.ts b/src/common/utils/tar.ts index bec7b5b3f2..f9876e2b27 100644 --- a/src/common/utils/tar.ts +++ b/src/common/utils/tar.ts @@ -26,6 +26,7 @@ export function readFileFromTar({ tarPath, filePath, parseJson }: Re entry.once("end", () => { const data = Buffer.concat(fileChunks); const result = parseJson ? JSON.parse(data.toString("utf8")) : data; + resolve(result); }); }, @@ -39,12 +40,14 @@ export function readFileFromTar({ tarPath, filePath, parseJson }: Re export async function listTarEntries(filePath: string): Promise { const entries: string[] = []; + await tar.list({ file: filePath, onentry: (entry: FileStat) => { entries.push(path.normalize(entry.path as any as string)); }, }); + return entries; } diff --git a/src/common/vars.ts b/src/common/vars.ts index ac9f1336ee..396a1077c5 100644 --- a/src/common/vars.ts +++ b/src/common/vars.ts @@ -30,6 +30,7 @@ defineGlobal("__static", { if (isDevelopment) { return path.resolve(contextDir, "static"); } + return path.resolve(process.resourcesPath, "static"); } }); diff --git a/src/common/workspace-store.ts b/src/common/workspace-store.ts index 4d5af6da98..e1fa113ca3 100644 --- a/src/common/workspace-store.ts +++ b/src/common/workspace-store.ts @@ -125,11 +125,14 @@ export class WorkspaceStore extends BaseStore { id: string; state: WorkspaceState; }; + if (ipcRenderer) { logger.info("[WORKSPACE-STORE] requesting initial state sync"); const workspaceStates: workspaceStateSync[] = await requestMain(WorkspaceStore.stateRequestChannel); + workspaceStates.forEach((workspaceState) => { const workspace = this.getById(workspaceState.id); + if (workspace) { workspace.setState(workspaceState.state); } @@ -137,12 +140,14 @@ export class WorkspaceStore extends BaseStore { } else { handleRequest(WorkspaceStore.stateRequestChannel, (): workspaceStateSync[] => { const states: workspaceStateSync[] = []; + this.workspacesList.forEach((workspace) => { states.push({ state: workspace.getState(), id: workspace.id }); }); + return states; }); } @@ -202,6 +207,7 @@ export class WorkspaceStore extends BaseStore { @action setActive(id = WorkspaceStore.defaultId) { if (id === this.currentWorkspaceId) return; + if (!this.getById(id)) { throw new Error(`workspace ${id} doesn't exist`); } @@ -211,15 +217,18 @@ export class WorkspaceStore extends BaseStore { @action addWorkspace(workspace: Workspace) { const { id, name } = workspace; + if (!name.trim() || this.getByName(name.trim())) { return; } this.workspaces.set(id, workspace); + if (!workspace.isManaged) { workspace.enabled = true; } appEventBus.emit({name: "workspace", action: "add"}); + return workspace; } @@ -237,10 +246,13 @@ export class WorkspaceStore extends BaseStore { @action removeWorkspaceById(id: WorkspaceId) { const workspace = this.getById(id); + if (!workspace) return; + if (this.isDefault(id)) { throw new Error("Cannot remove default workspace"); } + if (this.currentWorkspaceId === id) { this.currentWorkspaceId = WorkspaceStore.defaultId; // reset to default } @@ -259,10 +271,12 @@ export class WorkspaceStore extends BaseStore { if (currentWorkspace) { this.currentWorkspaceId = currentWorkspace; } + if (workspaces.length) { this.workspaces.clear(); workspaces.forEach(ws => { const workspace = new Workspace(ws); + if (!workspace.isManaged) { workspace.enabled = true; } diff --git a/src/extensions/cluster-feature.ts b/src/extensions/cluster-feature.ts index bb60e9d9b4..625f2b5973 100644 --- a/src/extensions/cluster-feature.ts +++ b/src/extensions/cluster-feature.ts @@ -108,12 +108,15 @@ export abstract class ClusterFeature { */ protected renderTemplates(folderPath: string): string[] { const resources: string[] = []; + logger.info(`[FEATURE]: render templates from ${folderPath}`); fs.readdirSync(folderPath).forEach(filename => { const file = path.join(folderPath, filename); const raw = fs.readFileSync(file); + if (filename.endsWith(".hb")) { const template = hb.compile(raw.toString()); + resources.push(template(this.templateContext)); } else { resources.push(raw.toString()); diff --git a/src/extensions/core-api/app.ts b/src/extensions/core-api/app.ts index 2664711db4..2c3a7a4f59 100644 --- a/src/extensions/core-api/app.ts +++ b/src/extensions/core-api/app.ts @@ -3,6 +3,7 @@ import { extensionsStore } from "../extensions-store"; export const version = getAppVersion(); export { isSnap, isWindows, isMac, isLinux, appName, slackUrl, issuesTrackerUrl } from "../../common/vars"; + export function getEnabledExtensions(): string[] { return extensionsStore.enabledExtensions; } diff --git a/src/extensions/extension-discovery.ts b/src/extensions/extension-discovery.ts index 699e37042f..105b8e2041 100644 --- a/src/extensions/extension-discovery.ts +++ b/src/extensions/extension-discovery.ts @@ -25,6 +25,7 @@ export interface InstalledExtension { } const logModule = "[EXTENSION-DISCOVERY]"; + export const manifestFilename = "package.json"; /** @@ -133,7 +134,6 @@ export class ExtensionDiscovery { if (path.basename(filePath) === manifestFilename) { try { const absPath = path.dirname(filePath); - // this.loadExtensionFromPath updates this.packagesJson const extension = await this.loadExtensionFromPath(absPath); @@ -251,6 +251,7 @@ export class ExtensionDiscovery { manifestJson = __non_webpack_require__(manifestPath); const installedManifestPath = path.join(this.nodeModulesPath, manifestJson.name, "package.json"); + this.packagesJson.dependencies[manifestJson.name] = path.dirname(manifestPath); const isEnabled = isBundled || extensionsStore.isEnabled(installedManifestPath); @@ -272,6 +273,7 @@ export class ExtensionDiscovery { async loadExtensions(): Promise> { const bundledExtensions = await this.loadBundledExtensions(); const localExtensions = await this.loadFromFolder(this.localFolderPath); + await this.installPackages(); const extensions = bundledExtensions.concat(localExtensions); @@ -333,12 +335,14 @@ export class ExtensionDiscovery { } const extension = await this.loadExtensionFromPath(absPath); + if (extension) { extensions.push(extension); } } logger.debug(`${logModule}: ${extensions.length} extensions loaded`, { folderPath, extensions }); + return extensions; } diff --git a/src/extensions/extension-installer.ts b/src/extensions/extension-installer.ts index 42863fe60e..2143c62287 100644 --- a/src/extensions/extension-installer.ts +++ b/src/extensions/extension-installer.ts @@ -37,6 +37,7 @@ export class ExtensionInstaller { cwd: extensionPackagesRoot(), silent: true }); + child.on("close", () => { resolve(); }); diff --git a/src/extensions/extension-loader.ts b/src/extensions/extension-loader.ts index eb49021391..71eaa19524 100644 --- a/src/extensions/extension-loader.ts +++ b/src/extensions/extension-loader.ts @@ -176,6 +176,7 @@ export class ExtensionLoader { loadOnClusterRenderer() { logger.info(`${logModule}: load on cluster renderer (dashboard)`); const cluster = getHostedCluster(); + this.autoInitExtensions(async (extension: LensRendererExtension) => { if (await extension.isEnabledForCluster(cluster) === false) { return []; @@ -209,11 +210,13 @@ export class ExtensionLoader { if (ext.isEnabled && !alreadyInit) { try { const LensExtensionClass = this.requireExtension(ext); + if (!LensExtensionClass) { continue; } const instance = new LensExtensionClass(ext); + instance.whenEnabled(() => register(instance)); instance.enable(); this.instances.set(extId, instance); @@ -231,12 +234,14 @@ export class ExtensionLoader { protected requireExtension(extension: InstalledExtension): LensExtensionConstructor { let extEntrypoint = ""; + try { if (ipcRenderer && extension.manifest.renderer) { extEntrypoint = path.resolve(path.join(path.dirname(extension.manifestPath), extension.manifest.renderer)); } else if (!ipcRenderer && extension.manifest.main) { extEntrypoint = path.resolve(path.join(path.dirname(extension.manifestPath), extension.manifest.main)); } + if (extEntrypoint !== "") { return __non_webpack_require__(extEntrypoint).default; } diff --git a/src/extensions/extension-store.ts b/src/extensions/extension-store.ts index a8078994ca..c1a1e62bd8 100644 --- a/src/extensions/extension-store.ts +++ b/src/extensions/extension-store.ts @@ -7,11 +7,13 @@ export abstract class ExtensionStore extends BaseStore { async loadExtension(extension: LensExtension) { this.extension = extension; + return super.load(); } async load() { if (!this.extension) { return; } + return super.load(); } diff --git a/src/extensions/extensions-store.ts b/src/extensions/extensions-store.ts index 2f865a165b..1533c9ad89 100644 --- a/src/extensions/extensions-store.ts +++ b/src/extensions/extensions-store.ts @@ -30,11 +30,13 @@ export class ExtensionsStore extends BaseStore { protected getState(extensionLoader: ExtensionLoader) { const state: Record = {}; + return Array.from(extensionLoader.userExtensions).reduce((state, [extId, ext]) => { state[extId] = { enabled: ext.isEnabled, name: ext.manifest.name, }; + return state; }, state); } @@ -47,6 +49,7 @@ export class ExtensionsStore extends BaseStore { reaction(() => this.state.toJS(), extensionsState => { extensionsState.forEach((state, extId) => { const ext = extensionLoader.getExtension(extId); + if (ext && !ext.isBundled) { ext.isEnabled = state.enabled; } @@ -61,6 +64,7 @@ export class ExtensionsStore extends BaseStore { isEnabled(extId: LensExtensionId) { const state = this.state.get(extId); + return state && state.enabled; // by default false } diff --git a/src/extensions/lens-extension.ts b/src/extensions/lens-extension.ts index 61dc6c0560..aaa6f60ac5 100644 --- a/src/extensions/lens-extension.ts +++ b/src/extensions/lens-extension.ts @@ -86,6 +86,7 @@ export class LensExtension { const cancelReaction = reaction(() => this.isEnabled, async (isEnabled) => { if (isEnabled) { const handlerDisposers = await handlers(); + disposers.push(...handlerDisposers); } else { unregisterHandlers(); @@ -93,6 +94,7 @@ export class LensExtension { }, { fireImmediately: true }); + return () => { unregisterHandlers(); cancelReaction(); diff --git a/src/extensions/lens-main-extension.ts b/src/extensions/lens-main-extension.ts index ab4d7a2a1f..f0e943540d 100644 --- a/src/extensions/lens-main-extension.ts +++ b/src/extensions/lens-main-extension.ts @@ -13,6 +13,7 @@ export class LensMainExtension extends LensExtension { pageId, params: params ?? {}, // compile to url with params }); + await windowManager.navigate(pageUrl, frameId); } } diff --git a/src/extensions/lens-renderer-extension.ts b/src/extensions/lens-renderer-extension.ts index 0c11306efd..b6c00d8353 100644 --- a/src/extensions/lens-renderer-extension.ts +++ b/src/extensions/lens-renderer-extension.ts @@ -23,6 +23,7 @@ export class LensRendererExtension extends LensExtension { pageId, params: params ?? {}, // compile to url with params }); + navigate(pageUrl); } diff --git a/src/extensions/registries/__tests__/page-registry.test.ts b/src/extensions/registries/__tests__/page-registry.test.ts index b7f2cd1252..78db140ed7 100644 --- a/src/extensions/registries/__tests__/page-registry.test.ts +++ b/src/extensions/registries/__tests__/page-registry.test.ts @@ -73,6 +73,7 @@ describe("globalPageRegistry", () => { describe("getByPageMenuTarget", () => { it("matching to first registered page without id", () => { const page = globalPageRegistry.getByPageMenuTarget({ extensionId: ext.name }); + expect(page.id).toEqual(undefined); expect(page.extensionId).toEqual(ext.name); expect(page.routePath).toEqual(getExtensionPageUrl({ extensionId: ext.name })); @@ -83,6 +84,7 @@ describe("globalPageRegistry", () => { pageId: "test-page", extensionId: ext.name }); + expect(page.id).toEqual("test-page"); }); @@ -91,6 +93,7 @@ describe("globalPageRegistry", () => { pageId: "wrong-page", extensionId: ext.name }); + expect(page).toBeNull(); }); }); diff --git a/src/extensions/registries/base-registry.ts b/src/extensions/registries/base-registry.ts index 4bfb3f9cd2..6d5485b32b 100644 --- a/src/extensions/registries/base-registry.ts +++ b/src/extensions/registries/base-registry.ts @@ -14,7 +14,9 @@ export class BaseRegistry { @action add(items: T | T[]) { const itemArray = rectify(items); + this.items.push(...itemArray); + return () => this.remove(...itemArray); } diff --git a/src/extensions/registries/kube-object-detail-registry.ts b/src/extensions/registries/kube-object-detail-registry.ts index 2638e82ade..9c79b662ea 100644 --- a/src/extensions/registries/kube-object-detail-registry.ts +++ b/src/extensions/registries/kube-object-detail-registry.ts @@ -20,8 +20,10 @@ export class KubeObjectDetailRegistry extends BaseRegistry b.priority - a.priority); } } diff --git a/src/extensions/registries/page-menu-registry.ts b/src/extensions/registries/page-menu-registry.ts index 9f5c4861a4..8ccbc9cd6c 100644 --- a/src/extensions/registries/page-menu-registry.ts +++ b/src/extensions/registries/page-menu-registry.ts @@ -35,8 +35,10 @@ export class GlobalPageMenuRegistry extends BaseRegistry { extensionId: ext.name, ...(menuItem.target || {}), }; + return menuItem; }); + return super.add(normalizedItems); } } @@ -49,8 +51,10 @@ export class ClusterPageMenuRegistry extends BaseRegistry({ extensionId, pageId = "" name: sanitizeExtensionName(extensionId), // compile only with extension-id first and define base path }); const extPageRoutePath = path.join(extensionBaseUrl, pageId); // page-id might contain route :param-s, so don't compile yet + if (params) { return compile(extPageRoutePath)(params); // might throw error when required params not passed } + return extPageRoutePath; } @@ -56,6 +58,7 @@ export class PageRegistry extends BaseRegistry { add(items: PageRegistration | PageRegistration[], ext: LensExtension) { const itemArray = rectify(items); let registeredPages: RegisteredPage[] = []; + try { registeredPages = itemArray.map(page => ({ ...page, @@ -69,6 +72,7 @@ export class PageRegistry extends BaseRegistry { error: String(err), }); } + return super.add(registeredPages); } @@ -78,8 +82,10 @@ export class PageRegistry extends BaseRegistry { getByPageMenuTarget(target: PageMenuTarget = {}): RegisteredPage | null { const targetUrl = getExtensionPageUrl(target); + return this.getItems().find(({ id: pageId, extensionId }) => { const pageUrl = getExtensionPageUrl({ extensionId, pageId, params: target.params }); // compiled with provided params + return targetUrl === pageUrl; }) || null; } diff --git a/src/extensions/stores/cluster-store.ts b/src/extensions/stores/cluster-store.ts index 60c409b525..988302543e 100644 --- a/src/extensions/stores/cluster-store.ts +++ b/src/extensions/stores/cluster-store.ts @@ -40,6 +40,7 @@ export class ClusterStore extends Singleton { if (!this.activeClusterId) { return null; } + return this.getById(this.activeClusterId); } diff --git a/src/main/__test__/cluster.test.ts b/src/main/__test__/cluster.test.ts index c057501eac..20e56c8b84 100644 --- a/src/main/__test__/cluster.test.ts +++ b/src/main/__test__/cluster.test.ts @@ -75,6 +75,7 @@ describe("create clusters", () => { preferences: {}, }) }; + mockFs(mockOpts); jest.spyOn(Kubectl.prototype, "ensureKubectl").mockReturnValue(Promise.resolve(true)); c = new Cluster({ @@ -112,6 +113,7 @@ describe("create clusters", () => { it("activating cluster should try to connect to cluster and do a refresh", async () => { const port = await getFreePort(); + jest.spyOn(ContextHandler.prototype, "ensureServer"); const mockListNSs = jest.fn(); @@ -122,17 +124,20 @@ describe("create clusters", () => { }; } }; + jest.spyOn(Cluster.prototype, "isClusterAdmin").mockReturnValue(Promise.resolve(true)); jest.spyOn(Cluster.prototype, "canI") .mockImplementationOnce((attr: V1ResourceAttributes): Promise => { expect(attr.namespace).toBe("default"); expect(attr.resource).toBe("pods"); expect(attr.verb).toBe("list"); + return Promise.resolve(true); }) .mockImplementation((attr: V1ResourceAttributes): Promise => { expect(attr.namespace).toBe("default"); expect(attr.verb).toBe("list"); + return Promise.resolve(true); }); jest.spyOn(Cluster.prototype, "getProxyKubeconfig").mockReturnValue(mockKC as any); @@ -148,6 +153,7 @@ describe("create clusters", () => { mockedRequest.mockImplementationOnce(((uri: any) => { expect(uri).toBe(`http://localhost:${port}/api-kube/version`); + return Promise.resolve({ gitVersion: "1.2.3" }); }) as any); @@ -165,6 +171,7 @@ describe("create clusters", () => { kubeConfigPath: "minikube-config.yml", workspace: workspaceStore.currentWorkspaceId }); + await c.init(port); await c.activate(); diff --git a/src/main/__test__/kube-auth-proxy.test.ts b/src/main/__test__/kube-auth-proxy.test.ts index ac8322d75b..b161372555 100644 --- a/src/main/__test__/kube-auth-proxy.test.ts +++ b/src/main/__test__/kube-auth-proxy.test.ts @@ -49,6 +49,7 @@ describe("kube auth proxy tests", () => { it("calling exit multiple times shouldn't throw", async () => { const port = await getFreePort(); const kap = new KubeAuthProxy(new Cluster({ id: "foobar", kubeConfigPath: "fake-path.yml" }), port, {}); + kap.exit(); kap.exit(); kap.exit(); @@ -69,24 +70,29 @@ describe("kube auth proxy tests", () => { jest.spyOn(Kubectl.prototype, "ensureKubectl").mockReturnValueOnce(Promise.resolve(false)); mockedCP.on.mockImplementation((event: string, listener: (message: any, sendHandle: any) => void): ChildProcess => { listeners[event] = listener; + return mockedCP; }); mockedCP.stderr = mock(); mockedCP.stderr.on.mockImplementation((event: string, listener: (message: any, sendHandle: any) => void): Readable => { listeners[`stderr/${event}`] = listener; + return mockedCP.stderr; }); mockedCP.stdout = mock(); mockedCP.stdout.on.mockImplementation((event: string, listener: (message: any, sendHandle: any) => void): Readable => { listeners[`stdout/${event}`] = listener; + return mockedCP.stdout; }); mockSpawn.mockImplementationOnce((command: string): ChildProcess => { expect(command).toBe(bundledKubectlPath()); + return mockedCP; }); mockWaitUntilUsed.mockReturnValueOnce(Promise.resolve()); const cluster = new Cluster({ id: "foobar", kubeConfigPath: "fake-path.yml" }); + jest.spyOn(cluster, "apiUrl", "get").mockReturnValue("https://fake.k8s.internal"); proxy = new KubeAuthProxy(cluster, port, {}); }); diff --git a/src/main/__test__/kubeconfig-manager.test.ts b/src/main/__test__/kubeconfig-manager.test.ts index 152a13055d..5a5eca9548 100644 --- a/src/main/__test__/kubeconfig-manager.test.ts +++ b/src/main/__test__/kubeconfig-manager.test.ts @@ -64,6 +64,7 @@ describe("kubeconfig manager tests", () => { preferences: {}, }) }; + mockFs(mockOpts); }); @@ -86,6 +87,7 @@ describe("kubeconfig manager tests", () => { expect(kubeConfManager.getPath()).toBe("tmp/kubeconfig-foo"); const file = await fse.readFile(kubeConfManager.getPath()); const yml = loadYaml(file.toString()); + expect(yml["current-context"]).toBe("minikube"); expect(yml["clusters"][0]["cluster"]["server"]).toBe(`http://127.0.0.1:${port}/foo`); expect(yml["users"][0]["name"]).toBe("proxy"); @@ -101,8 +103,8 @@ describe("kubeconfig manager tests", () => { const contextHandler = new ContextHandler(cluster); const port = await getFreePort(); const kubeConfManager = await KubeconfigManager.create(cluster, contextHandler, port); - const configPath = kubeConfManager.getPath(); + expect(await fse.pathExists(configPath)).toBe(true); await kubeConfManager.unlink(); expect(await fse.pathExists(configPath)).toBe(false); diff --git a/src/main/app-updater.ts b/src/main/app-updater.ts index c7b6659149..dd9ed97e69 100644 --- a/src/main/app-updater.ts +++ b/src/main/app-updater.ts @@ -14,6 +14,7 @@ export class AppUpdater { public start() { setInterval(AppUpdater.checkForUpdates, this.updateInterval); + return AppUpdater.checkForUpdates(); } } diff --git a/src/main/cluster-detectors/base-cluster-detector.ts b/src/main/cluster-detectors/base-cluster-detector.ts index f73cc2ac81..9d52e1a70e 100644 --- a/src/main/cluster-detectors/base-cluster-detector.ts +++ b/src/main/cluster-detectors/base-cluster-detector.ts @@ -20,6 +20,7 @@ export class BaseClusterDetector { protected async k8sRequest(path: string, options: RequestPromiseOptions = {}): Promise { const apiUrl = this.cluster.kubeProxyUrl + path; + return request(apiUrl, { json: true, timeout: 30000, diff --git a/src/main/cluster-detectors/cluster-id-detector.ts b/src/main/cluster-detectors/cluster-id-detector.ts index 2605ca269f..2e0cc694ff 100644 --- a/src/main/cluster-detectors/cluster-id-detector.ts +++ b/src/main/cluster-detectors/cluster-id-detector.ts @@ -7,17 +7,20 @@ export class ClusterIdDetector extends BaseClusterDetector { public async detect() { let id: string; + try { id = await this.getDefaultNamespaceId(); } catch(_) { id = this.cluster.apiUrl; } const value = createHash("sha256").update(id).digest("hex"); + return { value, accuracy: 100 }; } protected async getDefaultNamespaceId() { const response = await this.k8sRequest("/api/v1/namespaces/default"); + return response.metadata.uid; } } \ No newline at end of file diff --git a/src/main/cluster-detectors/detector-registry.ts b/src/main/cluster-detectors/detector-registry.ts index d4abe01304..43c56153c9 100644 --- a/src/main/cluster-detectors/detector-registry.ts +++ b/src/main/cluster-detectors/detector-registry.ts @@ -17,12 +17,16 @@ export class DetectorRegistry { async detectForCluster(cluster: Cluster): Promise { const results: {[key: string]: ClusterDetectionResult } = {}; + for (const detectorClass of this.registry) { const detector = new detectorClass(cluster); + try { const data = await detector.detect(); + if (!data) continue; const existingValue = results[detector.key]; + if (existingValue && existingValue.accuracy > data.accuracy) continue; // previous value exists and is more accurate results[detector.key] = data; } catch (e) { @@ -30,9 +34,11 @@ export class DetectorRegistry { } } const metadata: ClusterMetadata = {}; + for (const [key, result] of Object.entries(results)) { metadata[key] = result.value; } + return metadata; } } diff --git a/src/main/cluster-detectors/distribution-detector.ts b/src/main/cluster-detectors/distribution-detector.ts index 181425cb26..f4e981c568 100644 --- a/src/main/cluster-detectors/distribution-detector.ts +++ b/src/main/cluster-detectors/distribution-detector.ts @@ -7,30 +7,39 @@ export class DistributionDetector extends BaseClusterDetector { public async detect() { this.version = await this.getKubernetesVersion(); + if (await this.isRancher()) { return { value: "rancher", accuracy: 80}; } + if (this.isGKE()) { return { value: "gke", accuracy: 80}; } + if (this.isEKS()) { return { value: "eks", accuracy: 80}; } + if (this.isIKS()) { return { value: "iks", accuracy: 80}; } + if (this.isAKS()) { return { value: "aks", accuracy: 80}; } + if (this.isDigitalOcean()) { return { value: "digitalocean", accuracy: 90}; } + if (this.isMinikube()) { return { value: "minikube", accuracy: 80}; } + if (this.isCustom()) { return { value: "custom", accuracy: 10}; } + return { value: "unknown", accuracy: 10}; } @@ -38,6 +47,7 @@ export class DistributionDetector extends BaseClusterDetector { if (this.cluster.version) return this.cluster.version; const response = await this.k8sRequest("/version"); + return response.gitVersion; } @@ -72,6 +82,7 @@ export class DistributionDetector extends BaseClusterDetector { protected async isRancher() { try { const response = await this.k8sRequest(""); + return response.data.find((api: any) => api?.apiVersion?.group === "meta.cattle.io") !== undefined; } catch (e) { return false; diff --git a/src/main/cluster-detectors/last-seen-detector.ts b/src/main/cluster-detectors/last-seen-detector.ts index d56483625a..e648d5f2f9 100644 --- a/src/main/cluster-detectors/last-seen-detector.ts +++ b/src/main/cluster-detectors/last-seen-detector.ts @@ -8,6 +8,7 @@ export class LastSeenDetector extends BaseClusterDetector { if (!this.cluster.accessible) return null; await this.k8sRequest("/version"); + return { value: new Date().toJSON(), accuracy: 100 }; } } \ No newline at end of file diff --git a/src/main/cluster-detectors/nodes-count-detector.ts b/src/main/cluster-detectors/nodes-count-detector.ts index ba5fc93583..0ece5dd080 100644 --- a/src/main/cluster-detectors/nodes-count-detector.ts +++ b/src/main/cluster-detectors/nodes-count-detector.ts @@ -7,11 +7,13 @@ export class NodesCountDetector extends BaseClusterDetector { public async detect() { if (!this.cluster.accessible) return null; const nodeCount = await this.getNodeCount(); + return { value: nodeCount, accuracy: 100}; } protected async getNodeCount(): Promise { const response = await this.k8sRequest("/api/v1/nodes"); + return response.items.length; } } \ No newline at end of file diff --git a/src/main/cluster-detectors/version-detector.ts b/src/main/cluster-detectors/version-detector.ts index e59e6291b9..8080ef57a1 100644 --- a/src/main/cluster-detectors/version-detector.ts +++ b/src/main/cluster-detectors/version-detector.ts @@ -7,11 +7,13 @@ export class VersionDetector extends BaseClusterDetector { public async detect() { const version = await this.getKubernetesVersion(); + return { value: version, accuracy: 100}; } public async getKubernetesVersion() { const response = await this.k8sRequest("/version"); + return response.gitVersion; } } \ No newline at end of file diff --git a/src/main/cluster-manager.ts b/src/main/cluster-manager.ts index 9b2e88ef89..5717c7278d 100644 --- a/src/main/cluster-manager.ts +++ b/src/main/cluster-manager.ts @@ -24,8 +24,10 @@ export class ClusterManager extends Singleton { // auto-stop removed clusters autorun(() => { const removedClusters = Array.from(clusterStore.removedClusters.values()); + if (removedClusters.length > 0) { const meta = removedClusters.map(cluster => cluster.getMeta()); + logger.info(`[CLUSTER-MANAGER]: removing clusters`, meta); removedClusters.forEach(cluster => cluster.disconnect()); clusterStore.removedClusters.clear(); @@ -70,7 +72,9 @@ export class ClusterManager extends Singleton { // lens-server is connecting to 127.0.0.1:/ if (req.headers.host.startsWith("127.0.0.1")) { const clusterId = req.url.split("/")[1]; + cluster = clusterStore.getById(clusterId); + if (cluster) { // we need to swap path prefix so that request is proxied to kube api req.url = req.url.replace(`/${clusterId}`, apiKubePrefix); @@ -79,6 +83,7 @@ export class ClusterManager extends Singleton { cluster = clusterStore.getById(req.headers["x-cluster-id"].toString()); } else { const clusterId = getClusterIdFromHost(req.headers.host); + cluster = clusterStore.getById(clusterId); } diff --git a/src/main/cluster.ts b/src/main/cluster.ts index 3f03f90716..421856dc03 100644 --- a/src/main/cluster.ts +++ b/src/main/cluster.ts @@ -91,6 +91,7 @@ export class Cluster implements ClusterModel, ClusterState { @computed get prometheusPreferences(): ClusterPrometheusPreferences { const { prometheus, prometheusProvider } = this.preferences; + return toJS({ prometheus, prometheusProvider }, { recurseEverything: true, }); @@ -103,6 +104,7 @@ export class Cluster implements ClusterModel, ClusterState { constructor(model: ClusterModel) { this.updateModel(model); const kubeconfig = this.getKubeconfig(); + if (kubeconfig.getContextObject(this.contextName)) { this.apiUrl = kubeconfig.getCluster(kubeconfig.getContextObject(this.contextName).cluster).server; } @@ -167,13 +169,16 @@ export class Cluster implements ClusterModel, ClusterState { } logger.info(`[CLUSTER]: activate`, this.getMeta()); await this.whenInitialized; + if (!this.eventDisposers.length) { this.bindEvents(); } + if (this.disconnected || !this.accessible) { await this.reconnect(); } await this.refreshConnectionStatus(); + if (this.accessible) { await this.refreshAllowedResources(); this.isAdmin = await this.isClusterAdmin(); @@ -181,11 +186,13 @@ export class Cluster implements ClusterModel, ClusterState { this.ensureKubectl(); } this.activated = true; + return this.pushState(); } protected async ensureKubectl() { this.kubeCtl = new Kubectl(this.version); + return this.kubeCtl.ensureKubectl(); // download kubectl in background, so it's not blocking dashboard } @@ -215,9 +222,11 @@ export class Cluster implements ClusterModel, ClusterState { logger.info(`[CLUSTER]: refresh`, this.getMeta()); await this.whenInitialized; await this.refreshConnectionStatus(); + if (this.accessible) { this.isAdmin = await this.isClusterAdmin(); await this.refreshAllowedResources(); + if (opts.refreshMetadata) { this.refreshMetadata(); } @@ -231,12 +240,14 @@ export class Cluster implements ClusterModel, ClusterState { logger.info(`[CLUSTER]: refreshMetadata`, this.getMeta()); const metadata = await detectorRegistry.detectForCluster(this); const existingMetadata = this.metadata; + this.metadata = Object.assign(existingMetadata, metadata); } @action async refreshConnectionStatus() { const connectionStatus = await this.getConnectionStatus(); + this.online = connectionStatus > ClusterStatus.Offline; this.accessible = connectionStatus == ClusterStatus.AccessGranted; } @@ -271,6 +282,7 @@ export class Cluster implements ClusterModel, ClusterState { getMetrics(prometheusPath: string, queryParams: IMetricsReqParams & { query: string }) { const prometheusPrefix = this.preferences.prometheus?.prefix || ""; const metricsPath = `/api/v1/namespaces/${prometheusPath}/proxy${prometheusPrefix}/api/v1/query_range`; + return this.k8sRequest(metricsPath, { timeout: 0, resolveWithFullResponse: false, @@ -283,43 +295,54 @@ export class Cluster implements ClusterModel, ClusterState { try { const versionDetector = new VersionDetector(this); const versionData = await versionDetector.detect(); + this.metadata.version = versionData.value; + return ClusterStatus.AccessGranted; } catch (error) { logger.error(`Failed to connect cluster "${this.contextName}": ${error}`); + if (error.statusCode) { if (error.statusCode >= 400 && error.statusCode < 500) { this.failureReason = "Invalid credentials"; + return ClusterStatus.AccessDenied; } else { this.failureReason = error.error || error.message; + return ClusterStatus.Offline; } } else if (error.failed === true) { if (error.timedOut === true) { this.failureReason = "Connection timed out"; + return ClusterStatus.Offline; } else { this.failureReason = "Failed to fetch credentials"; + return ClusterStatus.AccessDenied; } } this.failureReason = error.message; + return ClusterStatus.Offline; } } async canI(resourceAttributes: V1ResourceAttributes): Promise { const authApi = this.getProxyKubeconfig().makeApiClient(AuthorizationV1Api); + try { const accessReview = await authApi.createSelfSubjectAccessReview({ apiVersion: "authorization.k8s.io/v1", kind: "SelfSubjectAccessReview", spec: { resourceAttributes } }); + return accessReview.body.status.allowed; } catch (error) { logger.error(`failed to request selfSubjectAccessReview: ${error}`); + return false; } } @@ -343,6 +366,7 @@ export class Cluster implements ClusterModel, ClusterState { ownerRef: this.ownerRef, accessibleNamespaces: this.accessibleNamespaces, }; + return toJS(model, { recurseEverything: true }); @@ -363,6 +387,7 @@ export class Cluster implements ClusterModel, ClusterState { allowedNamespaces: this.allowedNamespaces, allowedResources: this.allowedResources, }; + return toJS(state, { recurseEverything: true }); @@ -397,6 +422,7 @@ export class Cluster implements ClusterModel, ClusterState { } const api = this.getProxyKubeconfig().makeApiClient(CoreV1Api); + try { const namespaceList = await api.listNamespace(); const nsAccessStatuses = await Promise.all( @@ -406,12 +432,15 @@ export class Cluster implements ClusterModel, ClusterState { verb: "list", })) ); + return namespaceList.body.items .filter((ns, i) => nsAccessStatuses[i]) .map(ns => ns.metadata.name); } catch (error) { const ctx = this.getProxyKubeconfig().getContextObject(this.contextName); + if (ctx.namespace) return [ctx.namespace]; + return []; } } @@ -429,6 +458,7 @@ export class Cluster implements ClusterModel, ClusterState { namespace: this.allowedNamespaces[0] })) ); + return apiResources .filter((resource, i) => resourceAccessStatuses[i]) .map(apiResource => apiResource.resource); diff --git a/src/main/context-handler.ts b/src/main/context-handler.ts index 2c0c0b4e8d..d67c495a84 100644 --- a/src/main/context-handler.ts +++ b/src/main/context-handler.ts @@ -25,28 +25,34 @@ export class ContextHandler { public setupPrometheus(preferences: ClusterPrometheusPreferences = {}) { this.prometheusProvider = preferences.prometheusProvider?.type; this.prometheusPath = null; + if (preferences.prometheus) { const { namespace, service, port } = preferences.prometheus; + this.prometheusPath = `${namespace}/services/${service}:${port}`; } } protected async resolvePrometheusPath(): Promise { const prometheusService = await this.getPrometheusService(); + if (!prometheusService) return null; const { service, namespace, port } = prometheusService; + return `${namespace}/services/${service}:${port}`; } async getPrometheusProvider() { if (!this.prometheusProvider) { const service = await this.getPrometheusService(); + if (!service) { return null; } logger.info(`using ${service.id} as prometheus provider`); this.prometheusProvider = service.id; } + return prometheusProviders.find(p => p.id === this.prometheusProvider); } @@ -54,9 +60,11 @@ export class ContextHandler { const providers = this.prometheusProvider ? prometheusProviders.filter(provider => provider.id == this.prometheusProvider) : prometheusProviders; const prometheusPromises: Promise[] = providers.map(async (provider: PrometheusProvider): Promise => { const apiClient = this.cluster.getProxyKubeconfig().makeApiClient(CoreV1Api); + return await provider.getPrometheusService(apiClient); }); const resolvedPrometheusServices = await Promise.all(prometheusPromises); + return resolvedPrometheusServices.filter(n => n)[0]; } @@ -64,12 +72,14 @@ export class ContextHandler { if (!this.prometheusPath) { this.prometheusPath = await this.resolvePrometheusPath(); } + return this.prometheusPath; } async resolveAuthProxyUrl() { const proxyPort = await this.ensurePort(); const path = this.clusterUrl.path !== "/" ? this.clusterUrl.path : ""; + return `http://127.0.0.1:${proxyPort}${path}`; } @@ -79,14 +89,17 @@ export class ContextHandler { } const timeout = isWatchRequest ? 4 * 60 * 60 * 1000 : 30000; // 4 hours for watch request, 30 seconds for the rest const apiTarget = await this.newApiTarget(timeout); + if (!isWatchRequest) { this.apiTarget = apiTarget; } + return apiTarget; } protected async newApiTarget(timeout: number): Promise { const proxyUrl = await this.resolveAuthProxyUrl(); + return { target: proxyUrl, changeOrigin: true, @@ -101,6 +114,7 @@ export class ContextHandler { if (!this.proxyPort) { this.proxyPort = await getFreePort(); } + return this.proxyPort; } @@ -108,6 +122,7 @@ export class ContextHandler { if (!this.kubeAuthProxy) { await this.ensurePort(); const proxyEnv = Object.assign({}, process.env); + if (this.cluster.preferences.httpsProxy) { proxyEnv.HTTPS_PROXY = this.cluster.preferences.httpsProxy; } diff --git a/src/main/exit-app.ts b/src/main/exit-app.ts index a998a2811f..63fe7673e6 100644 --- a/src/main/exit-app.ts +++ b/src/main/exit-app.ts @@ -8,6 +8,7 @@ import logger from "./logger"; export function exitApp() { const windowManager = WindowManager.getInstance(); const clusterManager = ClusterManager.getInstance(); + appEventBus.emit({ name: "service", action: "close" }); windowManager.hide(); clusterManager.stop(); diff --git a/src/main/extension-filesystem.ts b/src/main/extension-filesystem.ts index fb3a4060be..c4ee622e1d 100644 --- a/src/main/extension-filesystem.ts +++ b/src/main/extension-filesystem.ts @@ -32,11 +32,14 @@ export class FilesystemProvisionerStore extends BaseStore { const salt = randomBytes(32).toString("hex"); const hashedName = SHA256(`${extensionName}/${salt}`).toString(); const dirPath = path.resolve(app.getPath("userData"), "extension_data", hashedName); + this.registeredExtensions.set(extensionName, dirPath); } const dirPath = this.registeredExtensions.get(extensionName); + await fse.ensureDir(dirPath); + return dirPath; } diff --git a/src/main/helm/helm-chart-manager.ts b/src/main/helm/helm-chart-manager.ts index a5920afb71..cf4a8e5ace 100644 --- a/src/main/helm/helm-chart-manager.ts +++ b/src/main/helm/helm-chart-manager.ts @@ -20,32 +20,39 @@ export class HelmChartManager { public async chart(name: string) { const charts = await this.charts(); + return charts[name]; } public async charts(): Promise { try { const cachedYaml = await this.cachedYaml(); + return cachedYaml["entries"]; } catch(error) { logger.error(error); + return []; } } public async getReadme(name: string, version = "") { const helm = await helmCli.binaryPath(); + if(version && version != "") { const { stdout } = await promiseExec(`"${helm}" show readme ${this.repo.name}/${name} --version ${version}`).catch((error) => { throw(error.stderr);}); + return stdout; } else { const { stdout } = await promiseExec(`"${helm}" show readme ${this.repo.name}/${name}`).catch((error) => { throw(error.stderr);}); + return stdout; } } public async getValues(name: string, version = "") { const helm = await helmCli.binaryPath(); + if(version && version != "") { const { stdout } = await promiseExec(`"${helm}" show values ${this.repo.name}/${name} --version ${version}`).catch((error) => { throw(error.stderr);}); @@ -61,6 +68,7 @@ export class HelmChartManager { if (!(this.repo.name in this.cache)) { const cacheFile = await fs.promises.readFile(this.repo.cacheFilePath, "utf-8"); const data = yaml.safeLoad(cacheFile); + for(const key in data["entries"]) { data["entries"][key].forEach((version: any) => { version["repo"] = this.repo.name; @@ -69,6 +77,7 @@ export class HelmChartManager { } this.cache[this.repo.name] = Buffer.from(JSON.stringify(data)); } + return JSON.parse(this.cache[this.repo.name].toString()); } } diff --git a/src/main/helm/helm-cli.ts b/src/main/helm/helm-cli.ts index 90ad78ba1d..ca6f755896 100644 --- a/src/main/helm/helm-cli.ts +++ b/src/main/helm/helm-cli.ts @@ -12,6 +12,7 @@ export class HelmCli extends LensBinary { originalBinaryName: "helm", newBinaryName: "helm3" }; + super(opts); } diff --git a/src/main/helm/helm-release-manager.ts b/src/main/helm/helm-release-manager.ts index a669ff2a6c..220d665ae0 100644 --- a/src/main/helm/helm-release-manager.ts +++ b/src/main/helm/helm-release-manager.ts @@ -12,14 +12,15 @@ export class HelmReleaseManager { const helm = await helmCli.binaryPath(); const namespaceFlag = namespace ? `-n ${namespace}` : "--all-namespaces"; const { stdout } = await promiseExec(`"${helm}" ls --output json ${namespaceFlag} --kubeconfig ${pathToKubeconfig}`).catch((error) => { throw(error.stderr);}); - const output = JSON.parse(stdout); + if (output.length == 0) { return output; } output.forEach((release: any, index: number) => { output[index] = toCamelCase(release); }); + return output; } @@ -27,15 +28,19 @@ export class HelmReleaseManager { public async installChart(chart: string, values: any, name: string, namespace: string, version: string, pathToKubeconfig: string){ const helm = await helmCli.binaryPath(); const fileName = tempy.file({name: "values.yaml"}); + await fs.promises.writeFile(fileName, yaml.safeDump(values)); + try { let generateName = ""; + if (!name) { generateName = "--generate-name"; name = ""; } const { stdout } = await promiseExec(`"${helm}" install ${name} ${chart} --version ${version} -f ${fileName} --namespace ${namespace} --kubeconfig ${pathToKubeconfig} ${generateName}`).catch((error) => { throw(error.stderr);}); const releaseName = stdout.split("\n")[0].split(" ")[1].trim(); + return { log: stdout, release: { @@ -51,10 +56,12 @@ export class HelmReleaseManager { public async upgradeRelease(name: string, chart: string, values: any, namespace: string, version: string, cluster: Cluster){ const helm = await helmCli.binaryPath(); const fileName = tempy.file({name: "values.yaml"}); + await fs.promises.writeFile(fileName, yaml.safeDump(values)); try { const { stdout } = await promiseExec(`"${helm}" upgrade ${name} ${chart} --version ${version} -f ${fileName} --namespace ${namespace} --kubeconfig ${cluster.getProxyKubeconfigPath()}`).catch((error) => { throw(error.stderr);}); + return { log: stdout, release: this.getRelease(name, namespace, cluster) @@ -68,7 +75,9 @@ export class HelmReleaseManager { const helm = await helmCli.binaryPath(); const { stdout } = await promiseExec(`"${helm}" status ${name} --output json --namespace ${namespace} --kubeconfig ${cluster.getProxyKubeconfigPath()}`).catch((error) => { throw(error.stderr);}); const release = JSON.parse(stdout); + release.resources = await this.getResources(name, namespace, cluster); + return release; } @@ -82,18 +91,21 @@ export class HelmReleaseManager { public async getValues(name: string, namespace: string, pathToKubeconfig: string) { const helm = await helmCli.binaryPath(); const { stdout, } = await promiseExec(`"${helm}" get values ${name} --all --output yaml --namespace ${namespace} --kubeconfig ${pathToKubeconfig}`).catch((error) => { throw(error.stderr);}); + return stdout; } public async getHistory(name: string, namespace: string, pathToKubeconfig: string) { const helm = await helmCli.binaryPath(); const { stdout } = await promiseExec(`"${helm}" history ${name} --output json --namespace ${namespace} --kubeconfig ${pathToKubeconfig}`).catch((error) => { throw(error.stderr);}); + return JSON.parse(stdout); } public async rollback(name: string, namespace: string, revision: number, pathToKubeconfig: string) { const helm = await helmCli.binaryPath(); const { stdout } = await promiseExec(`"${helm}" rollback ${name} ${revision} --namespace ${namespace} --kubeconfig ${pathToKubeconfig}`).catch((error) => { throw(error.stderr);}); + return stdout; } @@ -104,6 +116,7 @@ export class HelmReleaseManager { const { stdout } = await promiseExec(`"${helm}" get manifest ${name} --namespace ${namespace} --kubeconfig ${pathToKubeconfig} | "${kubectl}" get -n ${namespace} --kubeconfig ${pathToKubeconfig} -f - -o=json`).catch(() => { return { stdout: JSON.stringify({items: []})}; }); + return stdout; } } diff --git a/src/main/helm/helm-repo-manager.ts b/src/main/helm/helm-repo-manager.ts index fea000fec2..ae8595dae9 100644 --- a/src/main/helm/helm-repo-manager.ts +++ b/src/main/helm/helm-repo-manager.ts @@ -42,12 +42,14 @@ export class HelmRepoManager extends Singleton { resolveWithFullResponse: true, timeout: 10000, }); + return orderBy(res.body, repo => repo.name); } async init() { helmCli.setLogger(logger); await helmCli.ensureBinary(); + if (!this.initialized) { this.helmEnv = await this.parseHelmEnv(); await this.update(); @@ -62,12 +64,15 @@ export class HelmRepoManager extends Singleton { }); const lines = stdout.split(/\r?\n/); // split by new line feed const env: HelmEnv = {}; + lines.forEach((line: string) => { const [key, value] = line.split("="); + if (key && value) { env[key] = value.replace(/"/g, ""); // strip quotas } }); + return env; } @@ -75,6 +80,7 @@ export class HelmRepoManager extends Singleton { if (!this.initialized) { await this.init(); } + try { const repoConfigFile = this.helmEnv.HELM_REPOSITORY_CONFIG; const { repositories }: HelmRepoConfig = await readFile(repoConfigFile, "utf8") @@ -82,22 +88,27 @@ export class HelmRepoManager extends Singleton { .catch(() => ({ repositories: [] })); + if (!repositories.length) { await this.addRepo({ name: "bitnami", url: "https://charts.bitnami.com/bitnami" }); + return await this.repositories(); } + return repositories.map(repo => ({ ...repo, cacheFilePath: `${this.helmEnv.HELM_REPOSITORY_CACHE}/${repo.name}-index.yaml` })); } catch (error) { logger.error(`[HELM]: repositories listing error "${error}"`); + return []; } } public async repository(name: string) { const repositories = await this.repositories(); + return repositories.find(repo => repo.name == name); } @@ -106,6 +117,7 @@ export class HelmRepoManager extends Singleton { const { stdout } = await promiseExec(`"${helm}" repo update`).catch((error) => { return { stdout: error.stdout }; }); + return stdout; } @@ -115,6 +127,7 @@ export class HelmRepoManager extends Singleton { const { stdout } = await promiseExec(`"${helm}" repo add ${name} ${url}`).catch((error) => { throw(error.stderr); }); + return stdout; } @@ -124,6 +137,7 @@ export class HelmRepoManager extends Singleton { const { stdout } = await promiseExec(`"${helm}" repo remove ${name}`).catch((error) => { throw(error.stderr); }); + return stdout; } } diff --git a/src/main/helm/helm-service.ts b/src/main/helm/helm-service.ts index 0ccec256ed..1918268075 100644 --- a/src/main/helm/helm-service.ts +++ b/src/main/helm/helm-service.ts @@ -11,18 +11,23 @@ class HelmService { public async listCharts() { const charts: any = {}; + await repoManager.init(); const repositories = await repoManager.repositories(); + for (const repo of repositories) { charts[repo.name] = {}; const manager = new HelmChartManager(repo); let entries = await manager.charts(); + entries = this.excludeDeprecated(entries); + for (const key in entries) { entries[key] = entries[key][0]; } charts[repo.name] = entries; } + return charts; } @@ -34,50 +39,60 @@ class HelmService { const repo = await repoManager.repository(repoName); const chartManager = new HelmChartManager(repo); const chart = await chartManager.chart(chartName); + result.readme = await chartManager.getReadme(chartName, version); result.versions = chart; + return result; } public async getChartValues(repoName: string, chartName: string, version = "") { const repo = await repoManager.repository(repoName); const chartManager = new HelmChartManager(repo); + return chartManager.getValues(chartName, version); } public async listReleases(cluster: Cluster, namespace: string = null) { await repoManager.init(); + return await releaseManager.listReleases(cluster.getProxyKubeconfigPath(), namespace); } public async getRelease(cluster: Cluster, releaseName: string, namespace: string) { logger.debug("Fetch release"); + return await releaseManager.getRelease(releaseName, namespace, cluster); } public async getReleaseValues(cluster: Cluster, releaseName: string, namespace: string) { logger.debug("Fetch release values"); + return await releaseManager.getValues(releaseName, namespace, cluster.getProxyKubeconfigPath()); } public async getReleaseHistory(cluster: Cluster, releaseName: string, namespace: string) { logger.debug("Fetch release history"); + return await releaseManager.getHistory(releaseName, namespace, cluster.getProxyKubeconfigPath()); } public async deleteRelease(cluster: Cluster, releaseName: string, namespace: string) { logger.debug("Delete release"); + return await releaseManager.deleteRelease(releaseName, namespace, cluster.getProxyKubeconfigPath()); } public async updateRelease(cluster: Cluster, releaseName: string, namespace: string, data: { chart: string; values: {}; version: string }) { logger.debug("Upgrade release"); + return await releaseManager.upgradeRelease(releaseName, data.chart, data.values, namespace, data.version, cluster); } public async rollback(cluster: Cluster, releaseName: string, namespace: string, revision: number) { logger.debug("Rollback release"); const output = await releaseManager.rollback(releaseName, namespace, revision, cluster.getProxyKubeconfigPath()); + return { message: output }; } @@ -87,9 +102,11 @@ class HelmService { if (Array.isArray(entry)) { return entry[0]["deprecated"] != true; } + return entry["deprecated"] != true; }); } + return entries; } diff --git a/src/main/index.ts b/src/main/index.ts index 315526862c..cad2235743 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -34,11 +34,13 @@ let clusterManager: ClusterManager; let windowManager: WindowManager; app.setName(appName); + if (!process.env.CICD) { app.setPath("userData", workingDir); } mangleProxyEnv(); + if (app.commandLine.getSwitchValue("proxy-server") !== "") { process.env.HTTPS_PROXY = app.commandLine.getSwitchValue("proxy-server"); } @@ -48,6 +50,7 @@ app.on("ready", async () => { await shellSync(); const updater = new AppUpdater(); + updater.start(); registerFileProtocol("static", __static); @@ -110,6 +113,7 @@ app.on("ready", async () => { app.on("activate", (event, hasVisibleWindows) => { logger.info("APP:ACTIVATE", { hasVisibleWindows }); + if (!hasVisibleWindows) { windowManager.initMainWindow(); } @@ -121,6 +125,7 @@ app.on("will-quit", (event) => { 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 }); diff --git a/src/main/kube-auth-proxy.ts b/src/main/kube-auth-proxy.ts index 791b242104..589fe8fa16 100644 --- a/src/main/kube-auth-proxy.ts +++ b/src/main/kube-auth-proxy.ts @@ -45,6 +45,7 @@ export class KubeAuthProxy { "--accept-hosts", this.acceptHosts, "--reject-paths", "^[^/]" ]; + if (process.env.DEBUG_PROXY === "true") { args.push("-v", "9"); } @@ -62,6 +63,7 @@ export class KubeAuthProxy { this.proxyProcess.stdout.on("data", (data) => { let logItem = data.toString(); + if (logItem.startsWith("Starting to serve on")) { logItem = "Authentication proxy started\n"; } @@ -80,19 +82,23 @@ export class KubeAuthProxy { const error = data.split("http: proxy error:").slice(1).join("").trim(); let errorMsg = error; const jsonError = error.split("Response: ")[1]; + if (jsonError) { try { const parsedError = JSON.parse(jsonError); + errorMsg = parsedError.error_description || parsedError.error || jsonError; } catch (_) { errorMsg = jsonError.trim(); } } + return errorMsg; } protected async sendIpcLogMessage(res: KubeAuthProxyLog) { const channel = `kube-auth:${this.cluster.id}`; + logger.info(`[KUBE-AUTH]: out-channel "${channel}"`, { ...res, meta: this.cluster.getMeta() }); broadcastMessage(channel, res); } diff --git a/src/main/kubeconfig-manager.ts b/src/main/kubeconfig-manager.ts index b0251264e7..bd1b32c1f5 100644 --- a/src/main/kubeconfig-manager.ts +++ b/src/main/kubeconfig-manager.ts @@ -15,7 +15,9 @@ export class KubeconfigManager { static async create(cluster: Cluster, contextHandler: ContextHandler, port: number) { const kcm = new KubeconfigManager(cluster, contextHandler, port); + await kcm.init(); + return kcm; } @@ -66,13 +68,14 @@ export class KubeconfigManager { } ] }; - // write const configYaml = dumpConfigYaml(proxyConfig); + fs.ensureDir(path.dirname(tempFile)); fs.writeFileSync(tempFile, configYaml, { mode: 0o600 }); this.tempFile = tempFile; logger.debug(`Created temp kubeconfig "${contextName}" at "${tempFile}": \n${configYaml}`); + return tempFile; } diff --git a/src/main/kubectl.ts b/src/main/kubectl.ts index ee059bde5b..0a96ee354b 100644 --- a/src/main/kubectl.ts +++ b/src/main/kubectl.ts @@ -27,12 +27,10 @@ const kubectlMap: Map = new Map([ ["1.18", "1.18.8"], ["1.19", "1.19.0"] ]); - const packageMirrors: Map = new Map([ ["default", "https://storage.googleapis.com/kubernetes-release/release"], ["china", "https://mirror.azure.cn/kubernetes/kubectl"] ]); - let bundledPath: string; const initScriptVersionString = "# lens-initscript v3\n"; @@ -41,6 +39,7 @@ export function bundledKubectlPath(): string { if (isDevelopment || isTestEnv) { const platformName = isWindows ? "windows" : process.platform; + bundledPath = path.join(process.cwd(), "binaries", "client", platformName, process.arch, "kubectl"); } else { bundledPath = path.join(process.resourcesPath, process.arch, "kubectl"); @@ -71,12 +70,14 @@ export class Kubectl { // Returns the single bundled Kubectl instance public static bundled() { if (!Kubectl.bundledInstance) Kubectl.bundledInstance = new Kubectl(Kubectl.bundledKubectlVersion); + return Kubectl.bundledInstance; } constructor(clusterVersion: string) { const versionParts = /^v?(\d+\.\d+)(.*)/.exec(clusterVersion); const minorVersion = versionParts[1]; + /* minorVersion is the first two digits of kube server version if the version map includes that, use that version, if not, fallback to the exact x.y.z of kube version */ if (kubectlMap.has(minorVersion)) { @@ -134,18 +135,22 @@ export class Kubectl { // return binary name if bundled path is not functional if (!await this.checkBinary(this.getBundledPath(), false)) { Kubectl.invalidBundle = true; + return path.basename(this.getBundledPath()); } try { if (!await this.ensureKubectl()) { logger.error("Failed to ensure kubectl, fallback to the bundled version"); + return this.getBundledPath(); } + return this.path; } catch (err) { logger.error("Failed to ensure kubectl, fallback to the bundled version"); logger.error(err); + return this.getBundledPath(); } } @@ -154,28 +159,35 @@ export class Kubectl { try { await this.ensureKubectl(); await this.writeInitScripts(); + return this.dirname; } catch (err) { logger.error(err); + return ""; } } public async checkBinary(path: string, checkVersion = true) { const exists = await pathExists(path); + if (exists) { try { const { stdout } = await promiseExec(`"${path}" version --client=true -o json`); const output = JSON.parse(stdout); + if (!checkVersion) { return true; } let version: string = output.clientVersion.gitVersion; + if (version[0] === "v") { version = version.slice(1); } + if (version === this.kubectlVersion) { logger.debug(`Local kubectl is version ${this.kubectlVersion}`); + return true; } logger.error(`Local kubectl is version ${version}, expected ${this.kubectlVersion}, unlinking`); @@ -184,6 +196,7 @@ export class Kubectl { } await fs.promises.unlink(this.path); } + return false; } @@ -191,13 +204,16 @@ export class Kubectl { if (this.kubectlVersion === Kubectl.bundledKubectlVersion) { try { const exist = await pathExists(this.path); + if (!exist) { await fs.promises.copyFile(this.getBundledPath(), this.path); await fs.promises.chmod(this.path, 0o755); } + return true; } catch (err) { logger.error(`Could not copy the bundled kubectl to app-data: ${err}`); + return false; } } else { @@ -209,35 +225,44 @@ export class Kubectl { if (userStore.preferences?.downloadKubectlBinaries === false) { return true; } + if (Kubectl.invalidBundle) { logger.error(`Detected invalid bundle binary, returning ...`); + return false; } await ensureDir(this.dirname, 0o755); + return lockFile.lock(this.dirname).then(async (release) => { logger.debug(`Acquired a lock for ${this.kubectlVersion}`); const bundled = await this.checkBundled(); let isValid = await this.checkBinary(this.path, !bundled); + if (!isValid && !bundled) { await this.downloadKubectl().catch((error) => { logger.error(error); logger.debug(`Releasing lock for ${this.kubectlVersion}`); release(); + return false; }); isValid = !await this.checkBinary(this.path, false); } + if (!isValid) { logger.debug(`Releasing lock for ${this.kubectlVersion}`); release(); + return false; } logger.debug(`Releasing lock for ${this.kubectlVersion}`); release(); + return true; }).catch((e) => { logger.error(`Failed to get a lock for ${this.kubectlVersion}`); logger.error(e); + return false; }); } @@ -246,12 +271,14 @@ export class Kubectl { await ensureDir(path.dirname(this.path), 0o755); logger.info(`Downloading kubectl ${this.kubectlVersion} from ${this.url} to ${this.path}`); + return new Promise((resolve, reject) => { const stream = customRequest({ url: this.url, gzip: true, }); const file = fs.createWriteStream(this.path); + stream.on("complete", () => { logger.debug("kubectl binary download finished"); file.end(); @@ -279,8 +306,8 @@ export class Kubectl { const helmPath = helmCli.getBinaryDir(); const fsPromises = fs.promises; const bashScriptPath = path.join(this.dirname, ".bash_set_path"); - let bashScript = `${initScriptVersionString}`; + bashScript += "tempkubeconfig=\"$KUBECONFIG\"\n"; bashScript += "test -f \"/etc/profile\" && . \"/etc/profile\"\n"; bashScript += "if test -f \"$HOME/.bash_profile\"; then\n"; @@ -302,7 +329,6 @@ export class Kubectl { await fsPromises.writeFile(bashScriptPath, bashScript.toString(), { mode: 0o644 }); const zshScriptPath = path.join(this.dirname, ".zlogin"); - let zshScript = `${initScriptVersionString}`; zshScript += "tempkubeconfig=\"$KUBECONFIG\"\n"; @@ -335,9 +361,11 @@ export class Kubectl { protected getDownloadMirror() { const mirror = packageMirrors.get(userStore.preferences?.downloadMirror); + if (mirror) { return mirror; } + return packageMirrors.get("default"); // MacOS packages are only available from default } } diff --git a/src/main/kubectl_spec.ts b/src/main/kubectl_spec.ts index 9d5a5d1e1d..50d0b11374 100644 --- a/src/main/kubectl_spec.ts +++ b/src/main/kubectl_spec.ts @@ -8,12 +8,14 @@ jest.mock("../common/user-store"); describe("kubectlVersion", () => { it("returns bundled version if exactly same version used", async () => { const kubectl = new Kubectl(Kubectl.bundled().kubectlVersion); + expect(kubectl.kubectlVersion).toBe(Kubectl.bundled().kubectlVersion); }); it("returns bundled version if same major.minor version is used", async () => { const { bundledKubectlVersion } = packageInfo.config; const kubectl = new Kubectl(bundledKubectlVersion); + expect(kubectl.kubectlVersion).toBe(Kubectl.bundled().kubectlVersion); }); }); @@ -24,19 +26,23 @@ describe("getPath()", () => { const kubectl = new Kubectl(bundledKubectlVersion); const kubectlPath = await kubectl.getPath(); let binaryName = "kubectl"; + if (isWindows) { binaryName += ".exe"; } const expectedPath = path.join(Kubectl.kubectlDir, Kubectl.bundledKubectlVersion, binaryName); + expect(kubectlPath).toBe(expectedPath); }); it("returns plain binary name if bundled kubectl is non-functional", async () => { const { bundledKubectlVersion } = packageInfo.config; const kubectl = new Kubectl(bundledKubectlVersion); + jest.spyOn(kubectl, "getBundledPath").mockReturnValue("/invalid/path/kubectl"); const kubectlPath = await kubectl.getPath(); let binaryName = "kubectl"; + if (isWindows) { binaryName += ".exe"; } diff --git a/src/main/lens-binary.ts b/src/main/lens-binary.ts index bdd4c7ee62..3cf5a5fce7 100644 --- a/src/main/lens-binary.ts +++ b/src/main/lens-binary.ts @@ -31,6 +31,7 @@ export class LensBinary { constructor(opts: LensBinaryOpts) { const baseDir = opts.baseDir; + this.originalBinaryName = opts.originalBinaryName; this.binaryName = opts.newBinaryName || opts.originalBinaryName; this.binaryVersion = opts.version; @@ -50,11 +51,13 @@ export class LensBinary { this.arch = arch; this.platformName = isWindows ? "windows" : process.platform; this.dirname = path.normalize(path.join(baseDir, this.binaryName)); + if (isWindows) { this.binaryName = `${this.binaryName}.exe`; this.originalBinaryName = `${this.originalBinaryName}.exe`; } const tarName = this.getTarName(); + if (tarName) { this.tarPath = path.join(this.dirname, tarName); } @@ -70,6 +73,7 @@ export class LensBinary { public async binaryPath() { await this.ensureBinary(); + return this.getBinaryPath(); } @@ -96,20 +100,24 @@ export class LensBinary { public async binDir() { try { await this.ensureBinary(); + return this.dirname; } catch (err) { this.logger.error(err); + return ""; } } protected async checkBinary() { const exists = await pathExists(this.getBinaryPath()); + return exists; } public async ensureBinary() { const isValid = await this.checkBinary(); + if (!isValid) { await this.downloadBinary().catch((error) => { this.logger.error(error); @@ -148,6 +156,7 @@ export class LensBinary { protected async downloadBinary() { const binaryPath = this.tarPath || this.getBinaryPath(); + await ensureDir(this.getBinaryDir(), 0o755); const file = fs.createWriteStream(binaryPath); @@ -159,7 +168,6 @@ export class LensBinary { gzip: true, ...this.requestOpts }; - const stream = request(requestOpts); stream.on("complete", () => { @@ -174,6 +182,7 @@ export class LensBinary { }); throw(error); }); + return new Promise((resolve, reject) => { file.on("close", () => { this.logger.debug(`${this.originalBinaryName} binary download closed`); diff --git a/src/main/lens-proxy.ts b/src/main/lens-proxy.ts index 3f9433026c..5770429a7e 100644 --- a/src/main/lens-proxy.ts +++ b/src/main/lens-proxy.ts @@ -30,6 +30,7 @@ export class LensProxy { listen(port = this.port): this { this.proxyServer = this.buildCustomProxy().listen(port); logger.info(`LensProxy server has started at ${this.origin}`); + return this; } @@ -49,6 +50,7 @@ export class LensProxy { }, (req: http.IncomingMessage, res: http.ServerResponse) => { this.handleRequest(proxy, req, res); }); + spdyProxy.on("upgrade", (req: http.IncomingMessage, socket: net.Socket, head: Buffer) => { if (req.url.startsWith(`${apiPrefix}?`)) { this.handleWsUpgrade(req, socket, head); @@ -59,22 +61,27 @@ export class LensProxy { spdyProxy.on("error", (err) => { logger.error("proxy error", err); }); + return spdyProxy; } protected async handleProxyUpgrade(proxy: httpProxy, req: http.IncomingMessage, socket: net.Socket, head: Buffer) { const cluster = this.clusterManager.getClusterForRequest(req); + if (cluster) { const proxyUrl = await cluster.contextHandler.resolveAuthProxyUrl() + req.url.replace(apiKubePrefix, ""); const apiUrl = url.parse(cluster.apiUrl); const pUrl = url.parse(proxyUrl); const connectOpts = { port: parseInt(pUrl.port), host: pUrl.hostname }; const proxySocket = new net.Socket(); + proxySocket.connect(connectOpts, () => { proxySocket.write(`${req.method} ${pUrl.path} HTTP/1.1\r\n`); proxySocket.write(`Host: ${apiUrl.host}\r\n`); + for (let i = 0; i < req.rawHeaders.length; i += 2) { const key = req.rawHeaders[i]; + if (key !== "Host" && key !== "Authorization") { proxySocket.write(`${req.rawHeaders[i]}: ${req.rawHeaders[i+1]}\r\n`); } @@ -112,16 +119,20 @@ export class LensProxy { protected createProxy(): httpProxy { const proxy = httpProxy.createProxyServer(); + proxy.on("error", (error, req, res, target) => { if (this.closed) { return; } + if (target) { logger.debug(`Failed proxy to target: ${JSON.stringify(target, null, 2)}`); + if (req.method === "GET" && (!res.statusCode || res.statusCode >= 500)) { const reqId = this.getRequestId(req); const retryCount = this.retryCounters.get(reqId) || 0; const timeoutMs = retryCount * 250; + if (retryCount < 20) { logger.debug(`Retrying proxy request to url: ${reqId}`); setTimeout(() => { @@ -131,6 +142,7 @@ export class LensProxy { } } } + try { res.writeHead(500).end("Oops, something went wrong."); } catch (e) { @@ -143,9 +155,11 @@ export class LensProxy { protected createWsListener(): WebSocket.Server { const ws = new WebSocket.Server({ noServer: true }); + return ws.on("connection", ((socket: WebSocket, req: http.IncomingMessage) => { const cluster = this.clusterManager.getClusterForRequest(req); const nodeParam = url.parse(req.url, true).query["node"]?.toString(); + openShell(socket, cluster, nodeParam); })); } @@ -155,6 +169,7 @@ export class LensProxy { delete req.headers.authorization; req.url = req.url.replace(apiKubePrefix, ""); const isWatchRequest = req.url.includes("watch="); + return await contextHandler.getApiTarget(isWatchRequest); } } @@ -165,11 +180,14 @@ export class LensProxy { protected async handleRequest(proxy: httpProxy, req: http.IncomingMessage, res: http.ServerResponse) { const cluster = this.clusterManager.getClusterForRequest(req); + if (cluster) { const proxyTarget = await this.getProxyTarget(req, cluster.contextHandler); + if (proxyTarget) { // allow to fetch apis in "clusterId.localhost:port" from "localhost:port" res.setHeader("Access-Control-Allow-Origin", this.origin); + return proxy.web(req, res, proxyTarget); } } @@ -178,6 +196,7 @@ export class LensProxy { protected async handleWsUpgrade(req: http.IncomingMessage, socket: net.Socket, head: Buffer) { const wsServer = this.createWsListener(); + wsServer.handleUpgrade(req, socket, head, (con) => { wsServer.emit("connection", con, req); }); diff --git a/src/main/logger.ts b/src/main/logger.ts index 81d61e8002..0ddc7bb1f7 100644 --- a/src/main/logger.ts +++ b/src/main/logger.ts @@ -3,12 +3,10 @@ import winston from "winston"; import { isDebugging } from "../common/vars"; const logLevel = process.env.LOG_LEVEL ? process.env.LOG_LEVEL : isDebugging ? "debug" : "info"; - const consoleOptions: winston.transports.ConsoleTransportOptions = { handleExceptions: false, level: logLevel, }; - const fileOptions: winston.transports.FileTransportOptions = { handleExceptions: false, level: logLevel, @@ -18,7 +16,6 @@ const fileOptions: winston.transports.FileTransportOptions = { maxFiles: 16, tailable: true, }; - const logger = winston.createLogger({ format: winston.format.combine( winston.format.colorize(), diff --git a/src/main/menu.ts b/src/main/menu.ts index feccbafa21..2cddbb1b01 100644 --- a/src/main/menu.ts +++ b/src/main/menu.ts @@ -27,6 +27,7 @@ export function showAbout(browserWindow: BrowserWindow) { `Node: ${process.versions.node}`, `Copyright 2020 Mirantis, Inc.`, ]; + dialog.showMessageBoxSync(browserWindow, { title: `${isWindows ? " ".repeat(2) : ""}${appName}`, type: "info", @@ -39,6 +40,7 @@ export function showAbout(browserWindow: BrowserWindow) { export function buildMenu(windowManager: WindowManager) { function ignoreOnMac(menuItems: MenuItemConstructorOptions[]) { if (isMac) return []; + return menuItems; } @@ -48,6 +50,7 @@ export function buildMenu(windowManager: WindowManager) { item.enabled = false; }); } + return menuItems; } @@ -96,7 +99,6 @@ export function buildMenu(windowManager: WindowManager) { } ] }; - const fileMenu: MenuItemConstructorOptions = { label: "File", submenu: [ @@ -154,7 +156,6 @@ export function buildMenu(windowManager: WindowManager) { ]) ] }; - const editMenu: MenuItemConstructorOptions = { label: "Edit", submenu: [ @@ -169,7 +170,6 @@ export function buildMenu(windowManager: WindowManager) { { role: "selectAll" }, ] }; - const viewMenu: MenuItemConstructorOptions = { label: "View", submenu: [ @@ -203,7 +203,6 @@ export function buildMenu(windowManager: WindowManager) { { role: "togglefullscreen" } ] }; - const helpMenu: MenuItemConstructorOptions = { role: "help", submenu: [ @@ -235,7 +234,6 @@ export function buildMenu(windowManager: WindowManager) { ]) ] }; - // Prepare menu items order const appMenu: Record = { mac: macAppMenu, @@ -249,6 +247,7 @@ export function buildMenu(windowManager: WindowManager) { menuRegistry.getItems().forEach(({ parentId, ...menuItem }) => { try { const topMenu = appMenu[parentId as MenuTopId].submenu as MenuItemConstructorOptions[]; + topMenu.push(menuItem); } catch (err) { logger.error(`[MENU]: can't register menu item, parentId=${parentId}`, { menuItem }); @@ -260,6 +259,7 @@ export function buildMenu(windowManager: WindowManager) { } const menu = Menu.buildFromTemplate(Object.values(appMenu)); + Menu.setApplicationMenu(menu); if (isTestEnv) { @@ -273,6 +273,7 @@ export function buildMenu(windowManager: WindowManager) { for (const name of names) { parentLabels.push(name); menuItem = menu?.items?.find(item => item.label === name); + if (!menuItem) { break; } @@ -280,14 +281,18 @@ export function buildMenu(windowManager: WindowManager) { } const menuPath: string = parentLabels.join(" -> "); + if (!menuItem) { logger.info(`[MENU:test-menu-item-click] Cannot find menu item ${menuPath}`); + return; } const { enabled, visible, click } = menuItem; + if (enabled === false || visible === false || typeof click !== "function") { logger.info(`[MENU:test-menu-item-click] Menu item ${menuPath} not clickable`); + return; } diff --git a/src/main/node-shell-session.ts b/src/main/node-shell-session.ts index ea85b8ac2a..b67e776725 100644 --- a/src/main/node-shell-session.ts +++ b/src/main/node-shell-session.ts @@ -23,6 +23,7 @@ export class NodeShellSession extends ShellSession { public async open() { const shell = await this.kubectl.getPath(); let args = []; + if (this.createNodeShellPod(this.podId, this.nodeName)) { await this.waitForRunningPod(this.podId).catch(() => { this.exit(1001); @@ -31,6 +32,7 @@ export class NodeShellSession extends ShellSession { args = ["exec", "-i", "-t", "-n", "kube-system", this.podId, "--", "sh", "-c", "((clear && bash) || (clear && ash) || (clear && sh))"]; const shellEnv = await this.getCachedShellEnv(); + this.shellProcess = pty.spawn(shell, args, { cols: 80, cwd: this.cwd() || shellEnv["HOME"], @@ -85,10 +87,13 @@ export class NodeShellSession extends ShellSession { } } } as k8s.V1Pod; + await k8sApi.createNamespacedPod("kube-system", pod).catch((error) => { logger.error(error); + return false; }); + return true; } @@ -98,6 +103,7 @@ export class NodeShellSession extends ShellSession { } this.kc = new k8s.KubeConfig(); this.kc.loadFromFile(this.kubeconfigPath); + return this.kc; } @@ -105,7 +111,6 @@ export class NodeShellSession extends ShellSession { return new Promise(async (resolve, reject) => { const kc = this.getKubeConfig(); const watch = new k8s.Watch(kc); - const req = await watch.watch(`/api/v1/namespaces/kube-system/pods`, {}, // callback is called for each received object. (type, obj) => { @@ -119,6 +124,7 @@ export class NodeShellSession extends ShellSession { reject(false); } ); + setTimeout(() => { req.abort(); reject(false); @@ -129,17 +135,20 @@ export class NodeShellSession extends ShellSession { protected deleteNodeShellPod() { const kc = this.getKubeConfig(); const k8sApi = kc.makeApiClient(k8s.CoreV1Api); + k8sApi.deleteNamespacedPod(this.podId, "kube-system"); } } export async function openShell(socket: WebSocket, cluster: Cluster, nodeName?: string): Promise { let shell: ShellSession; + if (nodeName) { shell = new NodeShellSession(socket, cluster, nodeName); } else { shell = new ShellSession(socket, cluster); } shell.open(); + return shell; } diff --git a/src/main/port.ts b/src/main/port.ts index 6ba8f71695..cd4c5701e8 100644 --- a/src/main/port.ts +++ b/src/main/port.ts @@ -5,11 +5,14 @@ import logger from "./logger"; export async function getFreePort(): Promise { logger.debug("Lookup new free port.."); + return new Promise((resolve, reject) => { const server = net.createServer(); + server.unref(); server.on("listening", () => { const port = (server.address() as AddressInfo).port; + server.close(() => resolve(port)); logger.debug(`New port found: ${port}`); }); diff --git a/src/main/port_spec.ts b/src/main/port_spec.ts index 279bf5951e..43c2326b92 100644 --- a/src/main/port_spec.ts +++ b/src/main/port_spec.ts @@ -9,10 +9,12 @@ jest.mock("net", () => { return new class MockServer extends EventEmitter { listen = jest.fn(() => { this.emit("listening"); + return this; }); address = () => { newPort = Math.round(Math.random() * 10000); + return { port: newPort }; diff --git a/src/main/prometheus/helm.ts b/src/main/prometheus/helm.ts index 56d739c630..438cc87a64 100644 --- a/src/main/prometheus/helm.ts +++ b/src/main/prometheus/helm.ts @@ -10,9 +10,11 @@ export class PrometheusHelm extends PrometheusLens { public async getPrometheusService(client: CoreV1Api): Promise { const labelSelector = "app=prometheus,component=server,heritage=Helm"; + try { const serviceList = await client.listServiceForAllNamespaces(false, "", null, labelSelector); const service = serviceList.body.items[0]; + if (!service) return; return { @@ -23,6 +25,7 @@ export class PrometheusHelm extends PrometheusLens { }; } catch(error) { logger.warn(`PrometheusHelm: failed to list services: ${error.toString()}`); + return; } } diff --git a/src/main/prometheus/lens.ts b/src/main/prometheus/lens.ts index 725771a033..b829285f33 100644 --- a/src/main/prometheus/lens.ts +++ b/src/main/prometheus/lens.ts @@ -11,6 +11,7 @@ export class PrometheusLens implements PrometheusProvider { try { const resp = await client.readNamespacedService("prometheus", "lens-metrics"); const service = resp.body; + return { id: this.id, namespace: service.metadata.namespace, @@ -72,6 +73,7 @@ export class PrometheusLens implements PrometheusProvider { case "ingress": const bytesSent = (ingress: string, statuses: string) => `sum(rate(nginx_ingress_controller_bytes_sent_sum{ingress="${ingress}", status=~"${statuses}"}[${this.rateAccuracy}])) by (ingress)`; + return { bytesSentSuccess: bytesSent(opts.igress, "^2\\\\d*"), bytesSentFailure: bytesSent(opts.ingres, "^5\\\\d*"), diff --git a/src/main/prometheus/operator.ts b/src/main/prometheus/operator.ts index 6880d4c27c..ee4c1e63bf 100644 --- a/src/main/prometheus/operator.ts +++ b/src/main/prometheus/operator.ts @@ -10,9 +10,11 @@ export class PrometheusOperator implements PrometheusProvider { public async getPrometheusService(client: CoreV1Api): Promise { try { let service: V1Service; + for (const labelSelector of ["operated-prometheus=true", "self-monitor=true"]) { if (!service) { const serviceList = await client.listServiceForAllNamespaces(null, null, null, labelSelector); + service = serviceList.body.items[0]; } } @@ -26,6 +28,7 @@ export class PrometheusOperator implements PrometheusProvider { }; } catch(error) { logger.warn(`PrometheusOperator: failed to list services: ${error.toString()}`); + return; } } @@ -80,6 +83,7 @@ export class PrometheusOperator implements PrometheusProvider { case "ingress": const bytesSent = (ingress: string, statuses: string) => `sum(rate(nginx_ingress_controller_bytes_sent_sum{ingress="${ingress}", status=~"${statuses}"}[${this.rateAccuracy}])) by (ingress)`; + return { bytesSentSuccess: bytesSent(opts.igress, "^2\\\\d*"), bytesSentFailure: bytesSent(opts.ingres, "^5\\\\d*"), diff --git a/src/main/prometheus/provider-registry.ts b/src/main/prometheus/provider-registry.ts index 641b1b8cf2..c649560c85 100644 --- a/src/main/prometheus/provider-registry.ts +++ b/src/main/prometheus/provider-registry.ts @@ -77,6 +77,7 @@ export class PrometheusProviderRegistry { if (!this.prometheusProviders[type]) { throw "Unknown Prometheus provider"; } + return this.prometheusProviders[type]; } diff --git a/src/main/prometheus/stacklight.ts b/src/main/prometheus/stacklight.ts index 84892214a1..5cb2773ca5 100644 --- a/src/main/prometheus/stacklight.ts +++ b/src/main/prometheus/stacklight.ts @@ -11,6 +11,7 @@ export class PrometheusStacklight implements PrometheusProvider { try { const resp = await client.readNamespacedService("prometheus-server", "stacklight"); const service = resp.body; + return { id: this.id, namespace: service.metadata.namespace, @@ -72,6 +73,7 @@ export class PrometheusStacklight implements PrometheusProvider { case "ingress": const bytesSent = (ingress: string, statuses: string) => `sum(rate(nginx_ingress_controller_bytes_sent_sum{ingress="${ingress}", status=~"${statuses}"}[${this.rateAccuracy}])) by (ingress)`; + return { bytesSentSuccess: bytesSent(opts.igress, "^2\\\\d*"), bytesSentFailure: bytesSent(opts.ingres, "^5\\\\d*"), diff --git a/src/main/resource-applier.ts b/src/main/resource-applier.ts index c919185ae9..d4070f2378 100644 --- a/src/main/resource-applier.ts +++ b/src/main/resource-applier.ts @@ -16,19 +16,24 @@ export class ResourceApplier { async apply(resource: KubernetesObject | any): Promise { resource = this.sanitizeObject(resource); appEventBus.emit({name: "resource", action: "apply"}); + return await this.kubectlApply(yaml.safeDump(resource)); } protected async kubectlApply(content: string): Promise { const { kubeCtl } = this.cluster; const kubectlPath = await kubeCtl.getPath(); + return new Promise((resolve, reject) => { const fileName = tempy.file({ name: "resource.yaml" }); + fs.writeFileSync(fileName, content); const cmd = `"${kubectlPath}" apply --kubeconfig "${this.cluster.getProxyKubeconfigPath()}" -o json -f "${fileName}"`; + logger.debug(`shooting manifests with: ${cmd}`); const execEnv: NodeJS.ProcessEnv = Object.assign({}, process.env); const httpsProxy = this.cluster.preferences?.httpsProxy; + if (httpsProxy) { execEnv["HTTPS_PROXY"] = httpsProxy; } @@ -37,6 +42,7 @@ export class ResourceApplier { if (stderr != "") { fs.unlinkSync(fileName); reject(stderr); + return; } fs.unlinkSync(fileName); @@ -48,20 +54,25 @@ export class ResourceApplier { public async kubectlApplyAll(resources: string[]): Promise { const { kubeCtl } = this.cluster; const kubectlPath = await kubeCtl.getPath(); + return new Promise((resolve, reject) => { const tmpDir = tempy.directory(); + // Dump each resource into tmpDir resources.forEach((resource, index) => { fs.writeFileSync(path.join(tmpDir, `${index}.yaml`), resource); }); const cmd = `"${kubectlPath}" apply --kubeconfig "${this.cluster.getProxyKubeconfigPath()}" -o json -f "${tmpDir}"`; + console.log("shooting manifests with:", cmd); exec(cmd, (error, stdout, stderr) => { if (error) { reject(`Error applying manifests:${error}`); } + if (stderr != "") { reject(stderr); + return; } resolve(stdout); @@ -74,9 +85,11 @@ export class ResourceApplier { delete resource.status; delete resource.metadata?.resourceVersion; const annotations = resource.metadata?.annotations; + if (annotations) { delete annotations["kubectl.kubernetes.io/last-applied-configuration"]; } + return resource; } } diff --git a/src/main/router.ts b/src/main/router.ts index f8aa76043d..896893a592 100644 --- a/src/main/router.ts +++ b/src/main/router.ts @@ -52,11 +52,15 @@ export class Router { const method = req.method.toLowerCase(); const matchingRoute = this.router.route(method, path); const routeFound = !matchingRoute.isBoom; + if (routeFound) { const request = await this.getRequest({ req, res, cluster, url, params: matchingRoute.params }); + await matchingRoute.route(request); + return true; } + return false; } @@ -66,6 +70,7 @@ export class Router { parse: true, output: "data", }); + return { cluster, path: url.pathname, @@ -92,23 +97,29 @@ export class Router { woff2: "font/woff2", ttf: "font/ttf" }; + return mimeTypes[path.extname(filename).slice(1)] || "text/plain"; } async handleStaticFile(filePath: string, res: http.ServerResponse, req: http.IncomingMessage, retryCount = 0) { const asset = path.join(__static, filePath); + try { const filename = path.basename(req.url); // redirect requests to [appName].js, [appName].html /sockjs-node/ to webpack-dev-server (for hot-reload support) const toWebpackDevServer = filename.includes(appName) || filename.includes("hot-update") || req.url.includes("sockjs-node"); + if (isDevelopment && toWebpackDevServer) { const redirectLocation = `http://localhost:${webpackDevServerPort}${req.url}`; + res.statusCode = 307; res.setHeader("Location", redirectLocation); res.end(); + return; } const data = await readFile(asset); + res.setHeader("Content-Type", this.getMimeType(asset)); res.write(data); res.end(); @@ -117,6 +128,7 @@ export class Router { logger.error("handleStaticFile:", err.toString()); res.statusCode = 404; res.end(); + return; } this.handleStaticFile(`${publicPath}/${appName}.html`, res, req, Math.max(retryCount, 0) + 1); diff --git a/src/main/routes/helm-route.ts b/src/main/routes/helm-route.ts index b9a6cf513b..4d1cae8bc2 100644 --- a/src/main/routes/helm-route.ts +++ b/src/main/routes/helm-route.ts @@ -7,13 +7,16 @@ class HelmApiRoute extends LensApi { public async listCharts(request: LensApiRequest) { const { response } = request; const charts = await helmService.listCharts(); + this.respondJson(response, charts); } public async getChart(request: LensApiRequest) { const { params, query, response } = request; + try { const chart = await helmService.getChart(params.repo, params.chart, query.get("version")); + this.respondJson(response, chart); } catch (error) { this.respondText(response, error, 422); @@ -22,8 +25,10 @@ class HelmApiRoute extends LensApi { public async getChartValues(request: LensApiRequest) { const { params, query, response } = request; + try { const values = await helmService.getChartValues(params.repo, params.chart, query.get("version")); + this.respondJson(response, values); } catch (error) { this.respondText(response, error, 422); @@ -32,8 +37,10 @@ class HelmApiRoute extends LensApi { public async installChart(request: LensApiRequest) { const { payload, cluster, response } = request; + try { const result = await helmService.installChart(cluster, payload); + this.respondJson(response, result, 201); } catch (error) { logger.debug(error); @@ -43,8 +50,10 @@ class HelmApiRoute extends LensApi { public async updateRelease(request: LensApiRequest) { const { cluster, params, payload, response } = request; + try { const result = await helmService.updateRelease(cluster, params.release, params.namespace, payload ); + this.respondJson(response, result); } catch (error) { logger.debug(error); @@ -54,8 +63,10 @@ class HelmApiRoute extends LensApi { public async rollbackRelease(request: LensApiRequest) { const { cluster, params, payload, response } = request; + try { const result = await helmService.rollback(cluster, params.release, params.namespace, payload.revision); + this.respondJson(response, result); } catch (error) { logger.debug(error); @@ -65,8 +76,10 @@ class HelmApiRoute extends LensApi { public async listReleases(request: LensApiRequest) { const { cluster, params, response } = request; + try { const result = await helmService.listReleases(cluster, params.namespace); + this.respondJson(response, result); } catch(error) { logger.debug(error); @@ -76,8 +89,10 @@ class HelmApiRoute extends LensApi { public async getRelease(request: LensApiRequest) { const { cluster, params, response } = request; + try { const result = await helmService.getRelease(cluster, params.release, params.namespace); + this.respondJson(response, result); } catch (error) { logger.debug(error); @@ -87,8 +102,10 @@ class HelmApiRoute extends LensApi { public async getReleaseValues(request: LensApiRequest) { const { cluster, params, response } = request; + try { const result = await helmService.getReleaseValues(cluster, params.release, params.namespace); + this.respondText(response, result); } catch (error) { logger.debug(error); @@ -98,8 +115,10 @@ class HelmApiRoute extends LensApi { public async getReleaseHistory(request: LensApiRequest) { const { cluster, params, response } = request; + try { const result = await helmService.getReleaseHistory(cluster, params.release, params.namespace); + this.respondJson(response, result); } catch (error) { logger.debug(error); @@ -109,8 +128,10 @@ class HelmApiRoute extends LensApi { public async deleteRelease(request: LensApiRequest) { const { cluster, params, response } = request; + try { const result = await helmService.deleteRelease(cluster, params.release, params.namespace); + this.respondJson(response, result); } catch (error) { logger.debug(error); diff --git a/src/main/routes/kubeconfig-route.ts b/src/main/routes/kubeconfig-route.ts index 088bf1167f..7fe5bcb9bc 100644 --- a/src/main/routes/kubeconfig-route.ts +++ b/src/main/routes/kubeconfig-route.ts @@ -5,6 +5,7 @@ import { CoreV1Api, V1Secret } from "@kubernetes/client-node"; function generateKubeConfig(username: string, secret: V1Secret, cluster: Cluster) { const tokenData = Buffer.from(secret.data["token"], "base64"); + return { "apiVersion": "v1", "kind": "Config", @@ -43,14 +44,15 @@ class KubeconfigRoute extends LensApi { public async routeServiceAccountRoute(request: LensApiRequest) { const { params, response, cluster} = request; - const client = cluster.getProxyKubeconfig().makeApiClient(CoreV1Api); const secretList = await client.listNamespacedSecret(params.namespace); const secret = secretList.body.items.find(secret => { const { annotations } = secret.metadata; + return annotations && annotations["kubernetes.io/service-account.name"] == params.account; }); const data = generateKubeConfig(params.account, secret, cluster); + this.respondJson(response, data); } } diff --git a/src/main/routes/metrics-route.ts b/src/main/routes/metrics-route.ts index 4572030cca..d132f7b0ae 100644 --- a/src/main/routes/metrics-route.ts +++ b/src/main/routes/metrics-route.ts @@ -44,24 +44,31 @@ class MetricsRoute extends LensApi { async routeMetrics({ response, cluster, payload, query }: LensApiRequest) { const queryParams: IMetricsQuery = Object.fromEntries(query.entries()); const prometheusMetadata: ClusterPrometheusMetadata = {}; + try { const [prometheusPath, prometheusProvider] = await Promise.all([ cluster.contextHandler.getPrometheusPath(), cluster.contextHandler.getPrometheusProvider() ]); + prometheusMetadata.provider = prometheusProvider?.id; prometheusMetadata.autoDetected = !cluster.preferences.prometheusProvider?.type; + if (!prometheusPath) { prometheusMetadata.success = false; this.respondJson(response, {}); + return; } + // return data in same structure as query if (typeof payload === "string") { const [data] = await loadMetrics([payload], cluster, prometheusPath, queryParams); + this.respondJson(response, data); } else if (Array.isArray(payload)) { const data = await loadMetrics(payload, cluster, prometheusPath, queryParams); + this.respondJson(response, data); } else { const queries = Object.entries(payload).map(([queryName, queryOpts]) => ( @@ -69,6 +76,7 @@ class MetricsRoute extends LensApi { )); const result = await loadMetrics(queries, cluster, prometheusPath, queryParams); const data = Object.fromEntries(Object.keys(payload).map((metricName, i) => [metricName, result[i]])); + this.respondJson(response, data); } prometheusMetadata.success = true; diff --git a/src/main/routes/port-forward-route.ts b/src/main/routes/port-forward-route.ts index 967783aa3f..33c34758a4 100644 --- a/src/main/routes/port-forward-route.ts +++ b/src/main/routes/port-forward-route.ts @@ -52,15 +52,19 @@ class PortForward { PortForward.portForwards.push(this); this.process.on("exit", () => { const index = PortForward.portForwards.indexOf(this); + if (index > -1) { PortForward.portForwards.splice(index, 1); } }); + try { await tcpPortUsed.waitUntilUsed(this.localPort, 500, 15000); + return true; } catch (error) { this.process.kill(); + return false; } } @@ -75,11 +79,11 @@ class PortForwardRoute extends LensApi { public async routePortForward(request: LensApiRequest) { const { params, response, cluster} = request; const { namespace, port, resourceType, resourceName } = params; - let portForward = PortForward.getPortforward({ clusterId: cluster.id, kind: resourceType, name: resourceName, namespace, port }); + if (!portForward) { logger.info(`Creating a new port-forward ${namespace}/${resourceType}/${resourceName}:${port}`); portForward = new PortForward({ @@ -91,10 +95,12 @@ class PortForwardRoute extends LensApi { kubeConfig: cluster.getProxyKubeconfigPath() }); const started = await portForward.start(); + if (!started) { this.respondJson(response, { message: "Failed to open port-forward" }, 400); + return; } } diff --git a/src/main/routes/resource-applier-route.ts b/src/main/routes/resource-applier-route.ts index 8bbfec0d9c..1e532dde46 100644 --- a/src/main/routes/resource-applier-route.ts +++ b/src/main/routes/resource-applier-route.ts @@ -5,8 +5,10 @@ import { ResourceApplier } from "../resource-applier"; class ResourceApplierApiRoute extends LensApi { public async applyResource(request: LensApiRequest) { const { response, cluster, payload } = request; + try { const resource = await new ResourceApplier(cluster).apply(payload); + this.respondJson(response, [resource], 200); } catch (error) { this.respondText(response, error, 422); diff --git a/src/main/routes/watch-route.ts b/src/main/routes/watch-route.ts index d44d5cae7b..eb9f007eae 100644 --- a/src/main/routes/watch-route.ts +++ b/src/main/routes/watch-route.ts @@ -25,6 +25,7 @@ class ApiWatcher { } this.processor = setInterval(() => { const events = this.eventBuffer.splice(0); + events.map(event => this.sendEvent(event)); this.response.flushHeaders(); }, 50); @@ -38,6 +39,7 @@ class ApiWatcher { clearInterval(this.processor); } logger.debug(`Stopping watcher for api: ${this.apiUrl}`); + try { this.watchRequest.abort(); this.sendEvent({ @@ -81,6 +83,7 @@ class WatchRoute extends LensApi { message: "Empty request. Query params 'api' are not provided.", example: "?api=/api/v1/pods&api=/api/v1/nodes", }, 400); + return; } @@ -91,6 +94,7 @@ class WatchRoute extends LensApi { apis.forEach(apiUrl => { const watcher = new ApiWatcher(apiUrl, cluster.getProxyKubeconfig(), response); + watcher.start(); watchers.push(watcher); }); diff --git a/src/main/shell-session.ts b/src/main/shell-session.ts index 69979a51e3..19170695fc 100644 --- a/src/main/shell-session.ts +++ b/src/main/shell-session.ts @@ -39,11 +39,13 @@ export class ShellSession extends EventEmitter { public async open() { this.kubectlBinDir = await this.kubectl.binDir(); const pathFromPreferences = userStore.preferences.kubectlBinariesPath || this.kubectl.getBundledPath(); + this.kubectlPathDir = userStore.preferences.downloadKubectlBinaries ? this.kubectlBinDir : path.dirname(pathFromPreferences); this.helmBinDir = helmCli.getBinaryDir(); const env = await this.getCachedShellEnv(); const shell = env.PTYSHELL; const args = await this.getShellArgs(shell); + this.shellProcess = pty.spawn(shell, args, { cols: 80, cwd: this.cwd() || env.HOME, @@ -65,6 +67,7 @@ export class ShellSession extends EventEmitter { if(!this.preferences || !this.preferences.terminalCWD || this.preferences.terminalCWD === "") { return null; } + return this.preferences.terminalCWD; } @@ -85,6 +88,7 @@ export class ShellSession extends EventEmitter { protected async getCachedShellEnv() { let env = ShellSession.shellEnvs.get(this.clusterId); + if (!env) { env = await this.getShellEnv(); ShellSession.shellEnvs.set(this.clusterId, env); @@ -122,11 +126,14 @@ export class ShellSession extends EventEmitter { env["KUBECONFIG"] = this.kubeconfigPath; env["TERM_PROGRAM"] = app.getName(); env["TERM_PROGRAM_VERSION"] = app.getVersion(); + if (this.preferences.httpsProxy) { env["HTTPS_PROXY"] = this.preferences.httpsProxy; } const no_proxy = ["localhost", "127.0.0.1", env["NO_PROXY"]]; + env["NO_PROXY"] = no_proxy.filter(address => !!address).join(); + if (env.DEBUG) { // do not pass debug option to bash delete env["DEBUG"]; } @@ -147,12 +154,14 @@ export class ShellSession extends EventEmitter { if (!this.running) { return; } const message = Buffer.from(data.slice(1, data.length), "base64").toString(); + switch (data[0]) { case "0": this.shellProcess.write(message); break; case "4": const resizeMsgObj = JSON.parse(message); + this.shellProcess.resize(resizeMsgObj["Width"], resizeMsgObj["Height"]); break; case "9": @@ -171,6 +180,7 @@ export class ShellSession extends EventEmitter { this.shellProcess.onExit(({ exitCode }) => { this.running = false; let timeout = 0; + if (exitCode > 0) { this.sendResponse("Terminal will auto-close in 15 seconds ..."); timeout = 15*1000; diff --git a/src/main/shell-sync.ts b/src/main/shell-sync.ts index 80e07a8ac5..b8b5bd81ea 100644 --- a/src/main/shell-sync.ts +++ b/src/main/shell-sync.ts @@ -14,8 +14,8 @@ interface Env { */ export async function shellSync() { const { shell } = os.userInfo(); - let envVars = {}; + try { envVars = await shellEnv(shell); } catch (error) { @@ -23,6 +23,7 @@ export async function shellSync() { } const env: Env = JSON.parse(JSON.stringify(envVars)); + if (!env.LANG) { // the LANG env var expects an underscore instead of electron's dash env.LANG = `${app.getLocale().replace("-", "_")}.UTF-8`; diff --git a/src/main/tray.ts b/src/main/tray.ts index d006ae6b43..50df26cb8f 100644 --- a/src/main/tray.ts +++ b/src/main/tray.ts @@ -28,11 +28,13 @@ export function initTray(windowManager: WindowManager) { const dispose = autorun(() => { try { const menu = createTrayMenu(windowManager); + buildTray(getTrayIcon(), menu); } catch (err) { logger.error(`[TRAY]: building failed: ${err}`); } }); + return () => { dispose(); tray?.destroy(); @@ -60,6 +62,7 @@ export function createTrayMenu(windowManager: WindowManager): Menu { async click() { // note: argument[1] (browserWindow) not available when app is not focused / hidden const browserWindow = await windowManager.ensureMainWindow(); + showAbout(browserWindow); }, }, @@ -82,11 +85,13 @@ export function createTrayMenu(windowManager: WindowManager): Menu { .filter(workspace => clusterStore.getByWorkspaceId(workspace.id).length > 0) // hide empty workspaces .map(workspace => { const clusters = clusterStore.getByWorkspaceId(workspace.id); + return { label: workspace.name, toolTip: workspace.description, submenu: clusters.map(cluster => { const { id: clusterId, name: label, online, workspace } = cluster; + return { label: `${online ? "✓" : "\x20".repeat(3)/*offset*/}${label}`, toolTip: clusterId, @@ -103,8 +108,10 @@ export function createTrayMenu(windowManager: WindowManager): Menu { label: "Check for updates", async click() { const result = await AppUpdater.checkForUpdates(); + if (!result) { const browserWindow = await windowManager.ensureMainWindow(); + dialog.showMessageBoxSync(browserWindow, { message: "No updates available", type: "info", diff --git a/src/main/window-manager.ts b/src/main/window-manager.ts index 1fa2614c50..b60e7400ea 100644 --- a/src/main/window-manager.ts +++ b/src/main/window-manager.ts @@ -37,11 +37,13 @@ export class WindowManager extends Singleton { defaultWidth: 1440, }); } + if (!this.mainWindow) { // show icon in dock (mac-os only) app.dock?.show(); const { width, height, x, y } = this.windowState; + this.mainWindow = new BrowserWindow({ x, y, width, height, show: false, @@ -80,6 +82,7 @@ export class WindowManager extends Singleton { app.dock?.hide(); // hide icon in dock (mac-os) }); } + try { if (showSplash) await this.showSplash(); await this.mainWindow.loadURL(this.mainUrl); @@ -109,6 +112,7 @@ export class WindowManager extends Singleton { async ensureMainWindow(): Promise { if (!this.mainWindow) await this.initMainWindow(); this.mainWindow.show(); + return this.mainWindow; } @@ -131,6 +135,7 @@ export class WindowManager extends Singleton { reload() { const frameId = clusterFrameMap.get(this.activeClusterId); + if (frameId) { this.sendToView({ channel: "renderer:reload", frameId }); } else { diff --git a/src/migrations/cluster-store/2.0.0-beta.2.ts b/src/migrations/cluster-store/2.0.0-beta.2.ts index d7f1ef73f0..ccc8932e70 100644 --- a/src/migrations/cluster-store/2.0.0-beta.2.ts +++ b/src/migrations/cluster-store/2.0.0-beta.2.ts @@ -8,6 +8,7 @@ export default migration({ run(store) { for (const value of store) { const contextName = value[0]; + // Looping all the keys gives out the store internal stuff too... if (contextName === "__internal__" || value[1].hasOwnProperty("kubeConfig")) continue; store.set(contextName, { kubeConfig: value[1] }); diff --git a/src/migrations/cluster-store/2.4.1.ts b/src/migrations/cluster-store/2.4.1.ts index 2bde7755f1..aa6936e432 100644 --- a/src/migrations/cluster-store/2.4.1.ts +++ b/src/migrations/cluster-store/2.4.1.ts @@ -6,8 +6,10 @@ export default migration({ run(store) { for (const value of store) { const contextName = value[0]; + if (contextName === "__internal__") continue; const cluster = value[1]; + store.set(contextName, { kubeConfig: cluster.kubeConfig, icon: cluster.icon || null, preferences: cluster.preferences || {} }); } } diff --git a/src/migrations/cluster-store/2.6.0-beta.2.ts b/src/migrations/cluster-store/2.6.0-beta.2.ts index ed4b648ce1..596d16c31f 100644 --- a/src/migrations/cluster-store/2.6.0-beta.2.ts +++ b/src/migrations/cluster-store/2.6.0-beta.2.ts @@ -6,9 +6,12 @@ export default migration({ run(store) { for (const value of store) { const clusterKey = value[0]; + if (clusterKey === "__internal__") continue; const cluster = value[1]; + if (!cluster.preferences) cluster.preferences = {}; + if (cluster.icon) { cluster.preferences.icon = cluster.icon; delete (cluster["icon"]); diff --git a/src/migrations/cluster-store/2.6.0-beta.3.ts b/src/migrations/cluster-store/2.6.0-beta.3.ts index 63decfac1e..779fff7e7d 100644 --- a/src/migrations/cluster-store/2.6.0-beta.3.ts +++ b/src/migrations/cluster-store/2.6.0-beta.3.ts @@ -6,19 +6,26 @@ export default migration({ run(store, log) { for (const value of store) { const clusterKey = value[0]; + if (clusterKey === "__internal__") continue; const cluster = value[1]; + if (!cluster.kubeConfig) continue; const kubeConfig = yaml.safeLoad(cluster.kubeConfig); + if (!kubeConfig.hasOwnProperty("users")) continue; const userObj = kubeConfig.users[0]; + if (userObj) { const user = userObj.user; + if (user["auth-provider"] && user["auth-provider"].config) { const authConfig = user["auth-provider"].config; + if (authConfig["access-token"]) { authConfig["access-token"] = `${authConfig["access-token"]}`; } + if (authConfig.expiry) { authConfig.expiry = `${authConfig.expiry}`; } diff --git a/src/migrations/cluster-store/2.7.0-beta.0.ts b/src/migrations/cluster-store/2.7.0-beta.0.ts index a3c103769f..2f02a047f4 100644 --- a/src/migrations/cluster-store/2.7.0-beta.0.ts +++ b/src/migrations/cluster-store/2.7.0-beta.0.ts @@ -6,8 +6,10 @@ export default migration({ run(store) { for (const value of store) { const clusterKey = value[0]; + if (clusterKey === "__internal__") continue; const cluster = value[1]; + cluster.workspace = "default"; store.set(clusterKey, cluster); } diff --git a/src/migrations/cluster-store/2.7.0-beta.1.ts b/src/migrations/cluster-store/2.7.0-beta.1.ts index 73eb66ec2f..cb3934d422 100644 --- a/src/migrations/cluster-store/2.7.0-beta.1.ts +++ b/src/migrations/cluster-store/2.7.0-beta.1.ts @@ -6,18 +6,23 @@ export default migration({ version: "2.7.0-beta.1", run(store) { const clusters: any[] = []; + for (const value of store) { const clusterKey = value[0]; + if (clusterKey === "__internal__") continue; if (clusterKey === "clusters") continue; const cluster = value[1]; + cluster.id = uuid(); + if (!cluster.preferences.clusterName) { cluster.preferences.clusterName = clusterKey; } clusters.push(cluster); store.delete(clusterKey); } + if (clusters.length > 0) { store.set("clusters", clusters); } diff --git a/src/migrations/cluster-store/3.6.0-beta.1.ts b/src/migrations/cluster-store/3.6.0-beta.1.ts index 597aab4713..ca2d0ccbed 100644 --- a/src/migrations/cluster-store/3.6.0-beta.1.ts +++ b/src/migrations/cluster-store/3.6.0-beta.1.ts @@ -32,6 +32,7 @@ export default migration({ } catch (error) { printLog(`Failed to migrate Kubeconfig for cluster "${cluster.id}", removing cluster...`, error); + return undefined; } diff --git a/src/migrations/cluster-store/snap.ts b/src/migrations/cluster-store/snap.ts index 699f88716f..74b89aad9c 100644 --- a/src/migrations/cluster-store/snap.ts +++ b/src/migrations/cluster-store/snap.ts @@ -12,6 +12,7 @@ export default migration({ printLog("Migrating embedded kubeconfig paths"); const storedClusters: ClusterModel[] = store.get("clusters") || []; + if (!storedClusters.length) return; printLog("Number of clusters to migrate: ", storedClusters.length); @@ -22,8 +23,10 @@ export default migration({ */ if (!fs.existsSync(cluster.kubeConfigPath)) { const kubeconfigPath = cluster.kubeConfigPath.replace(/\/snap\/kontena-lens\/[0-9]*\//, "/snap/kontena-lens/current/"); + cluster.kubeConfigPath = kubeconfigPath; } + return cluster; }); diff --git a/src/renderer/api/api-manager.ts b/src/renderer/api/api-manager.ts index 6c72f633f3..68d4773540 100644 --- a/src/renderer/api/api-manager.ts +++ b/src/renderer/api/api-manager.ts @@ -25,6 +25,7 @@ export class ApiManager { protected resolveApi(api: string | KubeApi): KubeApi { if (typeof api === "string") return this.getApi(api); + return api; } @@ -33,6 +34,7 @@ export class ApiManager { else { const apis = Array.from(this.apis.entries()); const entry = apis.find(entry => entry[1] === api); + if (entry) this.unregisterApi(entry[0]); } } diff --git a/src/renderer/api/endpoints/cluster.api.ts b/src/renderer/api/endpoints/cluster.api.ts index d3017c691a..e96ab7f082 100644 --- a/src/renderer/api/endpoints/cluster.api.ts +++ b/src/renderer/api/endpoints/cluster.api.ts @@ -90,6 +90,7 @@ export class Cluster extends KubeObject { if (this.metadata.deletionTimestamp) return ClusterStatus.REMOVING; if (!this.status || !this.status) return ClusterStatus.CREATING; if (this.status.errorMessage) return ClusterStatus.ERROR; + return ClusterStatus.ACTIVE; } } diff --git a/src/renderer/api/endpoints/crd.api.ts b/src/renderer/api/endpoints/crd.api.ts index 02690a2afd..ad7d9d67ca 100644 --- a/src/renderer/api/endpoints/crd.api.ts +++ b/src/renderer/api/endpoints/crd.api.ts @@ -75,6 +75,7 @@ export class CustomResourceDefinition extends KubeObject { getResourceApiBase() { const { group } = this.spec; + return `/apis/${group}/${this.getVersion()}/${this.getPluralName()}`; } @@ -88,6 +89,7 @@ export class CustomResourceDefinition extends KubeObject { getResourceTitle() { const name = this.getPluralName(); + return name[0].toUpperCase() + name.substr(1); } @@ -124,6 +126,7 @@ export class CustomResourceDefinition extends KubeObject { const columns = this.spec.versions.find(a => this.getVersion() == a.name)?.additionalPrinterColumns ?? this.spec.additionalPrinterColumns?.map(({ JSONPath, ...rest }) => ({ ...rest, jsonPath: JSONPath })) // map to V1 shape ?? []; + return columns .filter(column => column.name != "Age") .filter(column => ignorePriority ? true : !column.priority); @@ -135,8 +138,10 @@ export class CustomResourceDefinition extends KubeObject { getConditions() { if (!this.status?.conditions) return []; + return this.status.conditions.map(condition => { const { message, reason, lastTransitionTime, status } = condition; + return { ...condition, isReady: status === "True", diff --git a/src/renderer/api/endpoints/cron-job.api.ts b/src/renderer/api/endpoints/cron-job.api.ts index 2cca8bfb3d..6669f34736 100644 --- a/src/renderer/api/endpoints/cron-job.api.ts +++ b/src/renderer/api/endpoints/cron-job.api.ts @@ -71,6 +71,7 @@ export class CronJob extends KubeObject { getLastScheduleTime() { if (!this.status.lastScheduleTime) return "-"; const diff = moment().diff(this.status.lastScheduleTime); + return formatDuration(diff, true); } @@ -84,7 +85,9 @@ export class CronJob extends KubeObject { const stamps = schedule.split(" "); const day = Number(stamps[stamps.length - 3]); // 1-31 const month = Number(stamps[stamps.length - 2]); // 1-12 + if (schedule.startsWith("@")) return false; + return day > daysInMonth[month - 1]; } } diff --git a/src/renderer/api/endpoints/daemon-set.api.ts b/src/renderer/api/endpoints/daemon-set.api.ts index 63fc6363e4..8dab807517 100644 --- a/src/renderer/api/endpoints/daemon-set.api.ts +++ b/src/renderer/api/endpoints/daemon-set.api.ts @@ -66,6 +66,7 @@ export class DaemonSet extends WorkloadKubeObject { getImages() { const containers: IPodContainer[] = get(this, "spec.template.spec.containers", []); const initContainers: IPodContainer[] = get(this, "spec.template.spec.initContainers", []); + return [...containers, ...initContainers].map(container => container.image); } } diff --git a/src/renderer/api/endpoints/deployment.api.ts b/src/renderer/api/endpoints/deployment.api.ts index e3572a0bc4..d876616470 100644 --- a/src/renderer/api/endpoints/deployment.api.ts +++ b/src/renderer/api/endpoints/deployment.api.ts @@ -171,10 +171,13 @@ export class Deployment extends WorkloadKubeObject { getConditions(activeOnly = false) { const { conditions } = this.status; + if (!conditions) return []; + if (activeOnly) { return conditions.filter(c => c.status === "True"); } + return conditions; } diff --git a/src/renderer/api/endpoints/endpoint.api.ts b/src/renderer/api/endpoints/endpoint.api.ts index 121836a637..d19c2f127e 100644 --- a/src/renderer/api/endpoints/endpoint.api.ts +++ b/src/renderer/api/endpoints/endpoint.api.ts @@ -73,11 +73,13 @@ export class EndpointSubset implements IEndpointSubset { getAddresses(): EndpointAddress[] { const addresses = this.addresses || []; + return addresses.map(a => new EndpointAddress(a)); } getNotReadyAddresses(): EndpointAddress[] { const notReadyAddresses = this.notReadyAddresses || []; + return notReadyAddresses.map(a => new EndpointAddress(a)); } @@ -85,10 +87,12 @@ export class EndpointSubset implements IEndpointSubset { if(!this.addresses) { return ""; } + return this.addresses.map(address => { if (!this.ports) { return address.ip; } + return this.ports.map(port => { return `${address.ip}:${port.port}`; }).join(", "); @@ -106,6 +110,7 @@ export class Endpoint extends KubeObject { getEndpointSubsets(): EndpointSubset[] { const subsets = this.subsets || []; + return subsets.map(s => new EndpointSubset(s)); } diff --git a/src/renderer/api/endpoints/events.api.ts b/src/renderer/api/endpoints/events.api.ts index 51dbf3c3b5..df9aa540a4 100644 --- a/src/renderer/api/endpoints/events.api.ts +++ b/src/renderer/api/endpoints/events.api.ts @@ -39,16 +39,19 @@ export class KubeEvent extends KubeObject { getSource() { const { component, host } = this.source; + return `${component} ${host || ""}`; } getFirstSeenTime() { const diff = moment().diff(this.firstTimestamp); + return formatDuration(diff, true); } getLastSeenTime() { const diff = moment().diff(this.lastTimestamp); + return formatDuration(diff, true); } } diff --git a/src/renderer/api/endpoints/helm-charts.api.ts b/src/renderer/api/endpoints/helm-charts.api.ts index 2964d18db2..a1fd497798 100644 --- a/src/renderer/api/endpoints/helm-charts.api.ts +++ b/src/renderer/api/endpoints/helm-charts.api.ts @@ -33,11 +33,13 @@ export const helmChartsApi = { get(repo: string, name: string, readmeVersion?: string) { const path = endpoint({ repo, name }); + return apiBase .get(`${path}?${stringify({ version: readmeVersion })}`) .then(data => { const versions = data.versions.map(HelmChart.create); const readme = data.readme; + return { readme, versions, diff --git a/src/renderer/api/endpoints/helm-releases.api.ts b/src/renderer/api/endpoints/helm-releases.api.ts index b93d29caa9..84e095721b 100644 --- a/src/renderer/api/endpoints/helm-releases.api.ts +++ b/src/renderer/api/endpoints/helm-releases.api.ts @@ -76,9 +76,11 @@ export const helmReleasesApi = { get(name: string, namespace: string) { const path = endpoint({ name, namespace }); + return apiBase.get(path).then(details => { const items: KubeObject[] = JSON.parse(details.resources).items; const resources = items.map(item => KubeObject.create(item)); + return { ...details, resources @@ -88,35 +90,43 @@ export const helmReleasesApi = { create(payload: IReleaseCreatePayload): Promise { const { repo, ...data } = payload; + data.chart = `${repo}/${data.chart}`; data.values = jsYaml.safeLoad(data.values); + return apiBase.post(endpoint(), { data }); }, update(name: string, namespace: string, payload: IReleaseUpdatePayload): Promise { const { repo, ...data } = payload; + data.chart = `${repo}/${data.chart}`; data.values = jsYaml.safeLoad(data.values); + return apiBase.put(endpoint({ name, namespace }), { data }); }, async delete(name: string, namespace: string) { const path = endpoint({ name, namespace }); + return apiBase.del(path); }, getValues(name: string, namespace: string) { const path = `${endpoint({ name, namespace })}/values`; + return apiBase.get(path); }, getHistory(name: string, namespace: string): Promise { const path = `${endpoint({ name, namespace })}/history`; + return apiBase.get(path); }, rollback(name: string, namespace: string, revision: number) { const path = `${endpoint({ name, namespace })}/rollback`; + return apiBase.put(path, { data: { revision @@ -157,10 +167,13 @@ export class HelmRelease implements ItemObject { getChart(withVersion = false) { let chart = this.chart; + if(!withVersion && this.getVersion() != "" ) { const search = new RegExp(`-${this.getVersion()}`); + chart = chart.replace(search, ""); } + return chart; } @@ -174,6 +187,7 @@ export class HelmRelease implements ItemObject { getVersion() { const versions = this.chart.match(/(v?\d+)[^-].*$/); + if (versions) { return versions[0]; } @@ -187,9 +201,11 @@ export class HelmRelease implements ItemObject { const updated = this.updated.replace(/\s\w*$/, ""); // 2019-11-26 10:58:09 +0300 MSK -> 2019-11-26 10:58:09 +0300 to pass into Date() const updatedDate = new Date(updated).getTime(); const diff = now - updatedDate; + if (humanize) { return formatDuration(diff, compact); } + return diff; } @@ -200,6 +216,7 @@ export class HelmRelease implements ItemObject { const version = this.getVersion(); const versions = await helmChartStore.getVersions(chartName); const chartVersion = versions.find(chartVersion => chartVersion.version === version); + return chartVersion ? chartVersion.repo : ""; } } diff --git a/src/renderer/api/endpoints/hpa.api.ts b/src/renderer/api/endpoints/hpa.api.ts index 657e34e38c..4876ee43eb 100644 --- a/src/renderer/api/endpoints/hpa.api.ts +++ b/src/renderer/api/endpoints/hpa.api.ts @@ -80,8 +80,10 @@ export class HorizontalPodAutoscaler extends KubeObject { getConditions() { if (!this.status.conditions) return []; + return this.status.conditions.map(condition => { const { message, reason, lastTransitionTime, status } = condition; + return { ...condition, isReady: status === "True", @@ -100,6 +102,7 @@ export class HorizontalPodAutoscaler extends KubeObject { protected getMetricName(metric: IHpaMetric): string { const { type, resource, pods, object, external } = metric; + switch (type) { case HpaMetricType.Resource: return resource.name; @@ -122,14 +125,17 @@ export class HorizontalPodAutoscaler extends KubeObject { const target = metric[metricType]; let currentValue = "unknown"; let targetValue = "unknown"; + if (current) { currentValue = current.currentAverageUtilization || current.currentAverageValue || current.currentValue; if (current.currentAverageUtilization) currentValue += "%"; } + if (target) { targetValue = target.targetAverageUtilization || target.targetAverageValue || target.targetValue; if (target.targetAverageUtilization) targetValue += "%"; } + return `${currentValue} / ${targetValue}`; } } diff --git a/src/renderer/api/endpoints/ingress.api.ts b/src/renderer/api/endpoints/ingress.api.ts index 5832dc55db..7d035ad591 100644 --- a/src/renderer/api/endpoints/ingress.api.ts +++ b/src/renderer/api/endpoints/ingress.api.ts @@ -6,6 +6,7 @@ import { KubeApi } from "../kube-api"; export class IngressApi extends KubeApi { getMetrics(ingress: string, namespace: string): Promise { const opts = { category: "ingress", ingress }; + return metricsApi.getMetrics({ bytesSentSuccess: opts, bytesSentFailure: opts, @@ -98,15 +99,18 @@ export class Ingress extends KubeObject { getRoutes() { const { spec: { tls, rules } } = this; + if (!rules) return []; let protocol = "http"; const routes: string[] = []; + if (tls && tls.length > 0) { protocol += "s"; } rules.map(rule => { const host = rule.host ? rule.host : "*"; + if (rule.http && rule.http.paths) { rule.http.paths.forEach(path => { const { serviceName, servicePort } = getBackendServiceNamePort(path.backend); @@ -132,7 +136,9 @@ export class Ingress extends KubeObject { getHosts() { const { spec: { rules } } = this; + if (!rules) return []; + return rules.filter(rule => rule.host).map(rule => rule.host); } @@ -141,7 +147,6 @@ export class Ingress extends KubeObject { const { spec: { tls, rules, backend, defaultBackend } } = this; const httpPort = 80; const tlsPort = 443; - // Note: not using the port name (string) const servicePort = defaultBackend?.service.port.number ?? backend?.servicePort; @@ -152,6 +157,7 @@ export class Ingress extends KubeObject { } else if (servicePort !== undefined) { ports.push(Number(servicePort)); } + if (tls && tls.length > 0) { ports.push(tlsPort); } diff --git a/src/renderer/api/endpoints/job.api.ts b/src/renderer/api/endpoints/job.api.ts index 1dc78fdc94..65b9bcfdc3 100644 --- a/src/renderer/api/endpoints/job.api.ts +++ b/src/renderer/api/endpoints/job.api.ts @@ -86,12 +86,15 @@ export class Job extends WorkloadKubeObject { // Type of Job condition could be only Complete or Failed // https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.14/#jobcondition-v1-batch const { conditions } = this.status; + if (!conditions) return; + return conditions.find(({ status }) => status === "True"); } getImages() { const containers: IPodContainer[] = get(this, "spec.template.spec.containers", []); + return [...containers].map(container => container.image); } @@ -99,6 +102,7 @@ export class Job extends WorkloadKubeObject { const params: JsonApiParams = { query: { propagationPolicy: "Background" } }; + return super.delete(params); } } diff --git a/src/renderer/api/endpoints/metrics.api.ts b/src/renderer/api/endpoints/metrics.api.ts index 86c829bcd6..a530c68506 100644 --- a/src/renderer/api/endpoints/metrics.api.ts +++ b/src/renderer/api/endpoints/metrics.api.ts @@ -41,6 +41,7 @@ export const metricsApi = { if (!start && !end) { const timeNow = Date.now() / 1000; const now = moment.unix(timeNow).startOf("minute").unix(); // round date to minutes + start = now - range; end = now; } @@ -76,8 +77,10 @@ export function normalizeMetrics(metrics: IMetrics, frames = 60): IMetrics { // fill the gaps result.forEach(res => { if (!res.values || !res.values.length) return; + while (res.values.length < frames) { const timestamp = moment.unix(res.values[0][0]).subtract(1, "minute").unix(); + res.values.unshift([timestamp, "0"]); } }); @@ -101,14 +104,17 @@ export function isMetricsEmpty(metrics: { [key: string]: IMetrics }) { export function getItemMetrics(metrics: { [key: string]: IMetrics }, itemName: string): { [key: string]: IMetrics } { if (!metrics) return; const itemMetrics = { ...metrics }; + for (const metric in metrics) { if (!metrics[metric]?.data?.result) { continue; } const results = metrics[metric].data.result; const result = results.find(res => Object.values(res.metric)[0] == itemName); + itemMetrics[metric].data.result = result ? [result] : []; } + return itemMetrics; } @@ -118,11 +124,13 @@ export function getMetricLastPoints(metrics: { [key: string]: IMetrics }) { Object.keys(metrics).forEach(metricName => { try { const metric = metrics[metricName]; + if (metric.data.result.length) { result[metricName] = +metric.data.result[0].values.slice(-1)[0][1]; } } catch (e) { } + return result; }, {}); diff --git a/src/renderer/api/endpoints/network-policy.api.ts b/src/renderer/api/endpoints/network-policy.api.ts index 4ecd333854..eb531990c2 100644 --- a/src/renderer/api/endpoints/network-policy.api.ts +++ b/src/renderer/api/endpoints/network-policy.api.ts @@ -55,6 +55,7 @@ export class NetworkPolicy extends KubeObject { getMatchLabels(): string[] { if (!this.spec.podSelector || !this.spec.podSelector.matchLabels) return []; + return Object .entries(this.spec.podSelector.matchLabels) .map(data => data.join(":")); @@ -62,6 +63,7 @@ export class NetworkPolicy extends KubeObject { getTypes(): string[] { if (!this.spec.policyTypes) return []; + return this.spec.policyTypes; } } diff --git a/src/renderer/api/endpoints/nodes.api.ts b/src/renderer/api/endpoints/nodes.api.ts index c85cd8f9b0..d1794f0fb7 100644 --- a/src/renderer/api/endpoints/nodes.api.ts +++ b/src/renderer/api/endpoints/nodes.api.ts @@ -87,9 +87,12 @@ export class Node extends KubeObject { getNodeConditionText() { const { conditions } = this.status; + if (!conditions) return ""; + return conditions.reduce((types, current) => { if (current.status !== "True") return ""; + return types += ` ${current.type}`; }, ""); } @@ -112,19 +115,23 @@ export class Node extends KubeObject { getCpuCapacity() { if (!this.status.capacity || !this.status.capacity.cpu) return 0; + return cpuUnitsToNumber(this.status.capacity.cpu); } getMemoryCapacity() { if (!this.status.capacity || !this.status.capacity.memory) return 0; + return unitsToBytes(this.status.capacity.memory); } getConditions() { const conditions = this.status.conditions || []; + if (this.isUnschedulable()) { return [{ type: "SchedulingDisabled", status: "True" }, ...conditions]; } + return conditions; } @@ -134,6 +141,7 @@ export class Node extends KubeObject { getWarningConditions() { const goodConditions = ["Ready", "HostUpgrades", "SchedulingDisabled"]; + return this.getActiveConditions().filter(condition => { return !goodConditions.includes(condition.type); }); @@ -145,6 +153,7 @@ export class Node extends KubeObject { getOperatingSystem(): string { const label = this.getLabels().find(label => label.startsWith("kubernetes.io/os=")); + if (label) { return label.split("=", 2)[1]; } diff --git a/src/renderer/api/endpoints/persistent-volume-claims.api.ts b/src/renderer/api/endpoints/persistent-volume-claims.api.ts index 529aefa951..1d9e1f1dce 100644 --- a/src/renderer/api/endpoints/persistent-volume-claims.api.ts +++ b/src/renderer/api/endpoints/persistent-volume-claims.api.ts @@ -52,6 +52,7 @@ export class PersistentVolumeClaim extends KubeObject { getPods(allPods: Pod[]): Pod[] { const pods = allPods.filter(pod => pod.getNs() === this.getNs()); + return pods.filter(pod => { return pod.getVolumes().filter(volume => volume.persistentVolumeClaim && @@ -62,22 +63,26 @@ export class PersistentVolumeClaim extends KubeObject { getStorage(): string { if (!this.spec.resources || !this.spec.resources.requests) return "-"; + return this.spec.resources.requests.storage; } getMatchLabels(): string[] { if (!this.spec.selector || !this.spec.selector.matchLabels) return []; + return Object.entries(this.spec.selector.matchLabels) .map(([name, val]) => `${name}:${val}`); } getMatchExpressions() { if (!this.spec.selector || !this.spec.selector.matchExpressions) return []; + return this.spec.selector.matchExpressions; } getStatus(): string { if (this.status) return this.status.phase; + return "-"; } } diff --git a/src/renderer/api/endpoints/persistent-volume.api.ts b/src/renderer/api/endpoints/persistent-volume.api.ts index 5e31eeb028..dd5dbb616e 100644 --- a/src/renderer/api/endpoints/persistent-volume.api.ts +++ b/src/renderer/api/endpoints/persistent-volume.api.ts @@ -47,20 +47,25 @@ export class PersistentVolume extends KubeObject { getCapacity(inBytes = false) { const capacity = this.spec.capacity; + if (capacity) { if (inBytes) return unitsToBytes(capacity.storage); + return capacity.storage; } + return 0; } getStatus() { if (!this.status) return; + return this.status.phase || "-"; } getClaimRefName() { const { claimRef } = this.spec; + return claimRef ? claimRef.name : ""; } } diff --git a/src/renderer/api/endpoints/poddisruptionbudget.api.ts b/src/renderer/api/endpoints/poddisruptionbudget.api.ts index b76260ae6f..50ab2d5b3d 100644 --- a/src/renderer/api/endpoints/poddisruptionbudget.api.ts +++ b/src/renderer/api/endpoints/poddisruptionbudget.api.ts @@ -22,6 +22,7 @@ export class PodDisruptionBudget extends KubeObject { getSelectors() { const selector = this.spec.selector; + return KubeObject.stringifyLabels(selector ? selector.matchLabels : null); } diff --git a/src/renderer/api/endpoints/pods.api.ts b/src/renderer/api/endpoints/pods.api.ts index 3f62a3a952..11b581db8f 100644 --- a/src/renderer/api/endpoints/pods.api.ts +++ b/src/renderer/api/endpoints/pods.api.ts @@ -6,6 +6,7 @@ import { KubeApi } from "../kube-api"; export class PodsApi extends KubeApi { async getLogs(params: { namespace: string; name: string }, query?: IPodLogsQuery): Promise { const path = `${this.getUrl(params)}/log`; + return this.request.get(path, { query }); } @@ -247,6 +248,7 @@ export class Pod extends WorkloadKubeObject { getRunningContainers() { const statuses = this.getContainerStatuses(); + return this.getAllContainers().filter(container => { return statuses.find(status => status.name === container.name && !!status.state["running"]); } @@ -256,18 +258,23 @@ export class Pod extends WorkloadKubeObject { getContainerStatuses(includeInitContainers = true) { const statuses: IPodContainerStatus[] = []; const { containerStatuses, initContainerStatuses } = this.status; + if (containerStatuses) { statuses.push(...containerStatuses); } + if (includeInitContainers && initContainerStatuses) { statuses.push(...initContainerStatuses); } + return statuses; } getRestartsCount(): number { const { containerStatuses } = this.status; + if (!containerStatuses) return 0; + return containerStatuses.reduce((count, item) => count + item.restartCount, 0); } @@ -290,18 +297,23 @@ export class Pod extends WorkloadKubeObject { const goodConditions = ["Initialized", "Ready"].every(condition => !!this.getConditions().find(item => item.type === condition && item.status === "True") ); + if (reason === PodStatus.EVICTED) { return PodStatus.EVICTED; } + if (phase === PodStatus.FAILED) { return PodStatus.FAILED; } + if (phase === PodStatus.SUCCEEDED) { return PodStatus.SUCCEEDED; } + if (phase === PodStatus.RUNNING && goodConditions) { return PodStatus.RUNNING; } + return PodStatus.PENDING; } @@ -312,20 +324,26 @@ export class Pod extends WorkloadKubeObject { let message = ""; const statuses = this.getContainerStatuses(false); // not including initContainers + if (statuses.length) { statuses.forEach(status => { const { state } = status; + if (state.waiting) { const { reason } = state.waiting; + message = reason ? reason : "Waiting"; } + if (state.terminated) { const { reason } = state.terminated; + message = reason ? reason : "Terminated"; } }); } if (message) return message; + return this.getStatusPhase(); } @@ -349,7 +367,9 @@ export class Pod extends WorkloadKubeObject { getNodeSelectors(): string[] { const { nodeSelector } = this.spec; + if (!nodeSelector) return []; + return Object.entries(nodeSelector).map(values => values.join(": ")); } @@ -367,8 +387,10 @@ export class Pod extends WorkloadKubeObject { }); const crashLoop = !!this.getContainerStatuses().find(condition => { const waiting = condition.state.waiting; + return (waiting && waiting.reason == "CrashLoopBackOff"); }); + return ( notReady || crashLoop || @@ -391,18 +413,22 @@ export class Pod extends WorkloadKubeObject { periodSeconds, successThreshold, failureThreshold } = probeData; const probe = []; + // HTTP Request if (httpGet) { const { path, port, host, scheme } = httpGet; + probe.push( "http-get", `${scheme.toLowerCase()}://${host || ""}:${port || ""}${path || ""}`, ); } + // Command if (exec && exec.command) { probe.push(`exec [${exec.command.join(" ")}]`); } + // TCP Probe if (tcpSocket && tcpSocket.port) { probe.push(`tcp-socket :${tcpSocket.port}`); @@ -414,6 +440,7 @@ export class Pod extends WorkloadKubeObject { `#success=${successThreshold || "0"}`, `#failure=${failureThreshold || "0"}`, ); + return probe; } diff --git a/src/renderer/api/endpoints/podsecuritypolicy.api.ts b/src/renderer/api/endpoints/podsecuritypolicy.api.ts index c7981f65be..dc5113625d 100644 --- a/src/renderer/api/endpoints/podsecuritypolicy.api.ts +++ b/src/renderer/api/endpoints/podsecuritypolicy.api.ts @@ -78,6 +78,7 @@ export class PodSecurityPolicy extends KubeObject { getRules() { const { fsGroup, runAsGroup, runAsUser, supplementalGroups, seLinux } = this.spec; + return { fsGroup: fsGroup ? fsGroup.rule : "", runAsGroup: runAsGroup ? runAsGroup.rule : "", diff --git a/src/renderer/api/endpoints/replica-set.api.ts b/src/renderer/api/endpoints/replica-set.api.ts index d1081811e9..999de8c1ac 100644 --- a/src/renderer/api/endpoints/replica-set.api.ts +++ b/src/renderer/api/endpoints/replica-set.api.ts @@ -48,6 +48,7 @@ export class ReplicaSet extends WorkloadKubeObject { getImages() { const containers: IPodContainer[] = get(this, "spec.template.spec.containers", []); + return [...containers].map(container => container.image); } } diff --git a/src/renderer/api/endpoints/resource-applier.api.ts b/src/renderer/api/endpoints/resource-applier.api.ts index a2843a6262..a397cfdda0 100644 --- a/src/renderer/api/endpoints/resource-applier.api.ts +++ b/src/renderer/api/endpoints/resource-applier.api.ts @@ -13,17 +13,20 @@ export const resourceApplierApi = { if (typeof resource === "string") { resource = jsYaml.safeLoad(resource); } + return apiBase .post("/stack", { data: resource }) .then(data => { const items = data.map(obj => { const api = apiManager.getApi(obj.metadata.selfLink); + if (api) { return new api.objectConstructor(obj); } else { return new KubeObject(obj); } }); + return items.length === 1 ? items[0] : items; }); } diff --git a/src/renderer/api/endpoints/resource-quota.api.ts b/src/renderer/api/endpoints/resource-quota.api.ts index a19e4025c5..e2d37a9081 100644 --- a/src/renderer/api/endpoints/resource-quota.api.ts +++ b/src/renderer/api/endpoints/resource-quota.api.ts @@ -58,6 +58,7 @@ export class ResourceQuota extends KubeObject { getScopeSelector() { const { matchExpressions = [] } = this.spec.scopeSelector || {}; + return matchExpressions; } } diff --git a/src/renderer/api/endpoints/selfsubjectrulesreviews.api.ts b/src/renderer/api/endpoints/selfsubjectrulesreviews.api.ts index 149a94e678..f47fc29a4e 100644 --- a/src/renderer/api/endpoints/selfsubjectrulesreviews.api.ts +++ b/src/renderer/api/endpoints/selfsubjectrulesreviews.api.ts @@ -38,16 +38,19 @@ export class SelfSubjectRulesReview extends KubeObject { getResourceRules() { const rules = this.status && this.status.resourceRules || []; + return rules.map(rule => this.normalize(rule)); } getNonResourceRules() { const rules = this.status && this.status.nonResourceRules || []; + return rules.map(rule => this.normalize(rule)); } protected normalize(rule: ISelfSubjectReviewRule): ISelfSubjectReviewRule { const { apiGroups = [], resourceNames = [], verbs = [], nonResourceURLs = [], resources = [] } = rule; + return { apiGroups, nonResourceURLs, @@ -56,6 +59,7 @@ export class SelfSubjectRulesReview extends KubeObject { resources: resources.map((resource, index) => { const apiGroup = apiGroups.length >= index + 1 ? apiGroups[index] : apiGroups.slice(-1)[0]; const separator = apiGroup == "" ? "" : "."; + return resource + separator + apiGroup; }) }; diff --git a/src/renderer/api/endpoints/service.api.ts b/src/renderer/api/endpoints/service.api.ts index 219e02ab5d..6c02873139 100644 --- a/src/renderer/api/endpoints/service.api.ts +++ b/src/renderer/api/endpoints/service.api.ts @@ -61,9 +61,11 @@ export class Service extends KubeObject { getExternalIps() { const lb = this.getLoadBalancer(); + if (lb && lb.ingress) { return lb.ingress.map(val => val.ip || val.hostname); } + return this.spec.externalIPs || []; } @@ -73,11 +75,13 @@ export class Service extends KubeObject { getSelector(): string[] { if (!this.spec.selector) return []; + return Object.entries(this.spec.selector).map(val => val.join("=")); } getPorts(): ServicePort[] { const ports = this.spec.ports || []; + return ports.map(p => new ServicePort(p)); } diff --git a/src/renderer/api/endpoints/stateful-set.api.ts b/src/renderer/api/endpoints/stateful-set.api.ts index 72c2ea5776..add2a554ba 100644 --- a/src/renderer/api/endpoints/stateful-set.api.ts +++ b/src/renderer/api/endpoints/stateful-set.api.ts @@ -102,6 +102,7 @@ export class StatefulSet extends WorkloadKubeObject { getImages() { const containers: IPodContainer[] = get(this, "spec.template.spec.containers", []); + return [...containers].map(container => container.image); } } diff --git a/src/renderer/api/endpoints/storage-class.api.ts b/src/renderer/api/endpoints/storage-class.api.ts index adb2059e4a..085701742c 100644 --- a/src/renderer/api/endpoints/storage-class.api.ts +++ b/src/renderer/api/endpoints/storage-class.api.ts @@ -18,6 +18,7 @@ export class StorageClass extends KubeObject { isDefault() { const annotations = this.metadata.annotations || {}; + return ( annotations["storageclass.kubernetes.io/is-default-class"] === "true" || annotations["storageclass.beta.kubernetes.io/is-default-class"] === "true" diff --git a/src/renderer/api/json-api.ts b/src/renderer/api/json-api.ts index 177aa3c9c2..49c2cb1a28 100644 --- a/src/renderer/api/json-api.ts +++ b/src/renderer/api/json-api.ts @@ -75,11 +75,14 @@ export class JsonApi { let reqUrl = this.config.apiBase + path; const reqInit: RequestInit = { ...this.reqInit, ...init }; const { data, query } = params || {} as P; + if (data && !reqInit.body) { reqInit.body = JSON.stringify(data); } + if (query) { const queryString = stringify(query); + reqUrl += (reqUrl.includes("?") ? "&" : "?") + queryString; } const infoLog: JsonApiLog = { @@ -87,6 +90,7 @@ export class JsonApi { reqUrl, reqInit, }; + return cancelableFetch(reqUrl, reqInit).then(res => { return this.parseResponse(res, infoLog); }); @@ -94,21 +98,26 @@ export class JsonApi { protected parseResponse(res: Response, log: JsonApiLog): Promise { const { status } = res; + return res.text().then(text => { let data; + try { data = text ? JSON.parse(text) : ""; // DELETE-requests might not have response-body } catch (e) { data = text; } + if (status >= 200 && status < 300) { this.onData.emit(data, res); this.writeLog({ ...log, data }); + return data; } else if (log.method === "GET" && res.status === 403) { this.writeLog({ ...log, data }); } else { const error = new JsonApiErrorParsed(data, this.parseError(data, res)); + this.onError.emit(error, res); this.writeLog({ ...log, error }); throw error; @@ -126,6 +135,7 @@ export class JsonApi { else if (error.message) { return [error.message]; } + return [res.statusText || "Error!"]; } @@ -133,6 +143,7 @@ export class JsonApi { if (!this.config.debug) return; const { method, reqUrl, ...params } = log; let textStyle = "font-weight: bold;"; + if (params.data) textStyle += "background: green; color: white;"; if (params.error) textStyle += "background: red; color: white;"; console.log(`%c${method} ${reqUrl}`, textStyle, params); diff --git a/src/renderer/api/kube-api-parse.ts b/src/renderer/api/kube-api-parse.ts index 174fdae918..285610bece 100644 --- a/src/renderer/api/kube-api-parse.ts +++ b/src/renderer/api/kube-api-parse.ts @@ -29,7 +29,6 @@ export function parseKubeApi(path: string): IKubeApiParsed { path = new URL(path, location.origin).pathname; const [, prefix, ...parts] = path.split("/"); const apiPrefix = `/${prefix}`; - const [left, right, namespaced] = splitArray(parts, "namespaces"); let apiGroup, apiVersion, namespace, resource, name; @@ -107,9 +106,11 @@ export function parseKubeApi(path: string): IKubeApiParsed { export function createKubeApiURL(ref: IKubeApiLinkRef): string { const { apiPrefix = "/apis", resource, apiVersion, name } = ref; let { namespace } = ref; + if (namespace) { namespace = `namespaces/${namespace}`; } + return [apiPrefix, apiVersion, namespace, resource, name] .filter(v => v) .join("/"); @@ -125,6 +126,7 @@ export function lookupApiLink(ref: IKubeObjectRef, parentObject: KubeObject): st // search in registered apis by 'kind' & 'apiVersion' const api = apiManager.getApi(api => api.kind === kind && api.apiVersionWithGroup == apiVersion); + if (api) { return api.getUrl({ namespace, name }); } @@ -132,8 +134,10 @@ export function lookupApiLink(ref: IKubeObjectRef, parentObject: KubeObject): st // lookup api by generated resource link const apiPrefixes = ["/apis", "/api"]; const resource = kind.endsWith("s") ? `${kind.toLowerCase()}es` : `${kind.toLowerCase()}s`; + for (const apiPrefix of apiPrefixes) { const apiLink = createKubeApiURL({ apiPrefix, apiVersion, name, namespace, resource }); + if (apiManager.getApi(apiLink)) { return apiLink; } @@ -141,6 +145,7 @@ export function lookupApiLink(ref: IKubeObjectRef, parentObject: KubeObject): st // resolve by kind only (hpa's might use refs to older versions of resources for example) const apiByKind = apiManager.getApi(api => api.kind === kind); + if (apiByKind) { return apiByKind.getUrl({ name, namespace }); } diff --git a/src/renderer/api/kube-api.ts b/src/renderer/api/kube-api.ts index 3ba1f37f8a..e7934675c6 100644 --- a/src/renderer/api/kube-api.ts +++ b/src/renderer/api/kube-api.ts @@ -72,6 +72,7 @@ export function forCluster(cluster: IKubeApiCluster, kubeC "X-Cluster-ID": cluster.id } }); + return new KubeApi({ objectConstructor: kubeClass, request @@ -83,6 +84,7 @@ export class KubeApi { static watchAll(...apis: KubeApi[]) { const disposers = apis.map(api => api.watch()); + return () => disposers.forEach(unwatch => unwatch()); } @@ -106,6 +108,7 @@ export class KubeApi { kind = options.objectConstructor?.kind, isNamespaced = options.objectConstructor?.namespaced } = options || {}; + if (!options.apiBase) { options.apiBase = objectConstructor.apiBase; } @@ -205,6 +208,7 @@ export class KubeApi { }); const res = await this.request.get(`${this.apiPrefix}/${this.apiGroup}`); + Object.defineProperty(this, "apiVersionPreferred", { value: res?.preferredVersion?.version ?? null, }); @@ -236,6 +240,7 @@ export class KubeApi { namespace: this.isNamespaced ? namespace : undefined, name, }); + return resourcePath + (query ? `?${stringify(this.normalizeQuery(query))}` : ""); } @@ -243,14 +248,17 @@ export class KubeApi { if (query.labelSelector) { query.labelSelector = [query.labelSelector].flat().join(","); } + if (query.fieldSelector) { query.fieldSelector = [query.fieldSelector].flat().join(","); } + return query; } protected parseResponse(data: KubeJsonApiData | KubeJsonApiData[] | KubeJsonApiDataList, namespace?: string): any { const KubeObjectConstructor = this.objectConstructor; + if (KubeObject.isJsonApiData(data)) { return new KubeObjectConstructor(data); } @@ -258,8 +266,10 @@ export class KubeApi { // process items list response if (KubeObject.isJsonApiDataList(data)) { const { apiVersion, items, metadata } = data; + this.setResourceVersion(namespace, metadata.resourceVersion); this.setResourceVersion("", metadata.resourceVersion); + return items.map(item => new KubeObjectConstructor({ kind: this.kind, apiVersion, @@ -277,6 +287,7 @@ export class KubeApi { async list({ namespace = "" } = {}, query?: IKubeApiQueryParams): Promise { await this.checkPreferredVersion(); + return this.request .get(this.getUrl({ namespace }), { query }) .then(data => this.parseResponse(data, namespace)); @@ -284,6 +295,7 @@ export class KubeApi { async get({ name = "", namespace = "default" } = {}, query?: IKubeApiQueryParams): Promise { await this.checkPreferredVersion(); + return this.request .get(this.getUrl({ namespace, name }), { query }) .then(this.parseResponse); @@ -310,6 +322,7 @@ export class KubeApi { async update({ name = "", namespace = "default" } = {}, data?: Partial): Promise { await this.checkPreferredVersion(); const apiUrl = this.getUrl({ namespace, name }); + return this.request .put(apiUrl, { data }) .then(this.parseResponse); @@ -318,6 +331,7 @@ export class KubeApi { async delete({ name = "", namespace = "default" }) { await this.checkPreferredVersion(); const apiUrl = this.getUrl({ namespace, name }); + return this.request.del(apiUrl); } diff --git a/src/renderer/api/kube-json-api.ts b/src/renderer/api/kube-json-api.ts index ce7da50826..3026a9a956 100644 --- a/src/renderer/api/kube-json-api.ts +++ b/src/renderer/api/kube-json-api.ts @@ -45,9 +45,11 @@ export interface KubeJsonApiError extends JsonApiError { export class KubeJsonApi extends JsonApi { protected parseError(error: KubeJsonApiError | any, res: Response): string[] { const { status, reason, message } = error; + if (status && reason) { return [message || `${status}: ${reason}`]; } + return super.parseError(error, res); } } diff --git a/src/renderer/api/kube-object.ts b/src/renderer/api/kube-object.ts index 8d0e6123f3..08bd6401b9 100644 --- a/src/renderer/api/kube-object.ts +++ b/src/renderer/api/kube-object.ts @@ -65,6 +65,7 @@ export class KubeObject implements ItemObject { static stringifyLabels(labels: { [name: string]: string }): string[] { if (!labels) return []; + return Object.entries(labels).map(([name, value]) => `${name}=${value}`); } @@ -104,9 +105,11 @@ export class KubeObject implements ItemObject { return moment(this.metadata.creationTimestamp).fromNow(); } const diff = new Date().getTime() - new Date(this.metadata.creationTimestamp).getTime(); + if (humanize) { return formatDuration(diff, compact); } + return diff; } @@ -120,14 +123,17 @@ export class KubeObject implements ItemObject { getAnnotations(filter = false): string[] { const labels = KubeObject.stringifyLabels(this.metadata.annotations); + return filter ? labels.filter(label => { const skip = resourceApplierApi.annotations.some(key => label.startsWith(key)); + return !skip; }) : labels; } getOwnerRefs() { const refs = this.metadata.ownerReferences || []; + return refs.map(ownerRef => ({ ...ownerRef, namespace: this.getNs(), @@ -136,6 +142,7 @@ export class KubeObject implements ItemObject { getSearchFields() { const { getName, getId, getNs, getAnnotations, getLabels } = this; + return [ getName(), getNs(), diff --git a/src/renderer/api/kube-watch-api.ts b/src/renderer/api/kube-watch-api.ts index 8d5a318c3c..58665a11a1 100644 --- a/src/renderer/api/kube-watch-api.ts +++ b/src/renderer/api/kube-watch-api.ts @@ -53,8 +53,10 @@ export class KubeWatchApi { apis.forEach(api => { this.subscribers.set(api, this.getSubscribersCount(api) + 1); }); + return () => apis.forEach(api => { const count = this.getSubscribersCount(api) - 1; + if (count <= 0) this.subscribers.delete(api); else this.subscribers.set(api, count); }); @@ -62,9 +64,11 @@ export class KubeWatchApi { protected getQuery(): Partial { const { isAdmin, allowedNamespaces } = getHostedCluster(); + return { api: this.activeApis.map(api => { if (isAdmin) return api.getWatchUrl(); + return allowedNamespaces.map(namespace => api.getWatchUrl(namespace)); }).flat() }; @@ -74,11 +78,13 @@ export class KubeWatchApi { @autobind() protected connect() { if (this.evtSource) this.disconnect(); // close previous connection + if (!this.activeApis.length) { return; } const query = this.getQuery(); const apiUrl = `${apiPrefix}/watch?${stringify(query)}`; + this.evtSource = new EventSource(apiUrl); this.evtSource.onmessage = this.onMessage; this.evtSource.onerror = this.onError; @@ -102,6 +108,7 @@ export class KubeWatchApi { protected onMessage(evt: MessageEvent) { if (!evt.data) return; const data = JSON.parse(evt.data); + if ((data as IKubeWatchEvent).object) { this.onData.emit(data); } else { @@ -114,12 +121,14 @@ export class KubeWatchApi { this.disconnect(); const { apiBase, namespace } = KubeApi.parseApi(event.url); const api = apiManager.getApi(apiBase); + if (api) { try { await api.refreshResourceVersion({ namespace }); this.reconnect(); } catch (error) { console.error("failed to refresh resource version", error); + if (this.subscribers.size > 0) { setTimeout(() => { this.onRouteEvent(event); @@ -132,6 +141,7 @@ export class KubeWatchApi { protected onError(evt: MessageEvent) { const { reconnectAttempts: attemptsRemain, reconnectTimeoutMs } = this; + if (evt.eventPhase === EventSource.CLOSED) { if (attemptsRemain > 0) { this.reconnectAttempts--; @@ -150,13 +160,17 @@ export class KubeWatchApi { const listener = (evt: IKubeWatchEvent) => { const { selfLink, namespace, resourceVersion } = evt.object.metadata; const api = apiManager.getApi(selfLink); + api.setResourceVersion(namespace, resourceVersion); api.setResourceVersion("", resourceVersion); + if (store == apiManager.getStore(api)) { callback(evt); } }; + this.onData.addListener(listener); + return () => this.onData.removeListener(listener); } diff --git a/src/renderer/api/terminal-api.ts b/src/renderer/api/terminal-api.ts index 39a86faae5..1a94052586 100644 --- a/src/renderer/api/terminal-api.ts +++ b/src/renderer/api/terminal-api.ts @@ -50,26 +50,32 @@ export class TerminalApi extends WebSocketApi { const { id, node } = this.options; const wss = `ws${protocol === "https:" ? "s" : ""}://`; const query: TerminalApiQuery = { id }; + if (port) { port = `:${port}`; } + if (node) { query.node = node; query.type = "node"; } + return `${wss}${hostname}${port}/api?${stringify(query)}`; } async connect() { const apiUrl = await this.getUrl(); + this.emitStatus("Connecting ..."); this.onData.addListener(this._onReady, { prepend: true }); + return super.connect(apiUrl); } destroy() { if (!this.socket) return; const exitCode = String.fromCharCode(4); // ctrl+d + this.sendCommand(exitCode); setTimeout(() => super.destroy(), 2000); } @@ -87,6 +93,7 @@ export class TerminalApi extends WebSocketApi { this.onData.removeListener(this._onReady); this.flush(); this.onData.emit(data); // re-emit data + return false; // prevent calling rest of listeners } @@ -100,6 +107,7 @@ export class TerminalApi extends WebSocketApi { sendTerminalSize(cols: number, rows: number) { const newSize = { Width: cols, Height: rows }; + if (!isEqual(this.size, newSize)) { this.sendCommand(JSON.stringify(newSize), TerminalChannels.TERMINAL_SIZE); this.size = newSize; @@ -108,6 +116,7 @@ export class TerminalApi extends WebSocketApi { protected parseMessage(data: string) { data = data.substr(1); // skip channel + return base64.decode(data); } @@ -125,10 +134,12 @@ export class TerminalApi extends WebSocketApi { protected emitStatus(data: string, options: { color?: TerminalColor; showTime?: boolean } = {}) { const { color, showTime } = options; + if (color) { data = `${color}${data}${TerminalColor.NO_COLOR}`; } let time; + if (showTime) { time = `${(new Date()).toLocaleString()} `; } diff --git a/src/renderer/api/websocket-api.ts b/src/renderer/api/websocket-api.ts index b9e0c22c96..79a92cf99e 100644 --- a/src/renderer/api/websocket-api.ts +++ b/src/renderer/api/websocket-api.ts @@ -47,9 +47,11 @@ export class WebSocketApi { constructor(protected params: IParams) { this.params = Object.assign({}, WebSocketApi.defaultParams, params); const { autoConnect, pingIntervalSeconds } = this.params; + if (autoConnect) { setTimeout(() => this.connect()); } + if (pingIntervalSeconds) { this.pingTimer = setInterval(() => this.ping(), pingIntervalSeconds * 1000); } @@ -57,6 +59,7 @@ export class WebSocketApi { get isConnected() { const state = this.socket ? this.socket.readyState : -1; + return state === WebSocket.OPEN && this.isOnline; } @@ -87,6 +90,7 @@ export class WebSocketApi { reconnect() { const { reconnectDelaySeconds } = this.params; + if (!reconnectDelaySeconds) return; this.writeLog("reconnect after", `${reconnectDelaySeconds}ms`); this.reconnectTimer = setTimeout(() => this.connect(), reconnectDelaySeconds * 1000); @@ -115,6 +119,7 @@ export class WebSocketApi { id: (Math.random() * Date.now()).toString(16).replace(".", ""), data: command, }; + if (this.isConnected) { this.socket.send(msg.data); } @@ -141,6 +146,7 @@ export class WebSocketApi { protected _onMessage(evt: MessageEvent) { const data = this.parseMessage(evt.data); + this.onData.emit(data); this.writeLog("%cMESSAGE", "color:black;font-weight:bold;", data); } @@ -151,6 +157,7 @@ export class WebSocketApi { protected _onClose(evt: CloseEvent) { const error = evt.code !== 1000 || !evt.wasClean; + if (error) { this.reconnect(); } diff --git a/src/renderer/api/workload-kube-object.ts b/src/renderer/api/workload-kube-object.ts index 51c0461f15..e0c6d3f121 100644 --- a/src/renderer/api/workload-kube-object.ts +++ b/src/renderer/api/workload-kube-object.ts @@ -51,16 +51,19 @@ export class WorkloadKubeObject extends KubeObject { getSelectors(): string[] { const selector = this.spec.selector; + return KubeObject.stringifyLabels(selector ? selector.matchLabels : null); } getNodeSelectors(): string[] { const nodeSelector = get(this, "spec.template.spec.nodeSelector"); + return KubeObject.stringifyLabels(nodeSelector); } getTemplateLabels(): string[] { const labels = get(this, "spec.template.metadata.labels"); + return KubeObject.stringifyLabels(labels); } @@ -74,7 +77,9 @@ export class WorkloadKubeObject extends KubeObject { getAffinityNumber() { const affinity = this.getAffinity(); + if (!affinity) return 0; + return Object.keys(affinity).length; } } \ No newline at end of file diff --git a/src/renderer/bootstrap.tsx b/src/renderer/bootstrap.tsx index ef22e72736..f2369df0fd 100644 --- a/src/renderer/bootstrap.tsx +++ b/src/renderer/bootstrap.tsx @@ -30,6 +30,7 @@ export { export async function bootstrap(App: AppComponent) { const rootElem = document.getElementById("app"); + rootElem.classList.toggle("is-mac", isMac); extensionLoader.init(); diff --git a/src/renderer/components/+add-cluster/add-cluster.tsx b/src/renderer/components/+add-cluster/add-cluster.tsx index e18a655f97..5122dfe02f 100644 --- a/src/renderer/components/+add-cluster/add-cluster.tsx +++ b/src/renderer/components/+add-cluster/add-cluster.tsx @@ -68,6 +68,7 @@ export class AddCluster extends React.Component { Notifications.error(

Can't setup {filePath} as kubeconfig: {String(err)}
); + if (throwError) { throw err; } @@ -82,12 +83,14 @@ export class AddCluster extends React.Component { switch (this.sourceTab) { case KubeConfigSourceTab.FILE: const contexts = this.getContexts(this.kubeConfigLocal); + this.kubeContexts.replace(contexts); break; case KubeConfigSourceTab.TEXT: try { this.error = ""; const contexts = this.getContexts(loadConfig(this.customConfig || "{}")); + this.kubeContexts.replace(contexts); } catch (err) { this.error = String(err); @@ -102,9 +105,11 @@ export class AddCluster extends React.Component { getContexts(config: KubeConfig): Map { const contexts = new Map(); + splitConfig(config).forEach(config => { contexts.set(config.currentContext, config); }); + return contexts; } @@ -116,6 +121,7 @@ export class AddCluster extends React.Component { message: _i18n._(t`Select custom kubeconfig file`), buttonLabel: _i18n._(t`Use configuration`), }); + if (!canceled && filePaths.length) { this.setKubeConfig(filePaths[0]); } @@ -129,9 +135,11 @@ export class AddCluster extends React.Component { @action addClusters = () => { let newClusters: ClusterModel[] = []; + try { if (!this.selectedContexts.length) { this.error = Please select at least one cluster context; + return; } this.error = ""; @@ -140,12 +148,16 @@ export class AddCluster extends React.Component { newClusters = this.selectedContexts.filter(context => { try { const kubeConfig = this.kubeContexts.get(context); + validateKubeConfig(kubeConfig); + return true; } catch (err) { this.error = String(err.message); + if (err instanceof ExecValidationNotFoundError) { Notifications.error(Error while adding cluster(s): {this.error}); + return false; } else { throw new Error(err); @@ -157,6 +169,7 @@ export class AddCluster extends React.Component { const kubeConfigPath = this.sourceTab === KubeConfigSourceTab.FILE ? this.kubeConfigPath // save link to original kubeconfig in file-system : ClusterStore.embedCustomKubeConfig(clusterId, kubeConfig); // save in app-files folder + return { id: clusterId, kubeConfigPath, @@ -171,8 +184,10 @@ export class AddCluster extends React.Component { runInAction(() => { clusterStore.addClusters(...newClusters); + if (newClusters.length === 1) { const clusterId = newClusters[0].id; + clusterStore.setActive(clusterId); navigate(clusterViewURL({ params: { clusterId } })); } else { @@ -271,6 +286,7 @@ export class AddCluster extends React.Component { const placeholder = this.selectedContexts.length > 0 ? Selected contexts: {this.selectedContexts.length} : Select contexts; + return (
{ const { ...dialogProps } = this.props; const { namespace, name, type } = this; const header =
Create Secret
; + return ( { disposeOnUnmount(this, [ autorun(() => { const { object: secret } = this.props; + if (secret) { this.data = secret.data; this.revealSecret = {}; @@ -41,7 +42,9 @@ export class SecretDetails extends React.Component { saveSecret = async () => { const { object: secret } = this.props; + this.isSaving = true; + try { await secretsStore.update(secret, { ...secret, data: this.data }); Notifications.ok(Secret successfully updated.); @@ -57,7 +60,9 @@ export class SecretDetails extends React.Component { render() { const { object: secret } = this.props; + if (!secret) return null; + return (
@@ -71,12 +76,14 @@ export class SecretDetails extends React.Component { Object.entries(this.data).map(([name, value]) => { const revealSecret = this.revealSecret[name]; let decodedVal = ""; + try { decodedVal = base64.decode(value); } catch { decodedVal = ""; } value = revealSecret ? decodedVal : value; + return (
{name}
diff --git a/src/renderer/components/+config/config.tsx b/src/renderer/components/+config/config.tsx index 70fa04d9b1..0d26baf812 100644 --- a/src/renderer/components/+config/config.tsx +++ b/src/renderer/components/+config/config.tsx @@ -15,6 +15,7 @@ export class Config extends React.Component { static get tabRoutes(): TabLayoutRoute[] { const query = namespaceStore.getContextParams(); const routes: TabLayoutRoute[] = []; + if (isAllowedResource("configmaps")) { routes.push({ title: ConfigMaps, @@ -23,6 +24,7 @@ export class Config extends React.Component { routePath: configMapsRoute.path.toString(), }); } + if (isAllowedResource("secrets")) { routes.push({ title: Secrets, @@ -31,6 +33,7 @@ export class Config extends React.Component { routePath: secretsRoute.path.toString(), }); } + if (isAllowedResource("resourcequotas")) { routes.push({ title: Resource Quotas, @@ -39,6 +42,7 @@ export class Config extends React.Component { routePath: resourceQuotaRoute.path.toString(), }); } + if (isAllowedResource("horizontalpodautoscalers")) { routes.push({ title: HPA, @@ -47,6 +51,7 @@ export class Config extends React.Component { routePath: hpaRoute.path.toString(), }); } + if (isAllowedResource("poddisruptionbudgets")) { routes.push({ title: Pod Disruption Budgets, @@ -55,6 +60,7 @@ export class Config extends React.Component { routePath: pdbRoute.path.toString(), }); } + return routes; } diff --git a/src/renderer/components/+custom-resources/crd-details.tsx b/src/renderer/components/+custom-resources/crd-details.tsx index 2ab52b2bad..f542a165fd 100644 --- a/src/renderer/components/+custom-resources/crd-details.tsx +++ b/src/renderer/components/+custom-resources/crd-details.tsx @@ -22,10 +22,12 @@ interface Props extends KubeObjectDetailsProps { export class CRDDetails extends React.Component { render() { const { object: crd } = this.props; + if (!crd) return null; const { plural, singular, kind, listKind } = crd.getNames(); const printerColumns = crd.getPrinterColumns(); const validation = crd.getValidation(); + return (
@@ -60,6 +62,7 @@ export class CRDDetails extends React.Component { { crd.getConditions().map(condition => { const { type, message, lastTransitionTime, status } = condition; + return ( { { printerColumns.map((column, index) => { const { name, type, jsonPath } = column; + return ( {name} diff --git a/src/renderer/components/+custom-resources/crd-list.tsx b/src/renderer/components/+custom-resources/crd-list.tsx index 1d91a1738b..83a05250a0 100644 --- a/src/renderer/components/+custom-resources/crd-list.tsx +++ b/src/renderer/components/+custom-resources/crd-list.tsx @@ -30,6 +30,7 @@ export class CrdList extends React.Component { onGroupChange(group: string) { const groups = [...this.groups]; const index = groups.findIndex(item => item == group); + if (index !== -1) groups.splice(index, 1); else groups.push(group); setQueryParams({ groups }); @@ -43,6 +44,7 @@ export class CrdList extends React.Component { [sortBy.version]: (crd: CustomResourceDefinition) => crd.getVersion(), [sortBy.scope]: (crd: CustomResourceDefinition) => crd.getScope(), }; + return ( Custom Resources} customizeHeader={() => { let placeholder = All groups; + if (selectedGroups.length == 1) placeholder = <>Group: {selectedGroups[0]}; if (selectedGroups.length >= 2) placeholder = <>Groups: {selectedGroups.join(", ")}; + return { // todo: move to global filters filters: ( @@ -71,6 +75,7 @@ export class CrdList extends React.Component { controlShouldRenderValue={false} formatOptionLabel={({ value: group }: SelectOption) => { const isSelected = selectedGroups.includes(group); + return (
diff --git a/src/renderer/components/+custom-resources/crd-resource-details.tsx b/src/renderer/components/+custom-resources/crd-resource-details.tsx index d04962c0dc..6610fb4b56 100644 --- a/src/renderer/components/+custom-resources/crd-resource-details.tsx +++ b/src/renderer/components/+custom-resources/crd-resource-details.tsx @@ -53,6 +53,7 @@ export class CrdResourceDetails extends React.Component { renderStatus(crd: CustomResourceDefinition, columns: AdditionalPrinterColumnsV1[]) { const showStatus = !columns.find(column => column.name == "Status") && crd.status?.conditions; + if (!showStatus) { return null; } @@ -77,6 +78,7 @@ export class CrdResourceDetails extends React.Component { render() { const { props: { object }, crd } = this; + if (!object || !crd) { return null; } diff --git a/src/renderer/components/+custom-resources/crd-resources.tsx b/src/renderer/components/+custom-resources/crd-resources.tsx index 6afe40ae3c..a4a52ef867 100644 --- a/src/renderer/components/+custom-resources/crd-resources.tsx +++ b/src/renderer/components/+custom-resources/crd-resources.tsx @@ -28,6 +28,7 @@ export class CrdResources extends React.Component { disposeOnUnmount(this, [ autorun(() => { const { store } = this; + if (store && !store.isLoading && !store.isLoaded) { store.loadAll(); } @@ -37,16 +38,19 @@ export class CrdResources extends React.Component { @computed get crd() { const { group, name } = this.props.match.params; + return crdStore.getByGroup(group, name); } @computed get store() { if (!this.crd) return null; + return apiManager.getStore(this.crd.getResourceApiBase()); } render() { const { crd, store } = this; + if (!crd) return null; const isNamespaced = crd.isNamespaced(); const extraColumns = crd.getPrinterColumns(false); // Cols with priority bigger than 0 are shown in details @@ -55,6 +59,7 @@ export class CrdResources extends React.Component { [sortBy.namespace]: (item: KubeObject) => item.getNs(), [sortBy.age]: (item: KubeObject) => item.metadata.creationTimestamp, }; + extraColumns.forEach(column => { sortingCallbacks[column.name] = (item: KubeObject) => jsonPath.value(item, column.jsonPath.slice(1)); }); @@ -74,6 +79,7 @@ export class CrdResources extends React.Component { isNamespaced && { title: Namespace, className: "namespace", sortBy: sortBy.namespace }, ...extraColumns.map(column => { const { name } = column; + return { title: name, className: name.toLowerCase(), diff --git a/src/renderer/components/+custom-resources/crd.store.ts b/src/renderer/components/+custom-resources/crd.store.ts index 54e1d3df66..64aefc1fe1 100644 --- a/src/renderer/components/+custom-resources/crd.store.ts +++ b/src/renderer/components/+custom-resources/crd.store.ts @@ -38,17 +38,22 @@ export class CRDStore extends KubeObjectStore { @computed get groups() { const groups: Record = {}; + return this.items.reduce((groups, crd) => { const group = crd.getGroup(); + if (!groups[group]) groups[group] = []; groups[group].push(crd); + return groups; }, groups); } getByGroup(group: string, pluralName: string) { const crdInGroup = this.groups[group]; + if (!crdInGroup) return null; + return crdInGroup.find(crd => crd.getPluralName() === pluralName); } diff --git a/src/renderer/components/+events/event-details.tsx b/src/renderer/components/+events/event-details.tsx index 3d6f51ab0b..d514d67521 100644 --- a/src/renderer/components/+events/event-details.tsx +++ b/src/renderer/components/+events/event-details.tsx @@ -21,9 +21,11 @@ interface Props extends KubeObjectDetailsProps { export class EventDetails extends React.Component { render() { const { object: event } = this.props; + if (!event) return; const { message, reason, count, type, involvedObject } = event; const { kind, name, namespace, fieldPath } = involvedObject; + return (
diff --git a/src/renderer/components/+events/event.store.ts b/src/renderer/components/+events/event.store.ts index 484c9701c1..39d4d5df83 100644 --- a/src/renderer/components/+events/event.store.ts +++ b/src/renderer/components/+events/event.store.ts @@ -28,6 +28,7 @@ export class EventStore extends KubeObjectStore { if(obj.kind == "Node") { return obj.getName() == evt.involvedObject.uid && evt.involvedObject.kind == "Node"; } + return obj.getId() == evt.involvedObject.uid; }); } @@ -38,12 +39,16 @@ export class EventStore extends KubeObjectStore { const eventsWithError = Object.values(groupsByInvolvedObject).map(events => { const recent = events[0]; const { kind, uid } = recent.involvedObject; + if (kind == Pod.kind) { // Wipe out running pods const pod = podsStore.items.find(pod => pod.getId() == uid); + if (!pod || (!pod.hasIssues() && pod.spec.priority < 500000)) return; } + return recent; }); + return compact(eventsWithError); } } diff --git a/src/renderer/components/+events/events.tsx b/src/renderer/components/+events/events.tsx index f50f2adcb9..3d6977c656 100644 --- a/src/renderer/components/+events/events.tsx +++ b/src/renderer/components/+events/events.tsx @@ -88,6 +88,7 @@ export class Events extends React.Component { const tooltipId = `message-${event.getId()}`; const isWarning = type === "Warning"; const detailsUrl = getDetailsUrl(lookupApiLink(involvedObject, event)); + return [ { className: { warning: isWarning }, @@ -114,9 +115,11 @@ export class Events extends React.Component { ]} /> ); + if (compact) { return events; } + return ( {events} diff --git a/src/renderer/components/+events/kube-event-details.tsx b/src/renderer/components/+events/kube-event-details.tsx index 297c4e791f..a7da4f5276 100644 --- a/src/renderer/components/+events/kube-event-details.tsx +++ b/src/renderer/components/+events/kube-event-details.tsx @@ -21,6 +21,7 @@ export class KubeEventDetails extends React.Component { render() { const { object } = this.props; const events = eventStore.getEventsByObject(object); + if (!events.length) { return ( @@ -28,6 +29,7 @@ export class KubeEventDetails extends React.Component { ); } + return (
@@ -36,6 +38,7 @@ export class KubeEventDetails extends React.Component {
{events.map(evt => { const { message, count, lastTimestamp, involvedObject } = evt; + return (
diff --git a/src/renderer/components/+events/kube-event-icon.tsx b/src/renderer/components/+events/kube-event-icon.tsx index 15a91ea5b3..d23b331ead 100644 --- a/src/renderer/components/+events/kube-event-icon.tsx +++ b/src/renderer/components/+events/kube-event-icon.tsx @@ -24,11 +24,14 @@ export class KubeEventIcon extends React.Component { const { object, showWarningsOnly, filterEvents } = this.props; const events = eventStore.getEventsByObject(object); let warnings = events.filter(evt => evt.isWarning()); + if (filterEvents) warnings = filterEvents(warnings); + if (!events.length || (showWarningsOnly && !warnings.length)) { return null; } const event = [...warnings, ...events][0]; // get latest event + return ( { const { name, description } = ext.manifest; + return [ name.toLowerCase().includes(searchText), description?.toLowerCase().includes(searchText), @@ -149,6 +150,7 @@ export class Extensions extends React.Component { installFromUrlOrPath = async () => { const { installPath } = this; + if (!installPath) return; const fileName = path.basename(installPath); @@ -158,6 +160,7 @@ export class Extensions extends React.Component { if (InputValidators.isUrl.validate(installPath)) { const { promise: filePromise } = downloadFile({ url: installPath, timeout: 60000 /*1m*/ }); const data = await filePromise; + this.requestInstall({ fileName, data }); } // otherwise installing from system path @@ -173,6 +176,7 @@ export class Extensions extends React.Component { installOnDrop = (files: File[]) => { logger.info("Install from D&D"); + return this.requestInstall( files.map(file => ({ fileName: path.basename(file.path), @@ -190,8 +194,10 @@ export class Extensions extends React.Component { .map(async request => { try { const data = await fse.readFile(request.filePath); + request.data = data; preloadedRequests.push(request); + return request; } catch(error) { if (showError) { @@ -209,6 +215,7 @@ export class Extensions extends React.Component { // tarball from npm contains single root folder "package/*" const firstFile = tarFiles[0]; + if (!firstFile) { throw new Error(`invalid extension bundle, ${manifestFilename} not found`); } @@ -230,6 +237,7 @@ export class Extensions extends React.Component { if (!manifest.lens && !manifest.renderer) { throw new Error(`${manifestFilename} must specify "main" and/or "renderer" fields`); } + return manifest; } @@ -241,6 +249,7 @@ export class Extensions extends React.Component { requests.forEach(req => { const tempFile = this.getExtensionPackageTemp(req.fileName); + fse.writeFileSync(tempFile, req.data); }); @@ -248,6 +257,7 @@ export class Extensions extends React.Component { await Promise.all( requests.map(async req => { const tempFile = this.getExtensionPackageTemp(req.fileName); + try { const manifest = await this.validatePackage(tempFile); @@ -270,6 +280,7 @@ export class Extensions extends React.Component { } }) ); + return validatedRequests; } @@ -342,6 +353,7 @@ export class Extensions extends React.Component { Notifications.error(

Installing extension {displayName} has failed: {error}

); + // Remove install state on install failure if (this.extensionState.get(extensionId)?.state === "installing") { this.extensionState.delete(extensionId); @@ -378,6 +390,7 @@ export class Extensions extends React.Component { Notifications.error(

Uninstalling extension {displayName} has failed: {error?.message ?? ""}

); + // Remove uninstall state on uninstall failure if (this.extensionState.get(extension.id)?.state === "uninstalling") { this.extensionState.delete(extension.id); diff --git a/src/renderer/components/+landing-page/landing-page.tsx b/src/renderer/components/+landing-page/landing-page.tsx index 5c1e34297c..a1606e7499 100644 --- a/src/renderer/components/+landing-page/landing-page.tsx +++ b/src/renderer/components/+landing-page/landing-page.tsx @@ -14,6 +14,7 @@ export class LandingPage extends React.Component { const clusters = clusterStore.getByWorkspaceId(workspaceStore.currentWorkspaceId); const noClustersInScope = !clusters.length; const showStartupHint = this.showHint && noClustersInScope; + return (
{showStartupHint && ( diff --git a/src/renderer/components/+namespaces/add-namespace-dialog.tsx b/src/renderer/components/+namespaces/add-namespace-dialog.tsx index a31a44de94..6097a3089f 100644 --- a/src/renderer/components/+namespaces/add-namespace-dialog.tsx +++ b/src/renderer/components/+namespaces/add-namespace-dialog.tsx @@ -38,6 +38,7 @@ export class AddNamespaceDialog extends React.Component { addNamespace = async () => { const { namespace } = this; const { onSuccess, onError } = this.props; + try { await namespaceStore.create({ name: namespace }).then(onSuccess); this.close(); @@ -51,6 +52,7 @@ export class AddNamespaceDialog extends React.Component { const { ...dialogProps } = this.props; const { namespace } = this; const header =
Create Namespace
; + return ( { export class NamespaceDetails extends React.Component { @computed get quotas() { const namespace = this.props.object.getName(); + return resourceQuotaStore.getAllByNs(namespace); } @@ -31,8 +32,10 @@ export class NamespaceDetails extends React.Component { render() { const { object: namespace } = this.props; + if (!namespace) return; const status = namespace.getStatus(); + return (
diff --git a/src/renderer/components/+namespaces/namespace-select.tsx b/src/renderer/components/+namespaces/namespace-select.tsx index e1c64f8342..9bf3c3921c 100644 --- a/src/renderer/components/+namespaces/namespace-select.tsx +++ b/src/renderer/components/+namespaces/namespace-select.tsx @@ -46,16 +46,20 @@ export class NamespaceSelect extends React.Component { @computed get options(): SelectOption[] { const { customizeOptions, showClusterOption, clusterOptionLabel } = this.props; let options: SelectOption[] = namespaceStore.items.map(ns => ({ value: ns.getName() })); + options = customizeOptions ? customizeOptions(options) : options; + if (showClusterOption) { options.unshift({ value: null, label: clusterOptionLabel }); } + return options; } formatOptionLabel = (option: SelectOption) => { const { showIcons } = this.props; const { value, label } = option; + return label || ( <> {showIcons && } @@ -66,6 +70,7 @@ export class NamespaceSelect extends React.Component { render() { const { className, showIcons, showClusterOption, clusterOptionLabel, customizeOptions, ...selectProps } = this.props; + return ( { } const { showNotification, resetSelection, getNotificationMessage, cssSelectorLimit } = this.props; const contentElem = this.rootElem.querySelector(cssSelectorLimit) || this.rootElem; + if (contentElem) { const { copiedText, copied } = copyToClipboard(contentElem, { resetSelection }); + if (copied && showNotification) { Notifications.ok(getNotificationMessage(copiedText)); } @@ -51,12 +53,14 @@ export class Clipboard extends React.Component { render() { try { const rootElem = this.rootReactElem; + return React.cloneElement(rootElem, { className: cssNames(Clipboard.displayName, rootElem.props.className), onClick: this.onClick, }); } catch (err) { logger.error(`Invalid usage components/CopyToClick usage. Children must contain root html element.`, { err: String(err) }); + return this.rootReactElem; } } diff --git a/src/renderer/components/cluster-icon/cluster-icon.tsx b/src/renderer/components/cluster-icon/cluster-icon.tsx index 42de2693e8..c1b0ef3577 100644 --- a/src/renderer/components/cluster-icon/cluster-icon.tsx +++ b/src/renderer/components/cluster-icon/cluster-icon.tsx @@ -60,6 +60,7 @@ export class ClusterIcon extends React.Component { interactive: interactive !== undefined ? interactive : !!this.props.onClick, active: isActive, }); + return (
{showTooltip && ( diff --git a/src/renderer/components/cluster-manager/bottom-bar.tsx b/src/renderer/components/cluster-manager/bottom-bar.tsx index cc79de800e..c70c25aeb9 100644 --- a/src/renderer/components/cluster-manager/bottom-bar.tsx +++ b/src/renderer/components/cluster-manager/bottom-bar.tsx @@ -11,6 +11,7 @@ import { statusBarRegistry } from "../../../extensions/registries"; export class BottomBar extends React.Component { render() { const { currentWorkspace } = workspaceStore; + return (
@@ -23,6 +24,7 @@ export class BottomBar extends React.Component {
{statusBarRegistry.getItems().map(({ item }, index) => { if (!item) return; + return
{item}
; })}
diff --git a/src/renderer/components/cluster-manager/cluster-manager.tsx b/src/renderer/components/cluster-manager/cluster-manager.tsx index b27e088f84..80df472a64 100644 --- a/src/renderer/components/cluster-manager/cluster-manager.tsx +++ b/src/renderer/components/cluster-manager/cluster-manager.tsx @@ -46,6 +46,7 @@ export class ClusterManager extends React.Component { get startUrl() { const { activeClusterId } = clusterStore; + if (activeClusterId) { return clusterViewURL({ params: { @@ -53,6 +54,7 @@ export class ClusterManager extends React.Component { } }); } + return landingURL(); } diff --git a/src/renderer/components/cluster-manager/cluster-status.tsx b/src/renderer/components/cluster-manager/cluster-status.tsx index bd8be870c2..3e17eb3e4c 100644 --- a/src/renderer/components/cluster-manager/cluster-status.tsx +++ b/src/renderer/components/cluster-manager/cluster-status.tsx @@ -39,6 +39,7 @@ export class ClusterStatus extends React.Component { error: res.error, }); }); + if (this.cluster.disconnected) { await this.activateCluster(); } @@ -62,6 +63,7 @@ export class ClusterStatus extends React.Component { renderContent() { const { authOutput, cluster, hasErrors } = this; const failureReason = cluster.failureReason; + if (!hasErrors || this.isReconnecting) { return ( <> @@ -75,6 +77,7 @@ export class ClusterStatus extends React.Component { ); } + return ( <> diff --git a/src/renderer/components/cluster-manager/cluster-view.tsx b/src/renderer/components/cluster-manager/cluster-view.tsx index bbf1ba6533..ddedf145e7 100644 --- a/src/renderer/components/cluster-manager/cluster-view.tsx +++ b/src/renderer/components/cluster-manager/cluster-view.tsx @@ -33,6 +33,7 @@ export class ClusterView extends React.Component { render() { const { cluster } = this; const showStatus = cluster && (!cluster.available || !hasLoadedView(cluster.id) || !cluster.ready); + return (
{showStatus && ( diff --git a/src/renderer/components/cluster-manager/clusters-menu.tsx b/src/renderer/components/cluster-manager/clusters-menu.tsx index c3c710f8d9..f0e537996a 100644 --- a/src/renderer/components/cluster-manager/clusters-menu.tsx +++ b/src/renderer/components/cluster-manager/clusters-menu.tsx @@ -53,6 +53,7 @@ export class ClustersMenu extends React.Component { })); } })); + if (cluster.online) { menu.append(new MenuItem({ label: _i18n._(t`Disconnect`), @@ -65,6 +66,7 @@ export class ClustersMenu extends React.Component { } })); } + if (!cluster.isManaged) { menu.append(new MenuItem({ label: _i18n._(t`Remove`), @@ -100,6 +102,7 @@ export class ClustersMenu extends React.Component { source: { index: from }, destination: { index: to }, } = result; + clusterStore.swapIconOrders(currentWorkspaceId, from, to); } } @@ -110,6 +113,7 @@ export class ClustersMenu extends React.Component { const workspace = workspaceStore.getById(workspaceStore.currentWorkspaceId); const clusters = clusterStore.getByWorkspaceId(workspace.id).filter(cluster => cluster.enabled); const activeClusterId = clusterStore.activeCluster; + return (
@@ -119,6 +123,7 @@ export class ClustersMenu extends React.Component {
{clusters.map((cluster, index) => { const isActive = cluster.id === activeClusterId; + return ( {({ draggableProps, dragHandleProps, innerRef }: DraggableProvided) => ( @@ -154,10 +159,12 @@ export class ClustersMenu extends React.Component {
{globalPageMenuRegistry.getItems().map(({ title, target, components: { Icon } }) => { const registeredPage = globalPageRegistry.getByPageMenuTarget(target); + if (!registeredPage) return; const { extensionId, id: pageId } = registeredPage; const pageUrl = getExtensionPageUrl({ extensionId, pageId, params: target.params }); const isActive = pageUrl === navigation.location.pathname; + return ( { @@ -52,10 +54,12 @@ export async function autoCleanOnRemove(clusterId: ClusterId, iframe: HTMLIFrame export function refreshViews() { const cluster = clusterStore.getById(getMatchedClusterId()); + lensViews.forEach(({ clusterId, view, isLoaded }) => { const isCurrent = clusterId === cluster?.id; const isReady = cluster?.available && cluster?.ready; const isVisible = isCurrent && isLoaded && isReady; + view.style.display = isVisible ? "flex" : "none"; }); } diff --git a/src/renderer/components/confirm-dialog/confirm-dialog.tsx b/src/renderer/components/confirm-dialog/confirm-dialog.tsx index 491ba99012..f5cfbd42c3 100644 --- a/src/renderer/components/confirm-dialog/confirm-dialog.tsx +++ b/src/renderer/components/confirm-dialog/confirm-dialog.tsx @@ -74,6 +74,7 @@ export class ConfirmDialog extends React.Component { okButtonProps = {}, cancelButtonProps = {}, } = this.params; + return ( { componentDidUpdate(prevProps: DialogProps) { const { isOpen } = this.props; + if (isOpen !== prevProps.isOpen) { this.toggle(isOpen); } @@ -91,6 +92,7 @@ export class Dialog extends React.PureComponent { onOpen = () => { this.props.onOpen(); + if (!this.props.pinned) { if (this.elem) this.elem.addEventListener("click", this.onClickOutside); // Using document.body target to handle keydown event before Drawer does @@ -100,6 +102,7 @@ export class Dialog extends React.PureComponent { onClose = () => { this.props.onClose(); + if (!this.props.pinned) { if (this.elem) this.elem.removeEventListener("click", this.onClickOutside); document.body.removeEventListener("keydown", this.onEscapeKey); @@ -108,6 +111,7 @@ export class Dialog extends React.PureComponent { onEscapeKey = (evt: KeyboardEvent) => { const escapeKey = evt.code === "Escape"; + if (escapeKey) { this.close(); evt.stopPropagation(); @@ -116,6 +120,7 @@ export class Dialog extends React.PureComponent { onClickOutside = (evt: MouseEvent) => { const target = evt.target as HTMLElement; + if (!this.contentElem.contains(target)) { this.close(); evt.stopPropagation(); @@ -125,6 +130,7 @@ export class Dialog extends React.PureComponent { render() { const { modal, animated, pinned } = this.props; let { className } = this.props; + className = cssNames("Dialog flex center", className, { modal, pinned }); let dialog = (
@@ -133,6 +139,7 @@ export class Dialog extends React.PureComponent {
); + if (animated) { dialog = ( @@ -143,6 +150,7 @@ export class Dialog extends React.PureComponent { else if (!this.isOpen) { return null; } + return createPortal(dialog, document.body); } } diff --git a/src/renderer/components/dialog/logs-dialog.tsx b/src/renderer/components/dialog/logs-dialog.tsx index cfb7aecbe5..263fb6b814 100644 --- a/src/renderer/components/dialog/logs-dialog.tsx +++ b/src/renderer/components/dialog/logs-dialog.tsx @@ -39,6 +39,7 @@ export class LogsDialog extends React.Component {
); + return ( diff --git a/src/renderer/components/dock/create-resource.tsx b/src/renderer/components/dock/create-resource.tsx index 8fa20cad3e..b26a7385b2 100644 --- a/src/renderer/components/dock/create-resource.tsx +++ b/src/renderer/components/dock/create-resource.tsx @@ -44,6 +44,7 @@ export class CreateResource extends React.Component { .filter(v => !!v); // skip empty documents if "---" pasted at the beginning or end const createdResources: string[] = []; const errors: string[] = []; + await Promise.all( resources.map(data => { return resourceApplierApi.update(data) @@ -51,6 +52,7 @@ export class CreateResource extends React.Component { .catch((err: JsonApiErrorParsed) => errors.push(err.toString())); }) ); + if (errors.length) { errors.forEach(Notifications.error); if (!createdResources.length) throw errors[0]; @@ -61,13 +63,16 @@ export class CreateResource extends React.Component { {createdResources.join(", ")} successfully created

); + Notifications.ok(successMessage); + return successMessage; }; render() { const { tabId, data, error, create, onChange } = this; const { className } = this.props; + return (
{ // auto-save to local-storage if (storageName) { const storage = createStorage<[TabId, T][]>(storageName, []); + this.data.replace(storage.get()); reaction(() => this.serializeData(), (data: T | any) => storage.set(data)); } @@ -24,6 +25,7 @@ export class DockTabStore { // clear data for closed tabs autorun(() => { const currentTabs = dockStore.tabs.map(tab => tab.id); + Array.from(this.data.keys()).forEach(tabId => { if (!currentTabs.includes(tabId)) { this.clearData(tabId); @@ -34,8 +36,10 @@ export class DockTabStore { protected serializeData() { const { storageSerializer } = this.options; + return Array.from(this.data).map(([tabId, tabData]) => { if (storageSerializer) return [tabId, storageSerializer(tabData)]; + return [tabId, tabData]; }); } diff --git a/src/renderer/components/dock/dock-tab.tsx b/src/renderer/components/dock/dock-tab.tsx index ba73e42d92..ceaee18303 100644 --- a/src/renderer/components/dock/dock-tab.tsx +++ b/src/renderer/components/dock/dock-tab.tsx @@ -40,6 +40,7 @@ export class DockTab extends React.Component { )}
); + return ( this.maxHeight) { this.setHeight(this.maxHeight); } @@ -94,6 +96,7 @@ export class DockStore { @action open(fullSize?: boolean) { this.isOpen = true; + if (typeof fullSize === "boolean") { this.fullSize = fullSize; } @@ -125,8 +128,10 @@ export class DockStore { .filter(tab => tab.kind === kind) .map(tab => { const tabNumber = +tab.title.match(/\d+/); + return tabNumber === 0 ? 1 : tabNumber; // tab without a number is first }); + for (let i = 1; ; i++) { if (!tabNumbers.includes(i)) return i; } @@ -136,29 +141,36 @@ export class DockStore { createTab(anonTab: IDockTab, addNumber = true): IDockTab { const tabId = MD5(Math.random().toString() + Date.now()).toString(); const tab: IDockTab = { id: tabId, ...anonTab }; + if (addNumber) { const tabNumber = this.getNewTabNumber(tab.kind); + if (tabNumber > 1) tab.title += ` (${tabNumber})`; } this.tabs.push(tab); this.selectTab(tab.id); this.open(); + return tab; } @action async closeTab(tabId: TabId) { const tab = this.getTabById(tabId); + if (!tab || tab.pinned) { return; } this.tabs.remove(tab); + if (this.selectedTabId === tab.id) { if (this.tabs.length) { const newTab = this.tabs.slice(-1)[0]; // last + if (newTab.kind === TabKind.TERMINAL) { // close the dock when selected sibling inactive terminal tab const { terminalStore } = await import("./terminal.store"); + if (!terminalStore.isConnected(newTab.id)) this.close(); } this.selectTab(newTab.id); diff --git a/src/renderer/components/dock/dock.tsx b/src/renderer/components/dock/dock.tsx index dfdd792750..6f513e7586 100644 --- a/src/renderer/components/dock/dock.tsx +++ b/src/renderer/components/dock/dock.tsx @@ -33,11 +33,14 @@ interface Props { export class Dock extends React.Component { onKeydown = (evt: React.KeyboardEvent) => { const { close, closeTab, selectedTab } = dockStore; + if (!selectedTab) return; const { code, ctrlKey, shiftKey } = evt.nativeEvent; + if (shiftKey && code === "Escape") { close(); } + if (ctrlKey && code === "KeyW") { if (selectedTab.pinned) close(); else closeTab(selectedTab.id); @@ -46,6 +49,7 @@ export class Dock extends React.Component { onChangeTab = (tab: IDockTab) => { const { open, selectTab } = dockStore; + open(); selectTab(tab.id); }; @@ -55,12 +59,15 @@ export class Dock extends React.Component { if (isTerminalTab(tab)) { return ; } + if (isCreateResourceTab(tab) || isEditResourceTab(tab)) { return ; } + if (isInstallChartTab(tab) || isUpgradeChartTab(tab)) { return } />; } + if (isPodLogsTab(tab)) { return ; } @@ -68,7 +75,9 @@ export class Dock extends React.Component { renderTabContent() { const { isOpen, height, selectedTab: tab } = dockStore; + if (!isOpen || !tab) return; + return (
{isCreateResourceTab(tab) && } @@ -84,6 +93,7 @@ export class Dock extends React.Component { render() { const { className } = this.props; const { isOpen, toggle, tabs, toggleFillSize, selectedTab, hasTabs, fullSize } = dockStore; + return (
{ } this.watchers.set(tabId, autorun(() => { const store = apiManager.getStore(resource); + if (store) { const isActiveTab = dockStore.isOpen && dockStore.selectedTabId === tabId; const obj = store.getByPath(resource); + // preload resource for editing if (!obj && !store.isLoaded && !store.isLoading && isActiveTab) { store.loadFromPath(resource).catch(noop); @@ -50,6 +52,7 @@ export class EditResourceStore extends DockTabStore { const [tabId] = Array.from(this.data).find(([, { resource }]) => { return object.selfLink === resource; }) || []; + return dockStore.getTabById(tabId); } @@ -67,10 +70,12 @@ export const editResourceStore = new EditResourceStore(); export function editResourceTab(object: KubeObject, tabParams: Partial = {}) { // use existing tab if already opened let tab = editResourceStore.getTabByResource(object); + if (tab) { dockStore.open(); dockStore.selectTab(tab.id); } + // or create new tab if (!tab) { tab = dockStore.createTab({ @@ -82,6 +87,7 @@ export function editResourceTab(object: KubeObject, tabParams: Partial resource: object.selfLink, }); } + return tab; } diff --git a/src/renderer/components/dock/edit-resource.tsx b/src/renderer/components/dock/edit-resource.tsx index 478192b35a..b9daf4e697 100644 --- a/src/renderer/components/dock/edit-resource.tsx +++ b/src/renderer/components/dock/edit-resource.tsx @@ -28,6 +28,7 @@ export class EditResource extends React.Component { @disposeOnUnmount autoDumpResourceOnInit = autorun(() => { if (!this.tabData) return; + if (this.tabData.draft === undefined && this.resource) { this.saveDraft(this.resource); } @@ -44,6 +45,7 @@ export class EditResource extends React.Component { get resource(): KubeObject { const { resource } = this.tabData; const store = apiManager.getStore(resource); + if (store) { return store.getByPath(resource); } @@ -71,9 +73,11 @@ export class EditResource extends React.Component { const { resource, draft } = this.tabData; const store = apiManager.getStore(resource); const updatedResource = await store.update(this.resource, jsYaml.safeLoad(draft)); + this.saveDraft(updatedResource); // update with new resourceVersion to avoid further errors on save const resourceType = updatedResource.kind; const resourceName = updatedResource.getName(); + return (

{resourceType} {resourceName} updated. @@ -84,10 +88,12 @@ export class EditResource extends React.Component { render() { const { tabId, resource, tabData, error, onChange, save } = this; const { draft } = tabData; + if (!resource || draft === undefined) { return ; } const { kind, getNs, getName } = resource; + return (

{ onChange = (value: string) => { this.validate(value); + if (this.props.onChange) { this.props.onChange(value, this.yamlError); } @@ -65,8 +66,10 @@ export class EditorPanel extends React.Component { render() { const { value, tabId } = this.props; let { className } = this.props; + className = cssNames("EditorPanel", className); const cursorPos = EditorPanel.cursorPos.getData(tabId); + return ( { submit = async () => { const { showNotifications } = this.props; + this.waiting = true; + try { const result = await this.props.submit(); + if (showNotifications) Notifications.ok(result); } catch (error) { if (showNotifications) Notifications.error(error.toString()); @@ -81,6 +84,7 @@ export class InfoPanel extends Component { if (!this.props.showInlineInfo || !this.errorInfo) { return; } + return (
@@ -92,6 +96,7 @@ export class InfoPanel extends Component { const { className, controls, submitLabel, disableSubmit, error, submittingMessage, showButtons, showSubmitClose } = this.props; const { submit, close, submitAndClose, waiting } = this; const isDisabled = !!(disableSubmit || waiting || error); + return (
diff --git a/src/renderer/components/dock/install-chart.store.ts b/src/renderer/components/dock/install-chart.store.ts index 860c795c60..7415cb92bb 100644 --- a/src/renderer/components/dock/install-chart.store.ts +++ b/src/renderer/components/dock/install-chart.store.ts @@ -28,6 +28,7 @@ export class InstallChartStore extends DockTabStore { }); autorun(() => { const { selectedTab, isOpen } = dockStore; + if (isInstallChartTab(selectedTab) && isOpen) { this.loadData() .catch(err => Notifications.error(String(err))); @@ -53,9 +54,11 @@ export class InstallChartStore extends DockTabStore { @action async loadVersions(tabId: TabId) { const { repo, name, version } = this.getData(tabId); + this.versions.clearData(tabId); // reset const charts = await helmChartsApi.get(repo, name, version); const versions = charts.versions.map(chartVersion => chartVersion.version); + this.versions.setData(tabId, versions); } @@ -63,8 +66,8 @@ export class InstallChartStore extends DockTabStore { async loadValues(tabId: TabId, attempt = 0): Promise { const data = this.getData(tabId); const { repo, name, version } = data; - const values = await helmChartsApi.getValues(repo, name, version); + if (values) { this.setData(tabId, { ...data, values }); } else if (attempt < 4) { @@ -77,7 +80,6 @@ export const installChartStore = new InstallChartStore(); export function createInstallChartTab(chart: HelmChart, tabParams: Partial = {}) { const { name, repo, version } = chart; - const tab = dockStore.createTab({ kind: TabKind.INSTALL_CHART, title: _i18n._(t`Helm Install: ${repo}/${name}`), diff --git a/src/renderer/components/dock/install-chart.tsx b/src/renderer/components/dock/install-chart.tsx index 0c0713007d..10d1ddc4ed 100644 --- a/src/renderer/components/dock/install-chart.tsx +++ b/src/renderer/components/dock/install-chart.tsx @@ -54,6 +54,7 @@ export class InstallChart extends Component { @autobind() viewRelease() { const { release } = this.releaseDetails; + navigate(releaseURL({ params: { name: release.name, @@ -66,12 +67,14 @@ export class InstallChart extends Component { @autobind() save(data: Partial) { const chart = { ...this.chartData, ...data }; + installChartStore.setData(this.tabId, chart); } @autobind() onVersionChange(option: SelectOption) { const version = option.value; + this.save({ version, values: "" }); installChartStore.loadValues(this.tabId); } @@ -99,7 +102,9 @@ export class InstallChart extends Component { chart: name, repo, namespace, version, values, }); + installChartStore.details.setData(this.tabId, details); + return (

Chart Release {details.release.name} successfully created.

); @@ -107,6 +112,7 @@ export class InstallChart extends Component { render() { const { tabId, chartData, values, versions, install } = this; + if (chartData?.values === undefined || !versions) { return ; } diff --git a/src/renderer/components/dock/pod-log-controls.tsx b/src/renderer/components/dock/pod-log-controls.tsx index 24571362fc..1d91a9ca11 100644 --- a/src/renderer/components/dock/pod-log-controls.tsx +++ b/src/renderer/components/dock/pod-log-controls.tsx @@ -39,11 +39,13 @@ export const PodLogControls = observer((props: Props) => { const downloadLogs = () => { const fileName = selectedContainer ? selectedContainer.name : pod.getName(); + saveFileDialog(`${fileName}.log`, logs.join("\n"), "text/plain"); }; const onContainerChange = (option: SelectOption) => { const { containers, initContainers } = tabData; + save({ selectedContainer: containers .concat(initContainers) @@ -54,6 +56,7 @@ export const PodLogControls = observer((props: Props) => { const containerSelectOptions = () => { const { containers, initContainers } = tabData; + return [ { label: _i18n._(t`Containers`), @@ -72,6 +75,7 @@ export const PodLogControls = observer((props: Props) => { const formatOptionLabel = (option: SelectOption) => { const { value, label } = option; + return label || <> {value}; }; diff --git a/src/renderer/components/dock/pod-log-list.tsx b/src/renderer/components/dock/pod-log-list.tsx index e882b38a5b..392e0eefc0 100644 --- a/src/renderer/components/dock/pod-log-list.tsx +++ b/src/renderer/components/dock/pod-log-list.tsx @@ -41,24 +41,31 @@ export class PodLogList extends React.Component { componentDidUpdate(prevProps: Props) { const { logs, id } = this.props; + if (id != prevProps.id) { this.isLastLineVisible = true; + return; } if (logs == prevProps.logs || !this.virtualListDiv.current) return; const newLogsLoaded = prevProps.logs.length < logs.length; const scrolledToBeginning = this.virtualListDiv.current.scrollTop === 0; const fewLogsLoaded = logs.length < logRange; + if (this.isLastLineVisible) { this.scrollToBottom(); // Scroll down to keep user watching/reading experience + return; } + if (scrolledToBeginning && newLogsLoaded) { this.virtualListDiv.current.scrollTop = (logs.length - prevProps.logs.length) * this.lineHeight; } + if (fewLogsLoaded) { this.isJumpButtonVisible = false; } + if (!logs.length) { this.isLastLineVisible = false; } @@ -73,6 +80,7 @@ export class PodLogList extends React.Component { const offset = 100 * this.lineHeight; const { scrollHeight } = this.virtualListDiv.current; const { scrollOffset } = props; + if (scrollHeight - scrollOffset < offset) { this.isJumpButtonVisible = false; } else { @@ -88,6 +96,7 @@ export class PodLogList extends React.Component { setLastLineVisibility = (props: ListOnScrollProps) => { const { scrollHeight, clientHeight } = this.virtualListDiv.current; const { scrollOffset, scrollDirection } = props; + if (scrollDirection == "backward") { this.isLastLineVisible = false; } else { @@ -103,6 +112,7 @@ export class PodLogList extends React.Component { */ checkLoadIntent = (props: ListOnScrollProps) => { const { scrollOffset } = props; + if (scrollOffset === 0) { this.props.load(); } @@ -136,6 +146,7 @@ export class PodLogList extends React.Component { const item = this.props.logs[rowIndex]; const contents: React.ReactElement[] = []; const ansiToHtml = (ansi: string) => DOMPurify.sanitize(colorConverter.ansi_to_html(ansi)); + if (searchQuery) { // If search is enabled, replace keyword with backgrounded // Case-insensitive search (lowercasing query and keywords in line) const regex = new RegExp(searchStore.escapeRegex(searchQuery), "gi"); @@ -143,6 +154,7 @@ export class PodLogList extends React.Component { const modified = item.replace(regex, match => match.toLowerCase()); // Splitting text line by keyword const pieces = modified.split(searchQuery.toLowerCase()); + pieces.forEach((piece, index) => { const active = isActiveOverlay(rowIndex, index); const lastItem = index === pieces.length - 1; @@ -153,6 +165,7 @@ export class PodLogList extends React.Component { dangerouslySetInnerHTML={{ __html: ansiToHtml(overlayValue) }} /> : null; + contents.push( @@ -161,6 +174,7 @@ export class PodLogList extends React.Component { ); }); } + return (
{contents.length > 1 ? contents : ( @@ -174,9 +188,11 @@ export class PodLogList extends React.Component { const { logs, isLoading } = this.props; const isInitLoading = isLoading && !logs.length; const rowHeights = new Array(logs.length).fill(this.lineHeight); + if (isInitLoading) { return ; } + if (!logs.length) { return (
@@ -184,6 +200,7 @@ export class PodLogList extends React.Component {
); } + return (
{ private refresher = interval(10, () => { const id = dockStore.selectedTabId; + if (!this.logs.get(id)) return; this.loadMore(id); }); @@ -39,6 +40,7 @@ export class PodLogsStore extends DockTabStore { }); autorun(() => { const { selectedTab, isOpen } = dockStore; + if (isPodLogsTab(selectedTab) && isOpen) { this.refresher.start(); } else { @@ -68,6 +70,7 @@ export class PodLogsStore extends DockTabStore { const logs = await this.loadLogs(tabId, { tailLines: this.lines + logRange }); + this.refresher.start(); this.logs.set(tabId, logs); } catch ({error}) { @@ -75,6 +78,7 @@ export class PodLogsStore extends DockTabStore { _i18n._(t`Failed to load logs: ${error.message}`), _i18n._(t`Reason: ${error.reason} (${error.code})`) ]; + this.refresher.stop(); this.logs.set(tabId, message); } @@ -92,6 +96,7 @@ export class PodLogsStore extends DockTabStore { const logs = await this.loadLogs(tabId, { sinceTime: this.getLastSinceTime(tabId) }); + // Add newly received logs to bottom this.logs.set(tabId, [...oldLogs, ...logs]); }; @@ -109,6 +114,7 @@ export class PodLogsStore extends DockTabStore { const pod = new Pod(data.pod); const namespace = pod.getNs(); const name = pod.getName(); + return podsApi.getLogs({ namespace, name }, { ...params, timestamps: true, // Always setting timestampt to separate old logs from new ones @@ -116,7 +122,9 @@ export class PodLogsStore extends DockTabStore { previous }).then(result => { const logs = [...result.split("\n")]; // Transform them into array + logs.pop(); // Remove last empty element + return logs; }); }; @@ -128,6 +136,7 @@ export class PodLogsStore extends DockTabStore { setNewLogSince(tabId: TabId) { if (!this.logs.has(tabId) || !this.logs.get(tabId).length || this.newLogSince.has(tabId)) return; const timestamp = this.getLastSinceTime(tabId); + this.newLogSince.set(tabId, timestamp.split(".")[0]); // Removing milliseconds from string } @@ -139,6 +148,7 @@ export class PodLogsStore extends DockTabStore { get lines() { const id = dockStore.selectedTabId; const logs = this.logs.get(id); + return logs ? logs.length : 0; } @@ -151,7 +161,9 @@ export class PodLogsStore extends DockTabStore { const logs = this.logs.get(tabId); const timestamps = this.getTimestamps(logs[logs.length - 1]); const stamp = new Date(timestamps ? timestamps[0] : null); + stamp.setSeconds(stamp.getSeconds() + 1); // avoid duplicates from last second + return stamp.toISOString(); } @@ -178,9 +190,11 @@ export const podLogsStore = new PodLogsStore(); export function createPodLogsTab(data: IPodLogsData, tabParams: Partial = {}) { const podId = data.pod.getId(); let tab = dockStore.getTabById(podId); + if (tab) { dockStore.open(); dockStore.selectTab(tab.id); + return; } // If no existent tab found @@ -191,6 +205,7 @@ export function createPodLogsTab(data: IPodLogsData, tabParams: Partial { @autobind() toOverlay() { const { activeOverlayLine } = searchStore; + if (!this.logListElement.current || activeOverlayLine === undefined) return; // Scroll vertically this.logListElement.current.scrollToItem(activeOverlayLine, "center"); // Scroll horizontally in timeout since virtual list need some time to prepare its contents setTimeout(() => { const overlay = document.querySelector(".PodLogs .list span.active"); + if (!overlay) return; overlay.scrollIntoViewIfNeeded(); }, 100); @@ -87,9 +89,11 @@ export class PodLogs extends React.Component { const logs = podLogsStore.logs.get(this.tabId); const { getData, removeTimestamps } = podLogsStore; const { showTimestamps } = getData(this.tabId); + if (!showTimestamps) { return logs.map(item => removeTimestamps(item)); } + return logs; } @@ -107,6 +111,7 @@ export class PodLogs extends React.Component { toNextOverlay={this.toOverlay} /> ); + return (
{ render() { const { className } = this.props; + return (
{ const { selectedTab, isOpen } = dockStore; + if (!isTerminalTab(selectedTab)) return; + if (isOpen) { this.connect(selectedTab.id); } @@ -40,6 +42,7 @@ export class TerminalStore { // disconnect closed tabs autorun(() => { const currentTabs = dockStore.tabs.map(tab => tab.id); + for (const [tabId] of this.connections) { if (!currentTabs.includes(tabId)) this.disconnect(tabId); } @@ -56,6 +59,7 @@ export class TerminalStore { node: tab.node, }); const terminal = new Terminal(tabId, api); + this.connections.set(tabId, api); this.terminals.set(tabId, terminal); } @@ -66,6 +70,7 @@ export class TerminalStore { } const terminal = this.terminals.get(tabId); const terminalApi = this.connections.get(tabId); + terminal.destroy(); terminalApi.destroy(); this.connections.delete(tabId); @@ -74,6 +79,7 @@ export class TerminalStore { reconnect(tabId: TabId) { const terminalApi = this.connections.get(tabId); + if (terminalApi) terminalApi.connect(); } @@ -83,6 +89,7 @@ export class TerminalStore { isDisconnected(tabId: TabId) { const terminalApi = this.connections.get(tabId); + if (terminalApi) { return terminalApi.readyState === WebSocketApiState.CLOSED; } @@ -91,12 +98,13 @@ export class TerminalStore { sendCommand(command: string, options: { enter?: boolean; newTab?: boolean; tabId?: TabId } = {}) { const { enter, newTab, tabId } = options; const { selectTab, getTabById } = dockStore; - const tab = tabId && getTabById(tabId); + if (tab) selectTab(tabId); if (newTab) createTerminalTab(); const terminalApi = this.connections.get(dockStore.selectedTabId); + if (terminalApi) { terminalApi.sendCommand(command + (enter ? "\r" : "")); } diff --git a/src/renderer/components/dock/terminal.ts b/src/renderer/components/dock/terminal.ts index 67a4f88168..a4246658fe 100644 --- a/src/renderer/components/dock/terminal.ts +++ b/src/renderer/components/dock/terminal.ts @@ -14,6 +14,7 @@ export class Terminal { // terminal element must be in DOM before attaching via xterm.open(elem) // https://xtermjs.org/docs/api/terminal/classes/terminal/#open const pool = document.createElement("div"); + pool.className = "terminal-init"; pool.style.cssText = "position: absolute; top: 0; left: 0; height: 0; visibility: hidden; overflow: hidden"; document.body.appendChild(pool); @@ -23,6 +24,7 @@ export class Terminal { static async preloadFonts() { const fontPath = require("../fonts/roboto-mono-nerd.ttf").default; // eslint-disable-line @typescript-eslint/no-var-requires const fontFace = new FontFace("RobotoMono", `url(${fontPath})`); + await fontFace.load(); document.fonts.add(fontFace); } @@ -41,10 +43,13 @@ export class Terminal { .filter(([name]) => name.startsWith(colorPrefix)) .reduce((colors, [name, color]) => { const colorName = name.split("").slice(colorPrefix.length); + colorName[0] = colorName[0].toLowerCase(); colors[colorName.join("")] = color; + return colors; }, {}); + this.xterm.setOption("theme", terminalColors); } @@ -62,6 +67,7 @@ export class Terminal { get isActive() { const { isOpen, selectedTabId } = dockStore; + return isOpen && selectedTabId === this.tabId; } @@ -95,6 +101,7 @@ export class Terminal { // bind events const onDataHandler = this.xterm.onData(this.onData); + this.viewport.addEventListener("scroll", this.onScroll); this.api.onReady.addListener(this.onClear, { once: true }); // clear status logs (connecting..) this.api.onData.addListener(this.onApiData); @@ -125,6 +132,7 @@ export class Terminal { if (!this.isActive) return; this.fitAddon.fit(); const { cols, rows } = this.xterm; + this.api.sendTerminalSize(cols, rows); }; diff --git a/src/renderer/components/dock/upgrade-chart.store.ts b/src/renderer/components/dock/upgrade-chart.store.ts index 55574aa0a7..f06f423c0d 100644 --- a/src/renderer/components/dock/upgrade-chart.store.ts +++ b/src/renderer/components/dock/upgrade-chart.store.ts @@ -23,7 +23,9 @@ export class UpgradeChartStore extends DockTabStore { autorun(() => { const { selectedTab, isOpen } = dockStore; + if (!isUpgradeChartTab(selectedTab)) return; + if (isOpen) { this.loadData(selectedTab.id); } @@ -31,6 +33,7 @@ export class UpgradeChartStore extends DockTabStore { autorun(() => { const objects = [...this.data.values()]; + objects.forEach(({ releaseName }) => this.createReleaseWatcher(releaseName)); }); } @@ -41,13 +44,16 @@ export class UpgradeChartStore extends DockTabStore { } const dispose = reaction(() => { const release = releaseStore.getByName(releaseName); + if (release) return release.getRevision(); // watch changes only by revision }, release => { const releaseTab = this.getTabByRelease(releaseName); + if (!releaseStore.isLoaded || !releaseTab) { return; } + // auto-reload values if was loaded before if (release) { if (dockStore.selectedTab === releaseTab && this.values.getData(releaseTab.id)) { @@ -61,17 +67,20 @@ export class UpgradeChartStore extends DockTabStore { dockStore.closeTab(releaseTab.id); } }); + this.watchers.set(releaseName, dispose); } isLoading(tabId = dockStore.selectedTabId) { const values = this.values.getData(tabId); + return !releaseStore.isLoaded || values === undefined; } @action async loadData(tabId: TabId) { const values = this.values.getData(tabId); + await Promise.all([ !releaseStore.isLoaded && releaseStore.loadAll(), !values && this.loadValues(tabId) @@ -83,13 +92,16 @@ export class UpgradeChartStore extends DockTabStore { this.values.clearData(tabId); // reset const { releaseName, releaseNamespace } = this.getData(tabId); const values = await helmReleasesApi.getValues(releaseName, releaseNamespace); + this.values.setData(tabId, values); } getTabByRelease(releaseName: string): IDockTab { const item = [...this.data].find(item => item[1].releaseName === releaseName); + if (item) { const [tabId] = item; + return dockStore.getTabById(tabId); } } @@ -99,10 +111,12 @@ export const upgradeChartStore = new UpgradeChartStore(); export function createUpgradeChartTab(release: HelmRelease, tabParams: Partial = {}) { let tab = upgradeChartStore.getTabByRelease(release.getName()); + if (tab) { dockStore.open(); dockStore.selectTab(tab.id); } + if (!tab) { tab = dockStore.createTab({ kind: TabKind.UPGRADE_CHART, @@ -115,6 +129,7 @@ export function createUpgradeChartTab(release: HelmRelease, tabParams: Partial { get release(): HelmRelease { const tabData = upgradeChartStore.getData(this.tabId); + if (!tabData) return; + return releaseStore.getByName(tabData.releaseName); } @@ -55,6 +57,7 @@ export class UpgradeChart extends React.Component { this.version = null; this.versions.clear(); const versions = await helmChartStore.getVersions(this.release.getChart()); + this.versions.replace(versions); this.version = this.versions[0]; } @@ -69,11 +72,13 @@ export class UpgradeChart extends React.Component { const { version, repo } = this.version; const releaseName = this.release.getName(); const releaseNs = this.release.getNs(); + await releaseStore.update(releaseName, releaseNs, { chart: this.release.getChart(), values: this.value, repo, version, }); + return (

Release {releaseName} successfully upgraded to version {version} @@ -84,12 +89,14 @@ export class UpgradeChart extends React.Component { formatVersionLabel = ({ value }: SelectOption) => { const chartName = this.release.getChart(); const { repo, version } = value; + return `${repo}/${chartName}-${version}`; }; render() { const { tabId, release, value, error, onChange, upgrade, versions, version } = this; const { className } = this.props; + if (!release || upgradeChartStore.isLoading() || !version) { return ; } @@ -111,6 +118,7 @@ export class UpgradeChart extends React.Component { />

); + return (
{labels.map(label => )} diff --git a/src/renderer/components/drawer/drawer-item.tsx b/src/renderer/components/drawer/drawer-item.tsx index 6a0e1874cb..ff77e29c88 100644 --- a/src/renderer/components/drawer/drawer-item.tsx +++ b/src/renderer/components/drawer/drawer-item.tsx @@ -14,6 +14,7 @@ export interface DrawerItemProps extends React.HTMLAttributes { export class DrawerItem extends React.Component { render() { const { name, title, labelsOnly, children, hidden, className, renderBoolean, ...elemProps } = this.props; + if (hidden) return null; const classNames = cssNames("DrawerItem", className, { labelsOnly }); diff --git a/src/renderer/components/drawer/drawer-param-toggler.tsx b/src/renderer/components/drawer/drawer-param-toggler.tsx index 181965e848..902c5bae44 100644 --- a/src/renderer/components/drawer/drawer-param-toggler.tsx +++ b/src/renderer/components/drawer/drawer-param-toggler.tsx @@ -24,6 +24,7 @@ export class DrawerParamToggler extends React.Component
diff --git a/src/renderer/components/drawer/drawer-title.tsx b/src/renderer/components/drawer/drawer-title.tsx index 7e521c183e..74615ed175 100644 --- a/src/renderer/components/drawer/drawer-title.tsx +++ b/src/renderer/components/drawer/drawer-title.tsx @@ -10,6 +10,7 @@ export interface DrawerTitleProps { export class DrawerTitle extends React.Component { render() { const { title, children, className } = this.props; + return (
{title || children} diff --git a/src/renderer/components/drawer/drawer.tsx b/src/renderer/components/drawer/drawer.tsx index de4003990e..64098a8ed1 100644 --- a/src/renderer/components/drawer/drawer.tsx +++ b/src/renderer/components/drawer/drawer.tsx @@ -56,12 +56,14 @@ export class Drawer extends React.Component { saveScrollPos = () => { if (!this.scrollElem) return; const key = history.location.key; + this.scrollPos.set(key, this.scrollElem.scrollTop); }; restoreScrollPos = () => { if (!this.scrollElem) return; const key = history.location.key; + this.scrollElem.scrollTop = this.scrollPos.get(key) || 0; }; @@ -69,6 +71,7 @@ export class Drawer extends React.Component { if (!this.props.open) { return; } + if (evt.code === "Escape") { this.close(); } @@ -76,11 +79,13 @@ export class Drawer extends React.Component { onClickOutside = (evt: MouseEvent) => { const { contentElem, mouseDownTarget, close, props: { open } } = this; + if (!open || evt.defaultPrevented || contentElem.contains(mouseDownTarget)) { return; } const clickedElem = evt.target as HTMLElement; const isOutsideAnyDrawer = !clickedElem.closest(".Drawer"); + if (isOutsideAnyDrawer) { close(); } @@ -95,12 +100,14 @@ export class Drawer extends React.Component { close = () => { const { open, onClose } = this.props; + if (open) onClose(); }; render() { const { open, position, title, animation, children, toolbar, size, usePortal } = this.props; let { className, contentClass } = this.props; + className = cssNames("Drawer", className, position); contentClass = cssNames("drawer-content flex column box grow", contentClass); const style = size ? { "--size": size } as React.CSSProperties : undefined; @@ -120,6 +127,7 @@ export class Drawer extends React.Component {
); + return usePortal ? createPortal(drawer, document.body) : drawer; } } diff --git a/src/renderer/components/error-boundary/error-boundary.tsx b/src/renderer/components/error-boundary/error-boundary.tsx index 77708a281c..5cc70f09c2 100644 --- a/src/renderer/components/error-boundary/error-boundary.tsx +++ b/src/renderer/components/error-boundary/error-boundary.tsx @@ -37,10 +37,12 @@ export class ErrorBoundary extends React.Component { render() { const { error, errorInfo } = this.state; + if (error) { const slackLink = Slack; const githubLink = Github; const pageUrl = location.href; + return (
@@ -69,6 +71,7 @@ export class ErrorBoundary extends React.Component {
); } + return this.props.children; } } diff --git a/src/renderer/components/file-picker/file-picker.tsx b/src/renderer/components/file-picker/file-picker.tsx index 4f9a07c0cf..14cd6c07e8 100644 --- a/src/renderer/components/file-picker/file-picker.tsx +++ b/src/renderer/components/file-picker/file-picker.tsx @@ -83,6 +83,7 @@ export class FilePicker extends React.Component { handleFileCount(files: File[]): File[] { const { limit: [minLimit, maxLimit] = [0, Infinity], onOverLimit } = this.props; + if (files.length > maxLimit) { switch (onOverLimit) { case OverLimitStyle.CAP: @@ -92,6 +93,7 @@ export class FilePicker extends React.Component { throw `Too many files. Expected at most ${maxLimit}. Got ${files.length}.`; } } + if (files.length < minLimit) { throw `Too many files. Expected at most ${maxLimit}. Got ${files.length}.`; } @@ -107,6 +109,7 @@ export class FilePicker extends React.Component { return files.filter(file => file.size <= maxSize ); case OverSizeLimitStyle.REJECT: const firstFileToLarge = files.find(file => file.size > maxSize); + if (firstFileToLarge) { throw `${firstFileToLarge.name} is too large. Maximum size is ${maxSize}. Has size of ${firstFileToLarge.size}`; } @@ -119,6 +122,7 @@ export class FilePicker extends React.Component { const { maxTotalSize, onOverTotalSizeLimit } = this.props; const totalSize = _.sum(files.map(f => f.size)); + if (totalSize <= maxTotalSize) { return files; } @@ -131,10 +135,12 @@ export class FilePicker extends React.Component { for (;files.length > 0;) { newTotalSize -= files.pop().size; + if (newTotalSize <= maxTotalSize) { break; } } + return files; case OverTotalSizeLimitStyle.REJECT: throw `Total file size to upload is too large. Expected at most ${maxTotalSize}. Found ${totalSize}.`; @@ -151,11 +157,13 @@ export class FilePicker extends React.Component { if ("uploadDir" in this.props) { const { uploadDir } = this.props; + this.status = FileInputStatus.PROCESSING; const paths: string[] = []; const promises = totalSizeLimitedFiles.map(async file => { const destinationPath = path.join(uploadDir, file.name); + paths.push(destinationPath); return fse.copyFile(file.path, destinationPath); @@ -170,6 +178,7 @@ export class FilePicker extends React.Component { } catch (errorText) { this.status = FileInputStatus.ERROR; this.errorText = errorText; + return; } } diff --git a/src/renderer/components/icon/icon.tsx b/src/renderer/components/icon/icon.tsx index 3946737416..f74a7fa46d 100644 --- a/src/renderer/components/icon/icon.tsx +++ b/src/renderer/components/icon/icon.tsx @@ -32,6 +32,7 @@ export class Icon extends React.PureComponent { get isInteractive() { const { interactive, onClick, href, link } = this.props; + return interactive ?? !!(onClick || href || link); } @@ -40,6 +41,7 @@ export class Icon extends React.PureComponent { if (this.props.disabled) { return; } + if (this.props.onClick) { this.props.onClick(evt); } @@ -49,14 +51,17 @@ export class Icon extends React.PureComponent { onKeyDown(evt: React.KeyboardEvent) { switch (evt.nativeEvent.code) { case "Space": + case "Enter": { // eslint-disable-next-line react/no-find-dom-node const icon = findDOMNode(this) as HTMLElement; + setTimeout(() => icon.click()); evt.preventDefault(); break; } } + if (this.props.onKeyDown) { this.props.onKeyDown(evt); } @@ -90,6 +95,7 @@ export class Icon extends React.PureComponent { // render as inline svg-icon if (svg) { const svgIconText = require(`!!raw-loader!./${svg}.svg`).default; + iconContent = ; } @@ -110,9 +116,11 @@ export class Icon extends React.PureComponent { if (link) { return ; } + if (href) { return ; } + return ; } } diff --git a/src/renderer/components/input/drop-file-input.tsx b/src/renderer/components/input/drop-file-input.tsx index a99e61ef2b..a462a117d3 100644 --- a/src/renderer/components/input/drop-file-input.tsx +++ b/src/renderer/components/input/drop-file-input.tsx @@ -45,6 +45,7 @@ export class DropFileInput extends React.Component< } this.dropAreaActive = false; const files = Array.from(evt.dataTransfer.files); + if (files.length > 0) { this.props.onDropFiles(files, { evt }); } @@ -53,12 +54,15 @@ export class DropFileInput extends React.Component< render() { const { onDragEnter, onDragLeave, onDragOver, onDrop } = this; const { disabled, className } = this.props; + try { const contentElem = React.Children.only(this.props.children) as React.ReactElement>; + if (disabled) { return contentElem; } const isValidContentElem = React.isValidElement(contentElem); + if (isValidContentElem) { const contentElemProps: React.HTMLProps = { className: cssNames("DropFileInput", contentElem.props.className, className, { @@ -69,10 +73,12 @@ export class DropFileInput extends React.Component< onDragOver, onDrop, }; + return React.cloneElement(contentElem, contentElemProps); } } catch (err) { logger.error(`Error: must contain only single child element`); + return this.props.children; } } diff --git a/src/renderer/components/input/file-input.tsx b/src/renderer/components/input/file-input.tsx index f4d83bce14..f136aed3bb 100644 --- a/src/renderer/components/input/file-input.tsx +++ b/src/renderer/components/input/file-input.tsx @@ -28,14 +28,17 @@ export class FileInput extends React.Component { protected onChange = async (evt: React.ChangeEvent) => { const fileList = Array.from(evt.target.files); + if (!fileList.length) { return; } let selectedFiles: FileInputSelection[] = fileList.map(file => ({ file })); + if (this.props.readAsText) { const readingFiles: Promise[] = fileList.map(file => { return new Promise((resolve) => { const reader = new FileReader(); + reader.onloadend = () => { resolve({ file, @@ -46,6 +49,7 @@ export class FileInput extends React.Component { reader.readAsText(file); }); }); + selectedFiles = await Promise.all(readingFiles); } this.props.onSelectFiles(...selectedFiles); @@ -53,6 +57,7 @@ export class FileInput extends React.Component { render() { const { onSelectFiles, readAsText, ...props } = this.props; + return ( { setValue(value: string) { if (value !== this.getValue()) { const nativeInputValueSetter = Object.getOwnPropertyDescriptor(this.input.constructor.prototype, "value").set; + nativeInputValueSetter.call(this.input, value); const evt = new Event("input", { bubbles: true }); + this.input.dispatchEvent(evt); } } getValue(): string { const { value, defaultValue = "" } = this.props; + if (value !== undefined) return value; // controlled input if (this.input) return this.input.value; // uncontrolled input + return defaultValue as string; } @@ -97,6 +102,7 @@ export class Input extends React.Component { private autoFitHeight() { const { multiLine, rows, maxRows } = this.props; + if (!multiLine) { return; } @@ -104,6 +110,7 @@ export class Input extends React.Component { const lineHeight = parseFloat(window.getComputedStyle(textArea).lineHeight); const rowsCount = (this.getValue().match(/\n/g) || []).length + 1; const height = lineHeight * Math.min(Math.max(rowsCount, rows), maxRows); + textArea.style.height = `${height}px`; } @@ -121,6 +128,7 @@ export class Input extends React.Component { break; } const result = validator.validate(value, this.props); + if (isBoolean(result) && !result) { errors.push(this.getValidatorError(value, validator)); } else if (result instanceof Promise) { @@ -143,6 +151,7 @@ export class Input extends React.Component { if (asyncValidators.length > 0) { this.setState({ validating: true, valid: false }); const asyncErrors = await Promise.all(asyncValidators); + if (this.validationId === validationId) { this.setValidation(errors.concat(...asyncErrors.filter(err => err))); } @@ -161,6 +170,7 @@ export class Input extends React.Component { private getValidatorError(value: string, { message }: InputValidator) { if (isFunction(message)) return message(value, this.props); + return message || ""; } @@ -173,6 +183,7 @@ export class Input extends React.Component { // debounce async validators .map(({ debounce, ...validator }) => { if (debounce) validator.validate = debouncePromise(validator.validate, debounce); + return validator; }); // run validation @@ -187,6 +198,7 @@ export class Input extends React.Component { @autobind() onFocus(evt: React.FocusEvent) { const { onFocus, autoSelectOnFocus } = this.props; + if (onFocus) onFocus(evt); if (autoSelectOnFocus) this.select(); this.setState({ focused: true }); @@ -195,6 +207,7 @@ export class Input extends React.Component { @autobind() onBlur(evt: React.FocusEvent) { const { onBlur } = this.props; + if (onBlur) onBlur(evt); if (this.state.dirtyOnBlur) this.setState({ dirty: true, dirtyOnBlur: false }); this.setState({ focused: false }); @@ -238,6 +251,7 @@ export class Input extends React.Component { get showMaxLenIndicator() { const { maxLength, multiLine } = this.props; + return maxLength && multiLine; } @@ -252,13 +266,16 @@ export class Input extends React.Component { componentDidUpdate(prevProps: InputProps) { const { defaultValue, value, dirty, validators } = this.props; + if (prevProps.value !== value || defaultValue !== prevProps.defaultValue) { this.validate(); this.autoFitHeight(); } + if (prevProps.dirty !== dirty) { this.setDirty(dirty); } + if (prevProps.validators !== validators) { this.setupValidators(); } @@ -307,8 +324,10 @@ export class Input extends React.Component { ); const componentId = id || showErrorsAsTooltip ? getRandId({ prefix: "input_tooltip_id" }) : undefined; let tooltipError: React.ReactNode; + if (showErrorsAsTooltip && showErrors) { const tooltipProps = typeof showErrorsAsTooltip === "object" ? showErrorsAsTooltip : {}; + tooltipProps.className = cssNames("InputTooltipError", tooltipProps.className); tooltipError = ( @@ -319,6 +338,7 @@ export class Input extends React.Component { ); } + return (
{tooltipError} diff --git a/src/renderer/components/input/input_validators.ts b/src/renderer/components/input/input_validators.ts index e315127509..53ec426bbb 100644 --- a/src/renderer/components/input/input_validators.ts +++ b/src/renderer/components/input/input_validators.ts @@ -28,6 +28,7 @@ export const isNumber: InputValidator = { message: () => _i18n._(t`Invalid number`), validate: (value, { min, max }) => { const numVal = +value; + return !( isNaN(numVal) || (min != null && numVal < min) || @@ -61,6 +62,7 @@ export const maxLength: InputValidator = { }; const systemNameMatcher = /^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$/; + export const systemName: InputValidator = { message: () => _i18n._(t`A System Name must be lowercase DNS labels separated by dots. DNS labels are alphanumerics and dashes enclosed by alphanumerics.`), validate: value => !!value.match(systemNameMatcher), diff --git a/src/renderer/components/input/search-input-url.tsx b/src/renderer/components/input/search-input-url.tsx index 854adc1024..6a507128e0 100644 --- a/src/renderer/components/input/search-input-url.tsx +++ b/src/renderer/components/input/search-input-url.tsx @@ -30,6 +30,7 @@ export class SearchInputUrl extends React.Component { onChange = (val: string, evt: React.ChangeEvent) => { this.setValue(val); + if (this.props.onChange) { this.props.onChange(val, evt); } @@ -37,6 +38,7 @@ export class SearchInputUrl extends React.Component { render() { const { inputVal } = this; + return ( { @autobind() onGlobalKey(evt: KeyboardEvent) { const meta = evt.metaKey || evt.ctrlKey; + if (meta && evt.key === "f") { this.inputRef.current.focus(); } @@ -54,6 +55,7 @@ export class SearchInput extends React.Component { } // clear on escape-key const escapeKey = evt.nativeEvent.code === "Escape"; + if (escapeKey) { this.clear(); evt.stopPropagation(); @@ -72,9 +74,11 @@ export class SearchInput extends React.Component { render() { const { className, compact, onClear, showClearIcon, bindGlobalFocusHotkey, value, ...inputProps } = this.props; let rightIcon = ; + if (showClearIcon && value) { rightIcon = ; } + return ( { export function FilterIcon(props: Props) { const { type, ...iconProps } = props; + switch (type) { case FilterType.NAMESPACE: return ; 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 9fbcb54add..86478faa03 100644 --- a/src/renderer/components/item-object-list/item-list-layout.tsx +++ b/src/renderer/components/item-object-list/item-list-layout.tsx @@ -104,6 +104,7 @@ export class ItemListLayout extends React.Component { // keep ui user settings in local storage const defaultUserSettings = toJS(this.userSettings); const storage = createStorage("items_list_layout", defaultUserSettings); + Object.assign(this.userSettings, storage.get()); // restore disposeOnUnmount(this, [ reaction(() => toJS(this.userSettings), settings => storage.set(settings)), @@ -113,10 +114,13 @@ export class ItemListLayout extends React.Component { async componentDidMount() { const { store, dependentStores, isClusterScoped } = this.props; const stores = [store, ...dependentStores]; + if (!isClusterScoped) stores.push(namespaceStore); + try { await Promise.all(stores.map(store => store.loadAll())); const subscriptions = stores.map(store => store.subscribe()); + await when(() => this.isUnmounting); subscriptions.forEach(dispose => dispose()); // unsubscribe all } catch (error) { @@ -127,6 +131,7 @@ export class ItemListLayout extends React.Component { componentWillUnmount() { this.isUnmounting = true; const { store, isSelectable } = this.props; + if (isSelectable) store.resetSelection(); } @@ -134,52 +139,64 @@ export class ItemListLayout extends React.Component { [FilterType.SEARCH]: items => { const { searchFilters, isSearchable } = this.props; const search = pageFilters.getValues(FilterType.SEARCH)[0] || ""; + if (search && isSearchable && searchFilters) { const normalizeText = (text: string) => String(text).toLowerCase(); const searchTexts = [search].map(normalizeText); + return items.filter(item => { return searchFilters.some(getTexts => { const sourceTexts: string[] = [getTexts(item)].flat().map(normalizeText); + return sourceTexts.some(source => searchTexts.some(search => source.includes(search))); }); }); } + return items; }, [FilterType.NAMESPACE]: items => { const filterValues = pageFilters.getValues(FilterType.NAMESPACE); + if (filterValues.length > 0) { return items.filter(item => filterValues.includes(item.getNs())); } + return items; }, }; @computed get isReady() { const { isReady, store } = this.props; + return typeof isReady == "boolean" ? isReady : store.isLoaded; } @computed get filters() { let { activeFilters } = pageFilters; const { isClusterScoped, isSearchable, searchFilters } = this.props; + if (isClusterScoped) { activeFilters = activeFilters.filter(({ type }) => type !== FilterType.NAMESPACE); } + if (!(isSearchable && searchFilters)) { activeFilters = activeFilters.filter(({ type }) => type !== FilterType.SEARCH); } + return activeFilters; } applyFilters(filters: ItemsFilter[], items: T[]): T[] { if (!filters || !filters.length) return items; + return filters.reduce((items, filter) => filter(items), items); } @computed get allItems() { const { filterItems, store } = this.props; + return this.applyFilters(filterItems, store.items); } @@ -190,10 +207,12 @@ export class ItemListLayout extends React.Component { Object.entries(filterGroups).forEach(([type, filtersGroup]) => { const filterCallback = filterCallbacks[type]; + if (filterCallback && filtersGroup.length > 0) { filterItems.push(filterCallback); } }); + return this.applyFilters(filterItems, allItems); } @@ -206,8 +225,10 @@ export class ItemListLayout extends React.Component { } = this.props; const { isSelected } = store; const item = this.items.find(item => item.getId() == uid); + if (!item) return; const itemId = item.getId(); + return ( { renderTableContents(item) .map((content, index) => { const cellProps: TableCellProps = isReactNode(content) ? { children: content } : content; + if (copyClassNameFromHeadCells && renderTableHeader) { const headCell = renderTableHeader[index]; + if (headCell) { cellProps.className = cssNames(cellProps.className, headCell.className); } } + return ; }) } @@ -257,6 +281,7 @@ export class ItemListLayout extends React.Component { const selectedCount = selectedItems.length; const tailCount = selectedCount > visibleMaxNamesCount ? selectedCount - visibleMaxNamesCount : 0; const tail = tailCount > 0 ? and {tailCount} more : null; + ConfirmDialog.open({ ok: removeSelectedItems, labelOk: Remove, @@ -274,9 +299,11 @@ export class ItemListLayout extends React.Component { renderFilters() { const { hideFilters } = this.props; const { isReady, userSettings, filters } = this; + if (!isReady || !filters.length || hideFilters || !userSettings.showAppliedFilters) { return; } + return ; } @@ -285,6 +312,7 @@ export class ItemListLayout extends React.Component { const allItemsCount = allItems.length; const itemsCount = items.length; const isFiltered = filters.length > 0 && allItemsCount > itemsCount; + if (isFiltered) { return ( @@ -297,11 +325,13 @@ export class ItemListLayout extends React.Component { ); } + return ; } renderHeaderContent(placeholders: IHeaderPlaceholders): ReactNode { const { title, filters, search, info } = placeholders; + return ( <> {title} @@ -319,14 +349,17 @@ export class ItemListLayout extends React.Component { const allItemsCount = allItems.length; const itemsCount = items.length; const isFiltered = isReady && filters.length > 0; + if (isFiltered) { const toggleFilters = () => userSettings.showAppliedFilters = !userSettings.showAppliedFilters; + return ( Filtered: {itemsCount} / {allItemsCount} ); } + return ( { renderHeader() { const { showHeader, customizeHeader, renderHeaderTitle, headerClassName, isClusterScoped } = this.props; + if (!showHeader) return; const title = typeof renderHeaderTitle === "function" ? renderHeaderTitle(this) : renderHeaderTitle; const placeholders: IHeaderPlaceholders = { @@ -352,8 +386,10 @@ export class ItemListLayout extends React.Component { search: , }; let header = this.renderHeaderContent(placeholders); + if (customizeHeader) { const modifiedHeader = customizeHeader(placeholders, header); + if (isReactNode(modifiedHeader)) { header = modifiedHeader; } else { @@ -363,6 +399,7 @@ export class ItemListLayout extends React.Component { }); } } + return (
{header} @@ -378,6 +415,7 @@ export class ItemListLayout extends React.Component { const { isReady, removeItemsDialog, items } = this; const { selectedItems } = store; const selectedItemId = detailsItem && detailsItem.getId(); + return (
{!isReady && ( @@ -432,6 +470,7 @@ export class ItemListLayout extends React.Component { render() { const { className } = this.props; + return (
{this.renderHeader()} diff --git a/src/renderer/components/item-object-list/page-filters-list.tsx b/src/renderer/components/item-object-list/page-filters-list.tsx index 492dffe20a..f2bdb35bf0 100644 --- a/src/renderer/components/item-object-list/page-filters-list.tsx +++ b/src/renderer/components/item-object-list/page-filters-list.tsx @@ -25,9 +25,11 @@ export class PageFiltersList extends React.Component { renderContent() { const { filters } = this.props; + if (!filters.length) { return null; } + return ( <>
@@ -39,6 +41,7 @@ export class PageFiltersList extends React.Component {
{filters.map(filter => { const { value, type } = filter; + return ( { @computed get groupedOptions() { const options: GroupSelectOption[] = []; const { disableFilters } = this.props; + if (!disableFilters[FilterType.NAMESPACE]) { const selectedValues = pageFilters.getValues(FilterType.NAMESPACE); + options.push({ label: Namespace, options: namespaceStore.items.map(ns => { const name = ns.getName(); + return { type: FilterType.NAMESPACE, value: name, @@ -46,18 +49,21 @@ export class PageFiltersSelect extends React.Component { }) }); } + return options; } @computed get options(): SelectOptionFilter[] { return this.groupedOptions.reduce((options, optGroup) => { options.push(...optGroup.options); + return options; }, []); } private formatLabel = (option: SelectOptionFilter) => { const { label, value, type, selected } = option; + return (
@@ -71,6 +77,7 @@ export class PageFiltersSelect extends React.Component { const { type, value, selected } = option; const { addFilter, removeFilter } = pageFilters; const filter = { type, value }; + if (!selected) { addFilter(filter); } @@ -81,11 +88,13 @@ export class PageFiltersSelect extends React.Component { render() { const { groupedOptions, formatLabel, onSelect, options } = this; + if (!options.length && this.props.allowEmpty) { return null; } const { allowEmpty, disableFilters, ...selectProps } = this.props; const selectedOptions = options.filter(opt => opt.selected); + return (