diff --git a/docs/clusters/adding-clusters.md b/docs/clusters/adding-clusters.md index 87a228567d..d153d8c9bf 100644 --- a/docs/clusters/adding-clusters.md +++ b/docs/clusters/adding-clusters.md @@ -1,6 +1,6 @@ # Adding Clusters -Add clusters by clicking the **Add Cluster** button in the left-side menu. +Add clusters by clicking the **Add Cluster** button in the left-side menu. 1. Click the **Add Cluster** button (indicated with a '+' icon). 2. Enter the path to your kubeconfig file. You'll need to have a kubeconfig file for the cluster you want to add. You can either browse for the path from the file system or or enter it directly. @@ -13,4 +13,10 @@ Selected [cluster contexts](https://kubernetes.io/docs/concepts/configuration/or For more information on kubeconfig see [Kubernetes docs](https://kubernetes.io/docs/concepts/configuration/organize-cluster-access-kubeconfig/). -To see your currently-enabled config with `kubectl`, enter `kubectl config view --minify --raw` in your terminal. \ No newline at end of file +To see your currently-enabled config with `kubectl`, enter `kubectl config view --minify --raw` in your terminal. + +When connecting to a cluster, make sure you have a valid and working kubeconfig for the cluster. Following lists known "gotchas" in some authentication types used in kubeconfig with Lens app. + +## Exec auth plugins + +When using [exec auth](https://kubernetes.io/docs/reference/access-authn-authz/authentication/#configuration) plugins make sure the paths that are used to call any binaries are full paths as Lens app might not be able to call binaries with relative paths. Make also sure that you pass all needed information either as arguments or env variables in the config, Lens app might not have all login shell env variables set automatically. diff --git a/docs/clusters/images/add-cluster.png b/docs/clusters/images/add-cluster.png index b53c09f704..fa7b632026 100644 Binary files a/docs/clusters/images/add-cluster.png and b/docs/clusters/images/add-cluster.png differ diff --git a/docs/extensions/guides/README.md b/docs/extensions/guides/README.md index 2917090212..2f04edfea1 100644 --- a/docs/extensions/guides/README.md +++ b/docs/extensions/guides/README.md @@ -19,10 +19,11 @@ Each guide or sample will include: | [Stores](stores.md) | | | [Components](components.md) | | | [KubeObjectListLayout](kube-object-list-layout.md) | | +| [Working with mobx](working-with-mobx.md) | | ## Samples | Sample | APIs | | ----- | ----- | [helloworld](https://github.com/lensapp/lens-extension-samples/tree/master/helloworld-sample) | LensMainExtension
LensRendererExtension
Component.Icon
Component.IconProps | -[minikube](https://github.com/lensapp/lens-extension-samples/tree/master/minikube-sample) | LensMainExtension
Store.clusterStore
Store.workspaceStore | \ No newline at end of file +[minikube](https://github.com/lensapp/lens-extension-samples/tree/master/minikube-sample) | LensMainExtension
Store.clusterStore
Store.workspaceStore | diff --git a/docs/extensions/guides/working-with-mobx.md b/docs/extensions/guides/working-with-mobx.md new file mode 100644 index 0000000000..5577ff6bdc --- /dev/null +++ b/docs/extensions/guides/working-with-mobx.md @@ -0,0 +1,23 @@ +# Working with mobx + +## Introduction + +Lens uses `mobx` as its state manager on top of React's state management system. +This helps with having a more declarative style of managing state, as opposed to `React`'s native `setState` mechanism. +You should already have a basic understanding of how `React` handles state ([read here](https://reactjs.org/docs/faq-state.html) for more information). +However, if you do not, here is a quick overview. + +- A `React.Component` is generic over both `Props` and `State` (with default empty object types). +- `Props` should be considered read-only from the point of view of the component and is the mechanism for passing in "arguments" to a component. +- `State` is a component's internal state and can be read by accessing the parent field `state`. +- `State` **must** be updated using the `setState` parent method which merges the new data with the old state. +- `React` does do some optimizations around re-rendering components after quick successions of `setState` calls. + +## How mobx works: + +`mobx` is a package that provides an abstraction over `React`'s state management. The three main concepts are: +- `observable`: data stored in the component's `state` +- `action`: a function that modifies any `observable` data +- `computed`: data that is derived from `observable` data but is not actually stored. Think of this as computing `isEmpty` vs an `observable` field called `count`. + +Further reading is available from `mobx`'s [website](https://mobx.js.org/the-gist-of-mobx.html). diff --git a/extensions/metrics-cluster-feature/src/metrics-feature.ts b/extensions/metrics-cluster-feature/src/metrics-feature.ts index 4787280b61..f843fe1506 100644 --- a/extensions/metrics-cluster-feature/src/metrics-feature.ts +++ b/extensions/metrics-cluster-feature/src/metrics-feature.ts @@ -28,7 +28,7 @@ export class MetricsFeature extends ClusterFeature.Feature { name = "metrics"; latestVersion = "v2.17.2-lens1"; - config: MetricsConfiguration = { + templateContext: MetricsConfiguration = { persistence: { enabled: false, storageClass: null, @@ -53,12 +53,12 @@ export class MetricsFeature extends ClusterFeature.Feature { // Check if there are storageclasses const storageClassApi = K8sApi.forCluster(cluster, K8sApi.StorageClass); const scs = await storageClassApi.list(); - this.config.persistence.enabled = scs.some(sc => ( + 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' )); - super.applyResources(cluster, super.renderTemplates(path.join(__dirname, "../resources/"))); + super.applyResources(cluster, path.join(__dirname, "../resources/")); } async upgrade(cluster: Store.Cluster): Promise { diff --git a/extensions/telemetry/src/tracker.ts b/extensions/telemetry/src/tracker.ts index 8c2fdab8e4..982ae146c4 100644 --- a/extensions/telemetry/src/tracker.ts +++ b/extensions/telemetry/src/tracker.ts @@ -118,7 +118,8 @@ export class Tracker extends Util.Singleton { kubernetesVersion: cluster.metadata.version, distribution: cluster.metadata.distribution, nodesCount: cluster.metadata.nodes, - lastSeen: cluster.metadata.lastSeen + lastSeen: cluster.metadata.lastSeen, + prometheus: cluster.metadata.prometheus }); } diff --git a/integration/__tests__/app.tests.ts b/integration/__tests__/app.tests.ts index c8c7af7c8b..eb67fc9ee8 100644 --- a/integration/__tests__/app.tests.ts +++ b/integration/__tests__/app.tests.ts @@ -136,6 +136,7 @@ describe("Lens integration tests", () => { it('adds cluster in test-workspace', async () => { await app.client.click('#current-workspace .Icon'); + await app.client.waitForVisible('.WorkspaceMenu li[title="test description"]'); await app.client.click('.WorkspaceMenu li[title="test description"]'); await addMinikubeCluster(app); await app.client.waitUntilTextExists("pre.kube-auth-out", "Authentication proxy started"); @@ -144,6 +145,7 @@ describe("Lens integration tests", () => { it('checks if default workspace has active cluster', async () => { await app.client.click('#current-workspace .Icon'); + await app.client.waitForVisible('.WorkspaceMenu > li:first-of-type'); await app.client.click('.WorkspaceMenu > li:first-of-type'); await app.client.waitForVisible(".ClustersMenu .ClusterIcon.active"); }); diff --git a/locales/en/messages.po b/locales/en/messages.po index 02ddfdb227..c66819dd24 100644 --- a/locales/en/messages.po +++ b/locales/en/messages.po @@ -738,6 +738,7 @@ msgstr "Current / Target" msgid "Current Healthy" msgstr "Current Healthy" +#: src/renderer/components/+workloads-statefulsets/statefulset-scale-dialog.tsx:101 #: src/renderer/components/+workloads-deployments/deployment-scale-dialog.tsx:103 msgid "Current replica scale: {currentReplicas}" msgstr "Current replica scale: {currentReplicas}" @@ -828,6 +829,7 @@ msgstr "Description" msgid "Desired Healthy" msgstr "Desired Healthy" +#: src/renderer/components/+workloads-statefulsets/statefulset-scale-dialog.tsx:105 #: src/renderer/components/+workloads-deployments/deployment-scale-dialog.tsx:107 msgid "Desired number of replicas" msgstr "Desired number of replicas" @@ -1091,6 +1093,7 @@ msgstr "Helm branch <0>{0} already in use" msgid "Hide" msgstr "Hide" +#: src/renderer/components/+workloads-statefulsets/statefulset-scale-dialog.tsx:127 #: src/renderer/components/+workloads-deployments/deployment-scale-dialog.tsx:116 msgid "High number of replicas may cause cluster performance issues" msgstr "High number of replicas may cause cluster performance issues" @@ -2298,6 +2301,7 @@ msgstr "Runtime Class" msgid "Save" msgstr "Save" +#: src/renderer/components/+workloads-statefulsets/statefulset-scale-dialog.tsx:155 #: src/renderer/components/+workloads-deployments/deployment-scale-dialog.tsx:128 #: src/renderer/components/+workloads-deployments/deployments.tsx:83 #: src/renderer/components/+workloads-deployments/deployments.tsx:84 @@ -2308,6 +2312,10 @@ msgstr "Scale" msgid "Scale Deployment <0>{deploymentName}" msgstr "Scale Deployment <0>{deploymentName}" +#: src/renderer/components/+workloads-statefulsets/statefulset-scale-dialog.tsx:139 +msgid "Scale Stateful Set <0>{statefulSetName}" +msgstr "Scale Stateful Set <0>{statefulSetName}" + #: src/renderer/components/+workloads-cronjobs/cronjob-details.tsx:45 #: src/renderer/components/+workloads-cronjobs/cronjobs.tsx:46 msgid "Schedule" diff --git a/locales/fi/messages.po b/locales/fi/messages.po index 0b668b7605..c5192a7eb9 100644 --- a/locales/fi/messages.po +++ b/locales/fi/messages.po @@ -734,6 +734,7 @@ msgstr "" msgid "Current Healthy" msgstr "" +#: src/renderer/components/+workloads-statefulsets/statefulset-scale-dialog.tsx:101 #: src/renderer/components/+workloads-deployments/deployment-scale-dialog.tsx:103 msgid "Current replica scale: {currentReplicas}" msgstr "" @@ -824,6 +825,7 @@ msgstr "" msgid "Desired Healthy" msgstr "" +#: src/renderer/components/+workloads-statefulsets/statefulset-scale-dialog.tsx:105 #: src/renderer/components/+workloads-deployments/deployment-scale-dialog.tsx:107 msgid "Desired number of replicas" msgstr "" @@ -1082,6 +1084,7 @@ msgstr "" msgid "Hide" msgstr "" +#: src/renderer/components/+workloads-statefulsets/statefulset-scale-dialog.tsx:127 #: src/renderer/components/+workloads-deployments/deployment-scale-dialog.tsx:116 msgid "High number of replicas may cause cluster performance issues" msgstr "" @@ -2281,6 +2284,7 @@ msgstr "" msgid "Save" msgstr "" +#: src/renderer/components/+workloads-statefulsets/statefulset-scale-dialog.tsx:155 #: src/renderer/components/+workloads-deployments/deployment-scale-dialog.tsx:128 #: src/renderer/components/+workloads-deployments/deployments.tsx:83 #: src/renderer/components/+workloads-deployments/deployments.tsx:84 @@ -2291,6 +2295,10 @@ msgstr "" msgid "Scale Deployment <0>{deploymentName}" msgstr "" +#: src/renderer/components/+workloads-statefulsets/statefulset-scale-dialog.tsx:139 +msgid "Scale Stateful Set <0>{statefulSetName}" +msgstr "" + #: src/renderer/components/+workloads-cronjobs/cronjob-details.tsx:45 #: src/renderer/components/+workloads-cronjobs/cronjobs.tsx:46 msgid "Schedule" diff --git a/locales/ru/messages.po b/locales/ru/messages.po index dc947d724c..de4ee7c7fb 100644 --- a/locales/ru/messages.po +++ b/locales/ru/messages.po @@ -739,6 +739,7 @@ msgstr "Текущее / Цель" msgid "Current Healthy" msgstr "" +#: src/renderer/components/+workloads-statefulsets/statefulset-scale-dialog.tsx:101 #: src/renderer/components/+workloads-deployments/deployment-scale-dialog.tsx:103 msgid "Current replica scale: {currentReplicas}" msgstr "Текущий размер реплики: {currentReplicas}" @@ -829,6 +830,7 @@ msgstr "Описание" msgid "Desired Healthy" msgstr "" +#: src/renderer/components/+workloads-statefulsets/statefulset-scale-dialog.tsx:105 #: src/renderer/components/+workloads-deployments/deployment-scale-dialog.tsx:107 msgid "Desired number of replicas" msgstr "Нужный уровень реплик" @@ -1092,6 +1094,7 @@ msgstr "" msgid "Hide" msgstr "Скрыть" +#: src/renderer/components/+workloads-statefulsets/statefulset-scale-dialog.tsx:127 #: src/renderer/components/+workloads-deployments/deployment-scale-dialog.tsx:116 msgid "High number of replicas may cause cluster performance issues" msgstr "Большое количество реплик может вызвать проблемы с производительностью кластера" @@ -2299,6 +2302,7 @@ msgstr "" msgid "Save" msgstr "Сохранить" +#: src/renderer/components/+workloads-statefulsets/statefulset-scale-dialog.tsx:155 #: src/renderer/components/+workloads-deployments/deployment-scale-dialog.tsx:128 #: src/renderer/components/+workloads-deployments/deployments.tsx:83 #: src/renderer/components/+workloads-deployments/deployments.tsx:84 @@ -2309,6 +2313,10 @@ msgstr "Масштабировать" msgid "Scale Deployment <0>{deploymentName}" msgstr "Масштабировать Deployment <0>{deploymentName}" +#: src/renderer/components/+workloads-statefulsets/statefulset-scale-dialog.tsx:139 +msgid "Scale Stateful Set <0>{statefulSetName}" +msgstr "Масштабировать Stateful Set <0>{statefulSetName}" + #: src/renderer/components/+workloads-cronjobs/cronjob-details.tsx:45 #: src/renderer/components/+workloads-cronjobs/cronjobs.tsx:46 msgid "Schedule" diff --git a/mkdocs.yml b/mkdocs.yml index f225d2250c..3e95eae065 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -33,6 +33,7 @@ nav: - Main Extension: extensions/guides/main-extension.md - Renderer Extension: extensions/guides/renderer-extension.md - Generator: extensions/guides/generator.md + - Working with mobx: extensions/guides/working-with-mobx.md - Testing and Publishing: - Testing Extensions: extensions/testing-and-publishing/testing.md - Publishing Extensions: extensions/testing-and-publishing/publishing.md @@ -62,7 +63,6 @@ theme: icon: material/toggle-switch-off-outline name: Switch to dark mode features: - - navigation.instant - toc.autohide - search.suggest - search.highlight diff --git a/package.json b/package.json index 53d67fb862..869f7453c3 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "compile:main": "yarn run webpack --config webpack.main.ts", "compile:renderer": "yarn run webpack --config webpack.renderer.ts", "compile:i18n": "yarn run lingui compile", - "compile:extension-types": "yarn run tsc -p ./tsconfig.extensions.json --outDir src/extensions/npm/extensions/dist", + "compile:extension-types": "yarn run webpack --config webpack.extensions.ts", "npm:fix-package-version": "yarn run ts-node build/set_npm_version.ts", "build:linux": "yarn run compile && electron-builder --linux --dir -c.productName=Lens", "build:mac": "yarn run compile && electron-builder --mac --dir -c.productName=Lens", @@ -225,7 +225,6 @@ "electron-devtools-installer": "^3.1.1", "electron-updater": "^4.3.1", "electron-window-state": "^5.0.3", - "file-type": "^14.7.1", "filenamify": "^4.1.0", "fs-extra": "^9.0.1", "handlebars": "^4.7.6", @@ -362,6 +361,7 @@ "nodemon": "^2.0.4", "patch-package": "^6.2.2", "postinstall-postinstall": "^2.1.0", + "prettier": "^2.2.0", "progress-bar-webpack-plugin": "^2.1.0", "raw-loader": "^4.0.1", "react": "^16.14.0", diff --git a/src/common/cluster-store.ts b/src/common/cluster-store.ts index 907f9b7aad..35ec663a55 100644 --- a/src/common/cluster-store.ts +++ b/src/common/cluster-store.ts @@ -2,7 +2,7 @@ import { workspaceStore } from "./workspace-store"; import path from "path"; import { app, ipcRenderer, remote, webFrame } from "electron"; import { unlink } from "fs-extra"; -import { action, computed, observable, reaction, toJS } from "mobx"; +import { action, comparer, computed, observable, reaction, toJS } from "mobx"; import { BaseStore } from "./base-store"; import { Cluster, ClusterState } from "../main/cluster"; import migrations from "../migrations/cluster-store"; @@ -23,9 +23,15 @@ export interface ClusterIconUpload { } export interface ClusterMetadata { - [key: string]: string | number | boolean; + [key: string]: string | number | boolean | object; } +export type ClusterPrometheusMetadata = { + success?: boolean; + provider?: string; + autoDetected?: boolean; +}; + export interface ClusterStoreModel { activeCluster?: ClusterId; // last opened cluster clusters?: ClusterModel[] @@ -47,9 +53,15 @@ export interface ClusterModel { kubeConfig?: string; // yaml } -export interface ClusterPreferences { +export interface ClusterPreferences extends ClusterPrometheusPreferences{ terminalCWD?: string; clusterName?: string; + iconOrder?: number; + icon?: string; + httpsProxy?: string; +} + +export interface ClusterPrometheusPreferences { prometheus?: { namespace: string; service: string; @@ -59,9 +71,6 @@ export interface ClusterPreferences { prometheusProvider?: { type: string; }; - iconOrder?: number; - icon?: string; - httpsProxy?: string; } export class ClusterStore extends BaseStore { @@ -84,6 +93,9 @@ export class ClusterStore extends BaseStore { super({ configName: "lens-cluster-store", accessPropertiesByDotNotation: false, // To make dots safe in cluster context names + syncOptions: { + equals: comparer.structural, + }, migrations, }); diff --git a/src/common/utils/downloadFile.ts b/src/common/utils/downloadFile.ts index a58e9242b4..4c65901d3d 100644 --- a/src/common/utils/downloadFile.ts +++ b/src/common/utils/downloadFile.ts @@ -3,6 +3,7 @@ import request from "request"; export interface DownloadFileOptions { url: string; gzip?: boolean; + timeout?: number; } export interface DownloadFileTicket { @@ -11,9 +12,9 @@ export interface DownloadFileTicket { cancel(): void; } -export function downloadFile({ url, gzip = true }: DownloadFileOptions): DownloadFileTicket { +export function downloadFile({ url, timeout, gzip = true }: DownloadFileOptions): DownloadFileTicket { const fileChunks: Buffer[] = []; - const req = request(url, { gzip }); + const req = request(url, { gzip, timeout }); const promise: Promise = new Promise((resolve, reject) => { req.on("data", (chunk: Buffer) => { fileChunks.push(chunk); diff --git a/src/common/utils/rectify-array.ts b/src/common/utils/rectify-array.ts index 48feb3a165..0e4d701114 100644 --- a/src/common/utils/rectify-array.ts +++ b/src/common/utils/rectify-array.ts @@ -3,6 +3,6 @@ * @param items either one item or an array of items * @returns a list of items */ -export function recitfy(items: T | T[]): T[] { +export function rectify(items: T | T[]): T[] { return Array.isArray(items) ? items : [items]; } diff --git a/src/extensions/__tests__/extension-loader.test.ts b/src/extensions/__tests__/extension-loader.test.ts new file mode 100644 index 0000000000..90aebfaee9 --- /dev/null +++ b/src/extensions/__tests__/extension-loader.test.ts @@ -0,0 +1,126 @@ +import { ExtensionLoader } from "../extension-loader"; + +const manifestPath = "manifest/path"; +const manifestPath2 = "manifest/path2"; +const manifestPath3 = "manifest/path3"; + +jest.mock( + "electron", + () => ({ + ipcRenderer: { + invoke: jest.fn(async (channel: string, ...args: any[]) => { + if (channel === "extensions:loaded") { + return [ + [ + manifestPath, + { + manifest: { + name: "TestExtension", + version: "1.0.0", + }, + absolutePath: "/test/1", + manifestPath, + isBundled: false, + isEnabled: true, + }, + ], + [ + manifestPath2, + { + manifest: { + name: "TestExtension2", + version: "2.0.0", + }, + absolutePath: "/test/2", + manifestPath: manifestPath2, + isBundled: false, + isEnabled: true, + }, + ], + ]; + } + }), + on: jest.fn( + (channel: string, listener: (event: any, ...args: any[]) => void) => { + if (channel === "extensions:loaded") { + // First initialize with extensions 1 and 2 + // and then broadcast event to remove extensioin 2 and add extension number 3 + setTimeout(() => { + listener({}, [ + [ + manifestPath, + { + manifest: { + name: "TestExtension", + version: "1.0.0", + }, + absolutePath: "/test/1", + manifestPath, + isBundled: false, + isEnabled: true, + }, + ], + [ + manifestPath3, + { + manifest: { + name: "TestExtension3", + version: "3.0.0", + }, + absolutePath: "/test/3", + manifestPath: manifestPath3, + isBundled: false, + isEnabled: true, + }, + ], + ]); + }, 10); + } + } + ), + }, + }), + { + virtual: true, + } +); + +describe("ExtensionLoader", () => { + it("renderer updates extension after ipc broadcast", async (done) => { + const extensionLoader = new ExtensionLoader(); + + expect(extensionLoader.userExtensions).toMatchInlineSnapshot(`Map {}`); + + await extensionLoader.init(); + + setTimeout(() => { + // Assert the extensions after the extension broadcast event + expect(extensionLoader.userExtensions).toMatchInlineSnapshot(` + Map { + "manifest/path" => Object { + "absolutePath": "/test/1", + "isBundled": false, + "isEnabled": true, + "manifest": Object { + "name": "TestExtension", + "version": "1.0.0", + }, + "manifestPath": "manifest/path", + }, + "manifest/path3" => Object { + "absolutePath": "/test/3", + "isBundled": false, + "isEnabled": true, + "manifest": Object { + "name": "TestExtension3", + "version": "3.0.0", + }, + "manifestPath": "manifest/path3", + }, + } + `); + + done(); + }, 10); + }); +}); diff --git a/src/extensions/__tests__/lens-extension.test.ts b/src/extensions/__tests__/lens-extension.test.ts index d6ba04cbb5..277a76b410 100644 --- a/src/extensions/__tests__/lens-extension.test.ts +++ b/src/extensions/__tests__/lens-extension.test.ts @@ -9,6 +9,7 @@ describe("lens extension", () => { name: "foo-bar", version: "0.1.1" }, + absolutePath: "/absolute/fake/", manifestPath: "/this/is/fake/package.json", isBundled: false, isEnabled: true diff --git a/src/extensions/cluster-feature.ts b/src/extensions/cluster-feature.ts index 6381d267cc..4cb2c9bf5a 100644 --- a/src/extensions/cluster-feature.ts +++ b/src/extensions/cluster-feature.ts @@ -10,17 +10,27 @@ import { requestMain } from "../common/ipc"; import { clusterKubectlApplyAllHandler } from "../common/cluster-ipc"; export interface ClusterFeatureStatus { + /** feature's current version, as set by the implementation */ currentVersion: string; - installed: boolean; + /** feature's latest version, as set by the implementation */ latestVersion: string; + /** whether the feature is installed or not, as set by the implementation */ + installed: boolean; + /** whether the feature can be upgraded or not, as set by the implementation */ canUpgrade: boolean; } export abstract class ClusterFeature { - name: string; - latestVersion: string; - config: any; + /** + * this field sets the template parameters that are to be applied to any templated kubernetes resources that are to be installed for the feature. + * See the renderTemplates() method for more details + */ + templateContext: any; + + /** + * this field holds the current feature status, is accessed directly by Lens + */ @observable status: ClusterFeatureStatus = { currentVersion: null, installed: false, @@ -28,15 +38,59 @@ export abstract class ClusterFeature { canUpgrade: false }; + /** + * to be implemented in the derived class, this method is typically called by Lens when a user has indicated that this feature is to be installed. The implementation + * of this method should install kubernetes resources using the applyResources() method, or by directly accessing the kubernetes api (K8sApi) + * + * @param cluster the cluster that the feature is to be installed on + */ abstract async install(cluster: Cluster): Promise; + /** + * to be implemented in the derived class, this method is typically called by Lens when a user has indicated that this feature is to be ugraded. The implementation + * of this method should upgrade the kubernetes resources already installed, if relevant to the feature + * + * @param cluster the cluster that the feature is to be upgraded on + */ abstract async upgrade(cluster: Cluster): Promise; + /** + * to be implemented in the derived class, this method is typically called by Lens when a user has indicated that this feature is to be uninstalled. The implementation + * of this method should install kubernetes resources using the kubernetes api (K8sApi) + * + * @param cluster the cluster that the feature is to be uninstalled from + */ abstract async uninstall(cluster: Cluster): Promise; + /** + * to be implemented in the derived class, this method is called periodically by Lens to determine details about the feature's current status. The implementation + * of this method should provide the current status information. The currentVersion and latestVersion fields may be displayed by Lens in describing the feature. + * The installed field should be set to true if the feature has been installed, otherwise false. Also, Lens relies on the canUpgrade field to determine if the feature + * can be upgraded so the implementation should set the canUpgrade field according to specific rules for the feature, if relevant. + * + * @param cluster the cluster that the feature may be installed on + * + * @return a promise, resolved with the updated ClusterFeatureStatus + */ abstract async updateStatus(cluster: Cluster): Promise; - protected async applyResources(cluster: Cluster, resources: string[]) { + /** + * this is a helper method that conveniently applies kubernetes resources to the cluster. + * + * @param cluster the cluster that the resources are to be applied to + * @param resourceSpec as a string type this is a folder path that is searched for files specifying kubernetes resources. The files are read and if any of the resource + * files are templated, the template parameters are filled using the templateContext field (See renderTemplate() method). Finally the resources are applied to the + * cluster. As a string[] type resourceSpec is treated as an array of fully formed (not templated) kubernetes resources that are applied to the cluster + */ + protected async applyResources(cluster: Cluster, resourceSpec: string | string[]) { + let resources: string[]; + + if ( typeof resourceSpec === "string" ) { + resources = this.renderTemplates(resourceSpec); + } else { + resources = resourceSpec; + } + if (app) { await new ResourceApplier(cluster).kubectlApplyAll(resources); } else { @@ -44,6 +98,14 @@ export abstract class ClusterFeature { } } + /** + * this is a helper method that conveniently reads kubernetes resource files into a string array. It also fills templated resource files with the template parameter values + * specified by the templateContext field. Templated files must end with the extension '.hb' and the template syntax must be compatible with handlebars.js + * + * @param folderPath this is a folder path that is searched for files defining kubernetes resources. + * + * @return an array of strings, each string being the contents of a resource file found in the folder path. This can be passed directly to applyResources() + */ protected renderTemplates(folderPath: string): string[] { const resources: string[] = []; logger.info(`[FEATURE]: render templates from ${folderPath}`); @@ -52,7 +114,7 @@ export abstract class ClusterFeature { const raw = fs.readFileSync(file); if (filename.endsWith('.hb')) { const template = hb.compile(raw.toString()); - resources.push(template(this.config)); + resources.push(template(this.templateContext)); } else { resources.push(raw.toString()); } diff --git a/src/extensions/extension-discovery.ts b/src/extensions/extension-discovery.ts index ab17606e92..452bba4d65 100644 --- a/src/extensions/extension-discovery.ts +++ b/src/extensions/extension-discovery.ts @@ -11,6 +11,12 @@ import type { LensExtensionId, LensExtensionManifest } from "./lens-extension"; export interface InstalledExtension { readonly manifest: LensExtensionManifest; + + // Absolute path to the non-symlinked source folder, + // e.g. "/Users/user/.k8slens/extensions/helloworld" + readonly absolutePath: string; + + // Absolute to the symlinked package.json file readonly manifestPath: string; readonly isBundled: boolean; // defined in project root's package.json isEnabled: boolean; @@ -174,6 +180,24 @@ export class ExtensionDiscovery { } }; + /** + * Uninstalls extension by path. + * The application will detect the folder unlink and remove the extension from the UI automatically. + * @param absolutePath Path to the non-symlinked folder of the extension + */ + async uninstallExtension(absolutePath: string) { + logger.info(`${logModule} Uninstalling ${absolutePath}`); + + const exists = await fs.pathExists(absolutePath); + + if (!exists) { + throw new Error(`Extension path ${absolutePath} doesn't exist`); + } + + // fs.remove does nothing if the path doesn't exist anymore + await fs.remove(absolutePath); + } + async load(): Promise> { if (this.loadStarted) { // The class is simplified by only supporting .load() to be called once @@ -230,6 +254,7 @@ export class ExtensionDiscovery { const isEnabled = isBundled || extensionsStore.isEnabled(installedManifestPath); return { + absolutePath: path.dirname(manifestPath), manifestPath: installedManifestPath, manifest: manifestJson, isBundled, diff --git a/src/extensions/extension-loader.ts b/src/extensions/extension-loader.ts index af0e9d6f86..a73f173e7c 100644 --- a/src/extensions/extension-loader.ts +++ b/src/extensions/extension-loader.ts @@ -1,4 +1,5 @@ import { app, ipcRenderer, remote } from "electron"; +import { EventEmitter } from "events"; import { action, computed, observable, reaction, toJS, when } from "mobx"; import path from "path"; import { getHostedCluster } from "../common/cluster-store"; @@ -26,26 +27,32 @@ export class ExtensionLoader { protected instances = observable.map(); protected readonly requestExtensionsChannel = "extensions:loaded"; + // emits event "remove" of type LensExtension when the extension is removed + private events = new EventEmitter(); + @observable isLoaded = false; whenLoaded = when(() => this.isLoaded); @computed get userExtensions(): Map { const extensions = this.extensions.toJS(); + extensions.forEach((ext, extId) => { if (ext.isBundled) { extensions.delete(extId); } }); + return extensions; } @action async init() { if (ipcRenderer) { - this.initRenderer(); + await this.initRenderer(); } else { - this.initMain(); + await this.initMain(); } + extensionsStore.manageState(this); } @@ -57,8 +64,27 @@ export class ExtensionLoader { this.extensions.set(extension.manifestPath as LensExtensionId, extension); } + removeInstance(lensExtensionId: LensExtensionId) { + logger.info(`${logModule} deleting extension instance ${lensExtensionId}`); + const instance = this.instances.get(lensExtensionId); + + if (!instance) { + return; + } + + try { + instance.disable(); + this.events.emit("remove", instance); + this.instances.delete(lensExtensionId); + } catch (error) { + logger.error(`${logModule}: deactivation extension error`, { lensExtensionId, error }); + } + + } + removeExtension(lensExtensionId: LensExtensionId) { - // TODO: Remove the extension properly (from menus etc.) + this.removeInstance(lensExtensionId); + if (!this.extensions.delete(lensExtensionId)) { throw new Error(`Can't remove extension ${lensExtensionId}, doesn't exist.`); } @@ -79,14 +105,25 @@ export class ExtensionLoader { } protected async initRenderer() { - const extensionListHandler = ( extensions: [LensExtensionId, InstalledExtension][]) => { + const extensionListHandler = (extensions: [LensExtensionId, InstalledExtension][]) => { this.isLoaded = true; + const receivedExtensionIds = extensions.map(([lensExtensionId]) => lensExtensionId); + + // Add new extensions extensions.forEach(([extId, ext]) => { if (!this.extensions.has(extId)) { this.extensions.set(extId, ext); } }); + + // Remove deleted extensions + this.extensions.forEach((_, lensExtensionId) => { + if (!receivedExtensionIds.includes(lensExtensionId)) { + this.removeExtension(lensExtensionId); + } + }); }; + requestMain(this.requestExtensionsChannel).then(extensionListHandler); subscribeToBroadcast(this.requestExtensionsChannel, (event, extensions: [LensExtensionId, InstalledExtension][]) => { extensionListHandler(extensions); @@ -95,36 +132,73 @@ export class ExtensionLoader { loadOnMain() { logger.info(`${logModule}: load on main`); - this.autoInitExtensions(async (ext: LensMainExtension) => [ - registries.menuRegistry.add(ext.appMenus) - ]); + this.autoInitExtensions(async (extension: LensMainExtension) => { + // Each .add returns a function to remove the item + const removeItems = [ + registries.menuRegistry.add(extension.appMenus) + ]; + + this.events.on("remove", (removedExtension: LensRendererExtension) => { + // manifestPath is considered the id + if (removedExtension.manifestPath === extension.manifestPath) { + removeItems.forEach(remove => { + remove(); + }); + } + }); + + return removeItems; + }); } loadOnClusterManagerRenderer() { logger.info(`${logModule}: load on main renderer (cluster manager)`); - this.autoInitExtensions(async (ext: LensRendererExtension) => [ - registries.globalPageRegistry.add(ext.globalPages, ext), - registries.globalPageMenuRegistry.add(ext.globalPageMenus, ext), - registries.appPreferenceRegistry.add(ext.appPreferences), - registries.clusterFeatureRegistry.add(ext.clusterFeatures), - registries.statusBarRegistry.add(ext.statusBarItems), - ]); + this.autoInitExtensions(async (extension: LensRendererExtension) => { + const removeItems = [ + registries.globalPageRegistry.add(extension.globalPages, extension), + registries.globalPageMenuRegistry.add(extension.globalPageMenus, extension), + registries.appPreferenceRegistry.add(extension.appPreferences), + registries.clusterFeatureRegistry.add(extension.clusterFeatures), + registries.statusBarRegistry.add(extension.statusBarItems), + ]; + + this.events.on("remove", (removedExtension: LensRendererExtension) => { + if (removedExtension.manifestPath === extension.manifestPath) { + removeItems.forEach(remove => { + remove(); + }); + } + }); + + return removeItems; + }); } loadOnClusterRenderer() { logger.info(`${logModule}: load on cluster renderer (dashboard)`); const cluster = getHostedCluster(); - this.autoInitExtensions(async (ext: LensRendererExtension) => { - if (await ext.isEnabledForCluster(cluster) === false) { + this.autoInitExtensions(async (extension: LensRendererExtension) => { + if (await extension.isEnabledForCluster(cluster) === false) { return []; } - return [ - registries.clusterPageRegistry.add(ext.clusterPages, ext), - registries.clusterPageMenuRegistry.add(ext.clusterPageMenus, ext), - registries.kubeObjectMenuRegistry.add(ext.kubeObjectMenuItems), - registries.kubeObjectDetailRegistry.add(ext.kubeObjectDetailItems), - registries.kubeObjectStatusRegistry.add(ext.kubeObjectStatusTexts) + + const removeItems = [ + registries.clusterPageRegistry.add(extension.clusterPages, extension), + registries.clusterPageMenuRegistry.add(extension.clusterPageMenus, extension), + registries.kubeObjectMenuRegistry.add(extension.kubeObjectMenuItems), + registries.kubeObjectDetailRegistry.add(extension.kubeObjectDetailItems), + registries.kubeObjectStatusRegistry.add(extension.kubeObjectStatusTexts) ]; + + this.events.on("remove", (removedExtension: LensRendererExtension) => { + if (removedExtension.manifestPath === extension.manifestPath) { + removeItems.forEach(remove => { + remove(); + }); + } + }); + + return removeItems; }); } @@ -148,13 +222,7 @@ export class ExtensionLoader { logger.error(`${logModule}: activation extension error`, { ext, err }); } } else if (!ext.isEnabled && alreadyInit) { - try { - const instance = this.instances.get(extId); - instance.disable(); - this.instances.delete(extId); - } catch (err) { - logger.error(`${logModule}: deactivation extension error`, { ext, err }); - } + this.removeInstance(extId); } } }, { diff --git a/src/extensions/lens-extension.ts b/src/extensions/lens-extension.ts index 3c9f70eb49..1d16183d75 100644 --- a/src/extensions/lens-extension.ts +++ b/src/extensions/lens-extension.ts @@ -114,3 +114,7 @@ export class LensExtension { export function sanitizeExtensionName(name: string) { return name.replace("@", "").replace("/", "--"); } + +export function extensionDisplayName(name: string, version: string) { + return `${name}@${version}`; +} diff --git a/src/extensions/registries/__tests__/page-registry.test.ts b/src/extensions/registries/__tests__/page-registry.test.ts index fafc801cc4..4c94274a37 100644 --- a/src/extensions/registries/__tests__/page-registry.test.ts +++ b/src/extensions/registries/__tests__/page-registry.test.ts @@ -11,6 +11,7 @@ describe("getPageUrl", () => { name: "foo-bar", version: "0.1.1" }, + absolutePath: "/absolute/fake/", manifestPath: "/this/is/fake/package.json", isBundled: false, isEnabled: true @@ -41,6 +42,7 @@ describe("globalPageRegistry", () => { name: "@acme/foo-bar", version: "0.1.1" }, + absolutePath: "/absolute/fake/", manifestPath: "/this/is/fake/package.json", isBundled: false, isEnabled: true diff --git a/src/extensions/registries/base-registry.ts b/src/extensions/registries/base-registry.ts index ff8760151c..4bfb3f9cd2 100644 --- a/src/extensions/registries/base-registry.ts +++ b/src/extensions/registries/base-registry.ts @@ -1,7 +1,7 @@ // Base class for extensions-api registries import { action, observable } from "mobx"; import { LensExtension } from "../lens-extension"; -import { recitfy } from "../../common/utils"; +import { rectify } from "../../common/utils"; export class BaseRegistry { private items = observable([], { deep: false }); @@ -13,7 +13,7 @@ export class BaseRegistry { add(items: T | T[], ext?: LensExtension): () => void; // allow method overloading with required "ext" @action add(items: T | T[]) { - const itemArray = recitfy(items); + const itemArray = rectify(items); this.items.push(...itemArray); return () => this.remove(...itemArray); } diff --git a/src/extensions/registries/page-registry.ts b/src/extensions/registries/page-registry.ts index 6eb2194205..1b04bcf2f8 100644 --- a/src/extensions/registries/page-registry.ts +++ b/src/extensions/registries/page-registry.ts @@ -7,7 +7,7 @@ import { compile } from "path-to-regexp"; import { BaseRegistry } from "./base-registry"; import { LensExtension, sanitizeExtensionName } from "../lens-extension"; import logger from "../../main/logger"; -import { recitfy } from "../../common/utils"; +import { rectify } from "../../common/utils"; export interface PageRegistration { /** @@ -54,7 +54,7 @@ export function getExtensionPageUrl

({ extensionId, pageId = "" export class PageRegistry extends BaseRegistry { @action add(items: PageRegistration | PageRegistration[], ext: LensExtension) { - const itemArray = recitfy(items); + const itemArray = rectify(items); let registeredPages: RegisteredPage[] = []; try { registeredPages = itemArray.map(page => ({ diff --git a/src/main/cluster.ts b/src/main/cluster.ts index e2831e8c3f..80b0289c08 100644 --- a/src/main/cluster.ts +++ b/src/main/cluster.ts @@ -1,8 +1,8 @@ import { ipcMain } from "electron"; -import type { ClusterId, ClusterMetadata, ClusterModel, ClusterPreferences } from "../common/cluster-store"; +import type { ClusterId, ClusterMetadata, ClusterModel, ClusterPreferences, ClusterPrometheusPreferences } from "../common/cluster-store"; import type { IMetricsReqParams } from "../renderer/api/endpoints/metrics.api"; import type { WorkspaceId } from "../common/workspace-store"; -import { action, computed, observable, reaction, toJS, when } from "mobx"; +import { action, comparer, computed, observable, reaction, toJS, when } from "mobx"; import { apiKubePrefix } from "../common/vars"; import { broadcastMessage } from "../common/ipc"; import { ContextHandler } from "./context-handler"; @@ -27,7 +27,8 @@ export enum ClusterMetadataKey { CLUSTER_ID = "id", DISTRIBUTION = "distribution", NODES_COUNT = "nodes", - LAST_SEEN = "lastSeen" + LAST_SEEN = "lastSeen", + PROMETHEUS = "prometheus" } export type ClusterRefreshOptions = { @@ -42,7 +43,6 @@ export interface ClusterState { accessible: boolean; ready: boolean; failureReason: string; - eventCount: number; isAdmin: boolean; allowedNamespaces: string[] allowedResources: string[] @@ -74,7 +74,6 @@ export class Cluster implements ClusterModel, ClusterState { @observable disconnected = true; // false if user has selected to connect @observable failureReason: string; @observable isAdmin = false; - @observable eventCount = 0; @observable preferences: ClusterPreferences = {}; @observable metadata: ClusterMetadata = {}; @observable allowedNamespaces: string[] = []; @@ -89,6 +88,13 @@ export class Cluster implements ClusterModel, ClusterState { return this.preferences.clusterName || this.contextName; } + @computed get prometheusPreferences(): ClusterPrometheusPreferences { + const { prometheus, prometheusProvider } = this.preferences; + return toJS({ prometheus, prometheusProvider }, { + recurseEverything: true, + }); + } + get version(): string { return String(this.metadata?.version) || ""; } @@ -138,6 +144,7 @@ export class Cluster implements ClusterModel, ClusterState { if (ipcMain) { this.eventDisposers.push( reaction(() => this.getState(), () => this.pushState()), + reaction(() => this.prometheusPreferences, (prefs) => this.contextHandler.setupPrometheus(prefs), { equals: comparer.structural, }), () => { clearInterval(refreshTimer); clearInterval(refreshMetadataTimer); @@ -209,10 +216,7 @@ export class Cluster implements ClusterModel, ClusterState { await this.refreshConnectionStatus(); if (this.accessible) { this.isAdmin = await this.isClusterAdmin(); - await Promise.all([ - this.refreshEvents(), - this.refreshAllowedResources(), - ]); + await this.refreshAllowedResources(); if (opts.refreshMetadata) { this.refreshMetadata(); } @@ -242,11 +246,6 @@ export class Cluster implements ClusterModel, ClusterState { this.allowedResources = await this.getAllowedResources(); } - @action - async refreshEvents() { - this.eventCount = await this.getEventCount(); - } - protected getKubeconfig(): KubeConfig { return loadConfig(this.kubeConfigPath); } @@ -332,40 +331,6 @@ export class Cluster implements ClusterModel, ClusterState { }); } - protected async getEventCount(): Promise { - if (!this.isAdmin) { - return 0; - } - const client = this.getProxyKubeconfig().makeApiClient(CoreV1Api); - try { - const response = await client.listEventForAllNamespaces(false, null, null, null, 1000); - const uniqEventSources = new Set(); - const warnings = response.body.items.filter(e => e.type !== 'Normal'); - for (const w of warnings) { - if (w.involvedObject.kind === 'Pod') { - try { - const { body: pod } = await client.readNamespacedPod(w.involvedObject.name, w.involvedObject.namespace); - logger.debug(`checking pod ${w.involvedObject.namespace}/${w.involvedObject.name}`); - if (podHasIssues(pod)) { - uniqEventSources.add(w.involvedObject.uid); - } - } catch (err) { - } - } else { - uniqEventSources.add(w.involvedObject.uid); - } - } - const nodes = (await client.listNode()).body.items; - const nodeNotificationCount = nodes - .map(getNodeWarningConditions) - .reduce((sum, conditions) => sum + conditions.length, 0); - return uniqEventSources.size + nodeNotificationCount; - } catch (error) { - logger.error("Failed to fetch event count: " + JSON.stringify(error)); - return 0; - } - } - toJSON(): ClusterModel { const model: ClusterModel = { id: this.id, @@ -393,7 +358,6 @@ export class Cluster implements ClusterModel, ClusterState { accessible: this.accessible, failureReason: this.failureReason, isAdmin: this.isAdmin, - eventCount: this.eventCount, allowedNamespaces: this.allowedNamespaces, allowedResources: this.allowedResources, }; diff --git a/src/main/context-handler.ts b/src/main/context-handler.ts index a1ef58ad0f..2c0c0b4e8d 100644 --- a/src/main/context-handler.ts +++ b/src/main/context-handler.ts @@ -1,5 +1,5 @@ import type { PrometheusProvider, PrometheusService } from "./prometheus/provider-registry"; -import type { ClusterPreferences } from "../common/cluster-store"; +import type { ClusterPrometheusPreferences } from "../common/cluster-store"; import type { Cluster } from "./cluster"; import type httpProxy from "http-proxy"; import url, { UrlWithStringQuery } from "url"; @@ -22,7 +22,7 @@ export class ContextHandler { this.setupPrometheus(cluster.preferences); } - protected setupPrometheus(preferences: ClusterPreferences = {}) { + public setupPrometheus(preferences: ClusterPrometheusPreferences = {}) { this.prometheusProvider = preferences.prometheusProvider?.type; this.prometheusPath = null; if (preferences.prometheus) { @@ -32,13 +32,18 @@ export class ContextHandler { } protected async resolvePrometheusPath(): Promise { - const { service, namespace, port } = await this.getPrometheusService(); + 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; } @@ -52,13 +57,7 @@ export class ContextHandler { return await provider.getPrometheusService(apiClient); }); const resolvedPrometheusServices = await Promise.all(prometheusPromises); - const service = resolvedPrometheusServices.filter(n => n)[0]; - return service || { - id: "lens", - namespace: "lens-metrics", - service: "prometheus", - port: 80 - }; + return resolvedPrometheusServices.filter(n => n)[0]; } async getPrometheusPath(): Promise { diff --git a/src/main/prometheus/lens.ts b/src/main/prometheus/lens.ts index 33126220de..eddd2457a3 100644 --- a/src/main/prometheus/lens.ts +++ b/src/main/prometheus/lens.ts @@ -38,7 +38,7 @@ export class PrometheusLens implements PrometheusProvider { cpuRequests:`sum(kube_pod_container_resource_requests{node=~"${opts.nodes}", resource="cpu"}) by (component)`, cpuLimits: `sum(kube_pod_container_resource_limits{node=~"${opts.nodes}", resource="cpu"}) by (component)`, cpuCapacity: `sum(kube_node_status_capacity{node=~"${opts.nodes}", resource="cpu"}) by (component)`, - podUsage: `sum(kubelet_running_pod_count{instance=~"${opts.nodes}"})`, + podUsage: `sum({__name__=~"kubelet_running_pod_count|kubelet_running_pods", instance=~"${opts.nodes}"})`, podCapacity: `sum(kube_node_status_capacity{node=~"${opts.nodes}", resource="pods"}) by (component)`, fsSize: `sum(node_filesystem_size_bytes{kubernetes_node=~"${opts.nodes}", mountpoint="/"}) by (kubernetes_node)`, fsUsage: `sum(node_filesystem_size_bytes{kubernetes_node=~"${opts.nodes}", mountpoint="/"} - node_filesystem_avail_bytes{kubernetes_node=~"${opts.nodes}", mountpoint="/"}) by (kubernetes_node)` diff --git a/src/main/prometheus/operator.ts b/src/main/prometheus/operator.ts index ef9f530299..cb7b9944f7 100644 --- a/src/main/prometheus/operator.ts +++ b/src/main/prometheus/operator.ts @@ -46,7 +46,7 @@ export class PrometheusOperator implements PrometheusProvider { cpuRequests:`sum(kube_pod_container_resource_requests{node=~"${opts.nodes}", resource="cpu"})`, cpuLimits: `sum(kube_pod_container_resource_limits{node=~"${opts.nodes}", resource="cpu"})`, cpuCapacity: `sum(kube_node_status_capacity{node=~"${opts.nodes}", resource="cpu"})`, - podUsage: `sum(kubelet_running_pod_count{node=~"${opts.nodes}"})`, + podUsage: `sum({__name__=~"kubelet_running_pod_count|kubelet_running_pods", node=~"${opts.nodes}"})`, podCapacity: `sum(kube_node_status_capacity{node=~"${opts.nodes}", resource="pods"})`, fsSize: `sum(node_filesystem_size_bytes{mountpoint="/"} * on (pod,namespace) group_left(node) kube_pod_info{node=~"${opts.nodes}"})`, fsUsage: `sum(node_filesystem_size_bytes{mountpoint="/"} * on (pod,namespace) group_left(node) kube_pod_info{node=~"${opts.nodes}"} - node_filesystem_avail_bytes{mountpoint="/"} * on (pod,namespace) group_left(node) kube_pod_info{node=~"${opts.nodes}"})` diff --git a/src/main/prometheus/stacklight.ts b/src/main/prometheus/stacklight.ts index bd23dd63cf..116ad728bb 100644 --- a/src/main/prometheus/stacklight.ts +++ b/src/main/prometheus/stacklight.ts @@ -38,7 +38,7 @@ export class PrometheusStacklight implements PrometheusProvider { cpuRequests:`sum(kube_pod_container_resource_requests{node=~"${opts.nodes}", resource="cpu"}) by (component)`, cpuLimits: `sum(kube_pod_container_resource_limits{node=~"${opts.nodes}", resource="cpu"}) by (component)`, cpuCapacity: `sum(kube_node_status_capacity{node=~"${opts.nodes}", resource="cpu"}) by (component)`, - podUsage: `sum(kubelet_running_pod_count{instance=~"${opts.nodes}"})`, + podUsage: `sum({__name__=~"kubelet_running_pod_count|kubelet_running_pods", instance=~"${opts.nodes}"})`, podCapacity: `sum(kube_node_status_capacity{node=~"${opts.nodes}", resource="pods"}) by (component)`, fsSize: `sum(node_filesystem_size_bytes{node=~"${opts.nodes}", mountpoint="/"}) by (node)`, fsUsage: `sum(node_filesystem_size_bytes{node=~"${opts.nodes}", mountpoint="/"} - node_filesystem_avail_bytes{node=~"${opts.nodes}", mountpoint="/"}) by (node)` diff --git a/src/main/routes/metrics-route.ts b/src/main/routes/metrics-route.ts index 254abe188f..4572030cca 100644 --- a/src/main/routes/metrics-route.ts +++ b/src/main/routes/metrics-route.ts @@ -1,7 +1,9 @@ +import _ from "lodash"; import { LensApiRequest } from "../router"; import { LensApi } from "../lens-api"; -import { Cluster } from "../cluster"; -import _ from "lodash"; +import { Cluster, ClusterMetadataKey } from "../cluster"; +import { ClusterPrometheusMetadata } from "../../common/cluster-store"; +import logger from "../logger"; export type IMetricsQuery = string | string[] | { [metricName: string]: string; @@ -22,11 +24,9 @@ async function loadMetrics(promQueries: string[], cluster: Cluster, prometheusPa try { return await cluster.getMetrics(prometheusPath, { query, ...queryParams }); } catch (error) { - if (lastAttempt || error?.statusCode === 404) { - return { - status: error.toString(), - data: { result: [] }, - }; + if (lastAttempt || (error?.statusCode >= 400 && error?.statusCode < 500)) { + logger.error("[Metrics]: metrics not available", { error }); + throw new Error("Metrics not available"); } await new Promise(resolve => setTimeout(resolve, (attempt + 1) * 1000)); // add delay before repeating request @@ -43,13 +43,19 @@ async function loadMetrics(promQueries: string[], cluster: Cluster, prometheusPa 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); @@ -65,8 +71,12 @@ class MetricsRoute extends LensApi { const data = Object.fromEntries(Object.keys(payload).map((metricName, i) => [metricName, result[i]])); this.respondJson(response, data); } + prometheusMetadata.success = true; } catch { + prometheusMetadata.success = false; this.respondJson(response, {}); + } finally { + cluster.metadata[ClusterMetadataKey.PROMETHEUS] = prometheusMetadata; } } } diff --git a/src/renderer/api/endpoints/stateful-set.api.ts b/src/renderer/api/endpoints/stateful-set.api.ts index 6a6f8c151d..0f3728b218 100644 --- a/src/renderer/api/endpoints/stateful-set.api.ts +++ b/src/renderer/api/endpoints/stateful-set.api.ts @@ -4,6 +4,29 @@ import { IAffinity, WorkloadKubeObject } from "../workload-kube-object"; import { autobind } from "../../utils"; import { KubeApi } from "../kube-api"; +export class StatefulSetApi extends KubeApi { + protected getScaleApiUrl(params: { namespace: string; name: string }) { + return this.getUrl(params) + "/scale"; + } + + getReplicas(params: { namespace: string; name: string }): Promise { + return this.request + .get(this.getScaleApiUrl(params)) + .then(({ status }: any) => status?.replicas); + } + + scale(params: { namespace: string; name: string }, replicas: number) { + return this.request.put(this.getScaleApiUrl(params), { + data: { + metadata: params, + spec: { + replicas + } + } + }); + } +} + @autobind() export class StatefulSet extends WorkloadKubeObject { static kind = "StatefulSet"; @@ -67,17 +90,22 @@ export class StatefulSet extends WorkloadKubeObject { observedGeneration: number; replicas: number; currentReplicas: number; + readyReplicas: number; currentRevision: string; updateRevision: string; collisionCount: number; }; + getReplicas() { + return this.spec.replicas || 0; + } + getImages() { const containers: IPodContainer[] = get(this, "spec.template.spec.containers", []); return [...containers].map(container => container.image); } } -export const statefulSetApi = new KubeApi({ +export const statefulSetApi = new StatefulSetApi({ objectConstructor: StatefulSet, }); diff --git a/src/renderer/components/+add-cluster/add-cluster.scss b/src/renderer/components/+add-cluster/add-cluster.scss index d80373406d..9583ec17b9 100644 --- a/src/renderer/components/+add-cluster/add-cluster.scss +++ b/src/renderer/components/+add-cluster/add-cluster.scss @@ -1,16 +1,20 @@ -.AddCluster { - .hint { - margin-top: -$padding; - color: $textColorSecondary; - - > * { - vertical-align: middle; - } - } +.AddClusters { + --flex-gap: #{$unit * 2}; + $spacing: $padding * 2; .AceEditor { min-height: 200px; max-height: 400px; + border: 1px solid var(--colorVague); + border-radius: $radius; + + .theme-light & { + border-color: var(--borderFaintColor); + } + + .editor { + border-radius: $radius; + } } .Select { @@ -34,4 +38,13 @@ code { color: $pink-400; } + + .text-primary { + color: var(--textColorAccent); + } + + .hint { + display: block; + padding-top: 6px; + } } diff --git a/src/renderer/components/+add-cluster/add-cluster.tsx b/src/renderer/components/+add-cluster/add-cluster.tsx index 1fae256618..139ebd392c 100644 --- a/src/renderer/components/+add-cluster/add-cluster.tsx +++ b/src/renderer/components/+add-cluster/add-cluster.tsx @@ -25,6 +25,8 @@ import { Notifications } from "../notifications"; import { Tab, Tabs } from "../tabs"; import { ExecValidationNotFoundError } from "../../../common/custom-errors"; import { appEventBus } from "../../../common/event-bus"; +import { PageLayout } from "../layout/page-layout"; +import { docsUrl } from "../../../common/vars"; enum KubeConfigSourceTab { FILE = "file", @@ -193,44 +195,19 @@ export class AddCluster extends React.Component { renderInfo() { return ( - -

Clusters associated with Lens

-

- Add clusters by clicking the Add Cluster button. - You'll need to obtain a working kubeconfig for the cluster you want to add. You can either browse it from the file system or paste it as a text from the clipboard. -

-

- Selected cluster contexts are added as a separate item in the - left-side cluster menu to allow you to operate easily on multiple clusters and/or contexts. -

-

- For more information on kubeconfig see Kubernetes docs. -

-

- NOTE: Any manually added cluster is not merged into your kubeconfig file. -

-

- To see your currently enabled config with kubectl, use kubectl config view --minify --raw command in your terminal. -

-

- When connecting to a cluster, make sure you have a valid and working kubeconfig for the cluster. Following lists known "gotchas" in some authentication types used in kubeconfig with Lens - app. -

-

Exec auth plugins

-

- When using exec auth plugins make sure the paths that are used to call - any binaries - are full paths as Lens app might not be able to call binaries with relative paths. Make also sure that you pass all needed information either as arguments or env variables in the config, - Lens app might not have all login shell env variables set automatically. -

- +

+ Add clusters by clicking the Add Cluster button. + You'll need to obtain a working kubeconfig for the cluster you want to add. + You can either browse it from the file system or paste it as a text from the clipboard. + Read more about adding clusters here. +

); } renderKubeConfigSource() { return ( <> - + Select kubeconfig file} @@ -242,7 +219,7 @@ export class AddCluster extends React.Component { /> {this.sourceTab === KubeConfigSourceTab.FILE && ( - <> +
Pro-Tip: you can also drag-n-drop kubeconfig file to this area - +
)} {this.sourceTab === KubeConfigSourceTab.TEXT && ( - <> +
Pro-Tip: paste kubeconfig to get available contexts - +
)} ); @@ -296,7 +273,7 @@ export class AddCluster extends React.Component { ? Selected contexts: {this.selectedContexts.length} : Select contexts; return ( - <> +
this.downloadUrl = v} - onSubmit={this.addExtensions} - /> -
-
- ); + async uninstallExtension(extension: InstalledExtension) { + const extensionName = extensionDisplayName(extension.manifest.name, extension.manifest.version); + + try { + await extensionDiscovery.uninstallExtension(extension.absolutePath); + } catch (error) { + Notifications.error( +

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

+ ); + } } renderExtensions() { const { extensions, extensionsPath, search } = this; + if (!extensions.length) { return ( -
- {search && No search results found} - {!search &&

There are no extensions in {extensionsPath}

} +
+ +
+ {search &&

No search results found

} + {!search &&

There are no extensions in {extensionsPath}

} +
); } + return extensions.map(ext => { const { manifestPath: extId, isEnabled, manifest } = ext; const { name, description } = manifest; + return (
-
-
+
+
Name: {name}
-
+
Description: {description}
- {!isEnabled && ( - - )} - {isEnabled && ( - - )} +
+ {!isEnabled && ( + + )} + {isEnabled && ( + + )} + +
); }); } render() { + const topHeader =

Manage Lens Extensions

; + const { installPath } = this; return ( - Extensions}> - - + + +

Lens Extensions

+
+ 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. + Check out documentation to learn more. +
+ +
+ Install Extension:}/> +
+ this.installPath = v} + onSubmit={this.installFromUrlOrPath} + iconLeft="link" + iconRight={ + Browse} + /> + } + /> +
+
+ +

Installed Extensions

+
this.search = value} /> -
- {this.renderExtensions()} -
- - - + {this.renderExtensions()} +
+
+
); } } diff --git a/src/renderer/components/+preferences/kubectl-binaries.tsx b/src/renderer/components/+preferences/kubectl-binaries.tsx index 3b0b258ab4..118298c561 100644 --- a/src/renderer/components/+preferences/kubectl-binaries.tsx +++ b/src/renderer/components/+preferences/kubectl-binaries.tsx @@ -1,8 +1,7 @@ import React, { useState } from 'react'; import { Trans } from '@lingui/macro'; -import { isPath } from '../input/input_validators'; import { Checkbox } from '../checkbox'; -import { Input } from '../input'; +import { Input, InputValidators } from '../input'; import { SubTitle } from '../layout/sub-title'; import { UserPreferences, userStore } from '../../../common/user-store'; import { observer } from 'mobx-react'; @@ -12,6 +11,7 @@ import { SelectOption, Select } from '../select'; export const KubectlBinaries = observer(({ preferences }: { preferences: UserPreferences }) => { const [downloadPath, setDownloadPath] = useState(preferences.downloadBinariesPath || ""); const [binariesPath, setBinariesPath] = useState(preferences.kubectlBinariesPath || ""); + const pathValidator = downloadPath ? InputValidators.isPath : undefined; const downloadMirrorOptions: SelectOption[] = [ { value: "default", label: "Default (Google)" }, @@ -47,7 +47,7 @@ export const KubectlBinaries = observer(({ preferences }: { preferences: UserPre theme="round-black" value={downloadPath} placeholder={userStore.getDefaultKubectlPath()} - validators={isPath} + validators={pathValidator} onChange={setDownloadPath} onBlur={save} disabled={!preferences.downloadKubectlBinaries} @@ -60,7 +60,7 @@ export const KubectlBinaries = observer(({ preferences }: { preferences: UserPre theme="round-black" placeholder={bundledKubectlPath()} value={binariesPath} - validators={isPath} + validators={pathValidator} onChange={setBinariesPath} onBlur={save} disabled={preferences.downloadKubectlBinaries} diff --git a/src/renderer/components/+workloads-statefulsets/statefulset-scale-dialog.scss b/src/renderer/components/+workloads-statefulsets/statefulset-scale-dialog.scss new file mode 100644 index 0000000000..1a91c4078a --- /dev/null +++ b/src/renderer/components/+workloads-statefulsets/statefulset-scale-dialog.scss @@ -0,0 +1,49 @@ +.StatefulSetScaleDialog { + .Wizard { + .header { + span { + color: #a0a0a0; + white-space: nowrap; + text-overflow: ellipsis; + } + } + + .WizardStep { + .step-content { + min-height: 90px; + overflow: hidden; + } + } + + .current-scale { + font-weight: bold + } + + .desired-scale { + flex: 1.1 0; + } + + .slider-container { + flex: 1 0; + } + + .plus-minus-container { + margin-left: $margin * 2; + .Icon { + --color-active: black; + } + } + + .warning { + color: $colorSoftError; + font-size: small; + display: flex; + align-items: center; + + .Icon { + margin: 0; + margin-right: $margin; + } + } + } +} diff --git a/src/renderer/components/+workloads-statefulsets/statefulset-scale-dialog.test.tsx b/src/renderer/components/+workloads-statefulsets/statefulset-scale-dialog.test.tsx new file mode 100755 index 0000000000..7a7f484cbe --- /dev/null +++ b/src/renderer/components/+workloads-statefulsets/statefulset-scale-dialog.test.tsx @@ -0,0 +1,167 @@ +import '@testing-library/jest-dom/extend-expect'; + +jest.mock("../../api/endpoints"); +import { statefulSetApi } from "../../api/endpoints"; +import { StatefulSetScaleDialog } from "./statefulset-scale-dialog"; +import { render, waitFor, fireEvent } from '@testing-library/react'; +import React from 'react'; + +const dummyStatefulSet = { + apiVersion: 'v1', + kind: 'dummy', + metadata: { + uid: 'dummy', + name: 'dummy', + creationTimestamp: 'dummy', + resourceVersion: 'dummy', + selfLink: 'link', + }, + selfLink: 'link', + + spec: { + serviceName: 'dummy', + replicas: 1, + selector: { + matchLabels: { 'label': 'label' } + }, + template: { + metadata: { + labels: { + app: 'app', + }, + }, + spec: { + containers: [{ + name: 'dummy', + image: 'dummy', + ports: [{ + containerPort: 1234, + name: 'dummy', + }], + volumeMounts: [{ + name: 'dummy', + mountPath: 'dummy', + }], + }], + tolerations: [{ + key: 'dummy', + operator: 'dummy', + effect: 'dummy', + tolerationSeconds: 1, + }], + }, + }, + volumeClaimTemplates: [{ + metadata: { + name: 'dummy', + }, + spec: { + accessModes: ['dummy'], + resources: { + requests: { + storage: 'dummy', + }, + }, + }, + }], + }, + status: { + observedGeneration: 1, + replicas: 1, + currentReplicas: 1, + readyReplicas: 1, + currentRevision: 'dummy', + updateRevision: 'dummy', + collisionCount: 1, + }, + + getImages: jest.fn(), + getReplicas: jest.fn(), + getSelectors: jest.fn(), + getTemplateLabels: jest.fn(), + getAffinity: jest.fn(), + getTolerations: jest.fn(), + getNodeSelectors: jest.fn(), + getAffinityNumber: jest.fn(), + getId: jest.fn(), + getResourceVersion: jest.fn(), + getName: jest.fn(), + getNs: jest.fn(), + getAge: jest.fn(), + getFinalizers: jest.fn(), + getLabels: jest.fn(), + getAnnotations: jest.fn(), + getOwnerRefs: jest.fn(), + getSearchFields: jest.fn(), + toPlainObject: jest.fn(), + update: jest.fn(), + delete: jest.fn(), +}; + +describe('', () => { + it('renders w/o errors', () => { + const { container } = render(); + expect(container).toBeInstanceOf(HTMLElement); + }); + + it('init with a dummy stateful set and mocked current/desired scale', async () => { + // mock statefulSetApi.getReplicas() which will be called + // when rendered. + const initReplicas = 1; + statefulSetApi.getReplicas = jest.fn().mockImplementationOnce(async () => initReplicas); + const { getByTestId } = render(); + StatefulSetScaleDialog.open(dummyStatefulSet); + // we need to wait for the StatefulSetScaleDialog to show up + // because there is an in which renders null at start. + await waitFor(async () => { + const [currentScale, desiredScale] = await Promise.all([ + getByTestId('current-scale'), + getByTestId('desired-scale'), + ]); + expect(currentScale).toHaveTextContent(`${initReplicas}`); + expect(desiredScale).toHaveTextContent(`${initReplicas}`); + }); + }); + + it('changes the desired scale when clicking the icon buttons +/-', async () => { + const initReplicas = 1; + statefulSetApi.getReplicas = jest.fn().mockImplementationOnce(async () => initReplicas); + const component = render(); + StatefulSetScaleDialog.open(dummyStatefulSet); + await waitFor(async () => { + expect(await component.findByTestId('desired-scale')).toHaveTextContent(`${initReplicas}`); + expect(await component.findByTestId('current-scale')).toHaveTextContent(`${initReplicas}`); + expect((await component.baseElement.querySelector('input').value)).toBe(`${initReplicas}`); + }); + + const up = await component.findByTestId('desired-replicas-up'); + const down = await component.findByTestId('desired-replicas-down'); + fireEvent.click(up); + expect(await component.findByTestId('desired-scale')).toHaveTextContent(`${initReplicas + 1}`); + expect(await component.findByTestId('current-scale')).toHaveTextContent(`${initReplicas}`); + expect((await component.baseElement.querySelector('input').value)).toBe(`${initReplicas + 1}`); + + fireEvent.click(down); + expect(await component.findByTestId('desired-scale')).toHaveTextContent(`${initReplicas}`); + expect(await component.findByTestId('current-scale')).toHaveTextContent(`${initReplicas}`); + expect((await component.baseElement.querySelector('input').value)).toBe(`${initReplicas}`); + + // edge case, desiredScale must >= 0 + let times = 10; + for (let i = 0; i < times; i++) { + fireEvent.click(down); + } + expect(await component.findByTestId('desired-scale')).toHaveTextContent('0'); + expect((await component.baseElement.querySelector('input').value)).toBe('0'); + + // edge case, desiredScale must <= scaleMax (100) + times = 120; + for (let i = 0; i < times; i++) { + fireEvent.click(up); + } + expect(await component.findByTestId('desired-scale')).toHaveTextContent('100'); + expect((component.baseElement.querySelector("input").value)).toBe('100'); + expect(await component.findByTestId('warning')) + .toHaveTextContent('High number of replicas may cause cluster performance issues'); + }); +}); diff --git a/src/renderer/components/+workloads-statefulsets/statefulset-scale-dialog.tsx b/src/renderer/components/+workloads-statefulsets/statefulset-scale-dialog.tsx new file mode 100644 index 0000000000..031490eb17 --- /dev/null +++ b/src/renderer/components/+workloads-statefulsets/statefulset-scale-dialog.tsx @@ -0,0 +1,164 @@ +import "./statefulset-scale-dialog.scss"; + +import { StatefulSet, statefulSetApi } from "../../api/endpoints"; +import React, { Component } from "react"; +import { computed, observable } from "mobx"; +import { observer } from "mobx-react"; +import { Trans } from "@lingui/macro"; +import { Dialog, DialogProps } from "../dialog"; +import { Wizard, WizardStep } from "../wizard"; +import { Icon } from "../icon"; +import { Slider } from "../slider"; +import { Notifications } from "../notifications"; +import { cssNames } from "../../utils"; + +interface Props extends Partial { +} + +@observer +export class StatefulSetScaleDialog extends Component { + @observable static isOpen = false; + @observable static data: StatefulSet = null; + + @observable ready = false; + @observable currentReplicas = 0; + @observable desiredReplicas = 0; + + static open(statefulSet: StatefulSet) { + StatefulSetScaleDialog.isOpen = true; + StatefulSetScaleDialog.data = statefulSet; + } + + static close() { + StatefulSetScaleDialog.isOpen = false; + } + + get statefulSet() { + return StatefulSetScaleDialog.data; + } + + close = () => { + StatefulSetScaleDialog.close(); + }; + + onOpen = async () => { + const { statefulSet } = this; + this.currentReplicas = await statefulSetApi.getReplicas({ + namespace: statefulSet.getNs(), + name: statefulSet.getName(), + }); + this.desiredReplicas = this.currentReplicas; + this.ready = true; + }; + + onClose = () => { + this.ready = false; + }; + + onChange = (evt: React.ChangeEvent, value: number) => { + this.desiredReplicas = value; + }; + + @computed get scaleMax() { + const { currentReplicas } = this; + const defaultMax = 50; + return currentReplicas <= defaultMax + ? defaultMax * 2 + : currentReplicas * 2; + } + + scale = async () => { + const { statefulSet } = this; + const { currentReplicas, desiredReplicas, close } = this; + try { + if (currentReplicas !== desiredReplicas) { + await statefulSetApi.scale({ + name: statefulSet.getName(), + namespace: statefulSet.getNs(), + }, desiredReplicas); + } + close(); + } catch (err) { + Notifications.error(err); + } + }; + + desiredReplicasUp = () => { + this.desiredReplicas < this.scaleMax && this.desiredReplicas++; + }; + + desiredReplicasDown = () => { + this.desiredReplicas > 0 && this.desiredReplicas--; + }; + + renderContents() { + const { currentReplicas, desiredReplicas, onChange, scaleMax } = this; + const warning = currentReplicas < 10 && desiredReplicas > 90; + return ( + <> +
+ Current replica scale: {currentReplicas} +
+
+
+ Desired number of replicas: {desiredReplicas} +
+
+ +
+
+ + +
+
+ {warning && +
+ + High number of replicas may cause cluster performance issues +
+ } + + ); + } + + render() { + const { className, ...dialogProps } = this.props; + const statefulSetName = this.statefulSet ? this.statefulSet.getName() : ""; + const header = ( +
+ Scale Stateful Set {statefulSetName} +
+ ); + return ( + + + Scale} + disabledNext={!this.ready} + > + {this.renderContents()} + + + + ); + } +} diff --git a/src/renderer/components/+workloads-statefulsets/statefulsets.scss b/src/renderer/components/+workloads-statefulsets/statefulsets.scss index ec39b5d53f..8f7f665b34 100644 --- a/src/renderer/components/+workloads-statefulsets/statefulsets.scss +++ b/src/renderer/components/+workloads-statefulsets/statefulsets.scss @@ -5,7 +5,7 @@ } &.pods { - flex-grow: 0.3; + flex-grow: 1; } &.warning { diff --git a/src/renderer/components/+workloads-statefulsets/statefulsets.tsx b/src/renderer/components/+workloads-statefulsets/statefulsets.tsx index e6f25ad9fe..7bd4ea35ff 100644 --- a/src/renderer/components/+workloads-statefulsets/statefulsets.tsx +++ b/src/renderer/components/+workloads-statefulsets/statefulsets.tsx @@ -3,21 +3,27 @@ import "./statefulsets.scss"; import React from "react"; import { observer } from "mobx-react"; import { RouteComponentProps } from "react-router"; -import { Trans } from "@lingui/macro"; -import { StatefulSet } from "../../api/endpoints"; +import { t, Trans } from "@lingui/macro"; +import { StatefulSet, statefulSetApi } from "../../api/endpoints"; import { podsStore } from "../+workloads-pods/pods.store"; import { statefulSetStore } from "./statefulset.store"; import { nodesStore } from "../+nodes/nodes.store"; import { eventStore } from "../+events/event.store"; +import { KubeObjectMenuProps } from "../kube-object/kube-object-menu"; import { KubeObjectListLayout } from "../kube-object"; import { IStatefulSetsRouteParams } from "../+workloads"; import { KubeObjectStatusIcon } from "../kube-object-status-icon"; +import { StatefulSetScaleDialog } from "./statefulset-scale-dialog"; +import { MenuItem } from "../menu/menu"; +import { _i18n } from "../../i18n"; +import { Icon } from "../icon/icon"; +import { kubeObjectMenuRegistry } from "../../../extensions/registries/kube-object-menu-registry"; enum sortBy { name = "name", namespace = "namespace", - pods = "pods", age = "age", + replicas = "replicas", } interface Props extends RouteComponentProps { @@ -25,8 +31,9 @@ interface Props extends RouteComponentProps { @observer export class StatefulSets extends React.Component { - getPodsLength(statefulSet: StatefulSet) { - return statefulSetStore.getChildPods(statefulSet).length; + renderPods(statefulSet: StatefulSet) { + const { readyReplicas, currentReplicas } = statefulSet.status; + return `${readyReplicas || 0}/${currentReplicas || 0}`; } render() { @@ -38,7 +45,7 @@ export class StatefulSets extends React.Component { [sortBy.name]: (statefulSet: StatefulSet) => statefulSet.getName(), [sortBy.namespace]: (statefulSet: StatefulSet) => statefulSet.getNs(), [sortBy.age]: (statefulSet: StatefulSet) => statefulSet.metadata.creationTimestamp, - [sortBy.pods]: (statefulSet: StatefulSet) => this.getPodsLength(statefulSet), + [sortBy.replicas]: (statefulSet: StatefulSet) => statefulSet.getReplicas(), }} searchFilters={[ (statefulSet: StatefulSet) => statefulSet.getSearchFields(), @@ -47,18 +54,43 @@ export class StatefulSets extends React.Component { renderTableHeader={[ { title: Name, className: "name", sortBy: sortBy.name }, { title: Namespace, className: "namespace", sortBy: sortBy.namespace }, - { title: Pods, className: "pods", sortBy: sortBy.pods }, + { title: Pods, className: "pods" }, + { title: Replicas, className: "replicas", sortBy: sortBy.replicas }, { className: "warning" }, { title: Age, className: "age", sortBy: sortBy.age }, ]} renderTableContents={(statefulSet: StatefulSet) => [ statefulSet.getName(), statefulSet.getNs(), - this.getPodsLength(statefulSet), + this.renderPods(statefulSet), + statefulSet.getReplicas(), , statefulSet.getAge(), ]} + renderItemMenu={(item: StatefulSet) => { + return ; + }} /> ); } } + +export function StatefulSetMenu(props: KubeObjectMenuProps) { + const { object, toolbar } = props; + return ( + <> + StatefulSetScaleDialog.open(object)}> + + Scale + + + ); +} + +kubeObjectMenuRegistry.add({ + kind: "StatefulSet", + apiVersions: ["apps/v1"], + components: { + MenuItem: StatefulSetMenu + } +}); diff --git a/src/renderer/components/app.tsx b/src/renderer/components/app.tsx index 2be6949411..59b95f776c 100755 --- a/src/renderer/components/app.tsx +++ b/src/renderer/components/app.tsx @@ -37,12 +37,17 @@ import { webFrame } from "electron"; import { clusterPageRegistry, getExtensionPageUrl, PageRegistration, RegisteredPage } from "../../extensions/registries/page-registry"; import { extensionLoader } from "../../extensions/extension-loader"; import { appEventBus } from "../../common/event-bus"; -import { requestMain } from "../../common/ipc"; +import { broadcastMessage, requestMain } from "../../common/ipc"; import whatInput from 'what-input'; import { clusterSetFrameIdHandler } from "../../common/cluster-ipc"; import { ClusterPageMenuRegistration, clusterPageMenuRegistry } from "../../extensions/registries"; import { TabLayoutRoute, TabLayout } from "./layout/tab-layout"; -import { Trans } from "@lingui/macro"; +import { StatefulSetScaleDialog } from "./+workloads-statefulsets/statefulset-scale-dialog"; +import { eventStore } from "./+events/event.store"; +import { reaction, computed } from "mobx"; +import { nodesStore } from "./+nodes/nodes.store"; +import { podsStore } from "./+workloads-pods/pods.store"; +import { sum } from "lodash"; @observer export class App extends React.Component { @@ -68,6 +73,39 @@ export class App extends React.Component { whatInput.ask(); // Start to monitor user input device } + async componentDidMount() { + const cluster = getHostedCluster(); + const promises: Promise[] = []; + if (isAllowedResource("events") && isAllowedResource("pods")) { + promises.push(eventStore.loadAll()); + promises.push(podsStore.loadAll()); + } + if (isAllowedResource("nodes")) { + promises.push(nodesStore.loadAll()); + } + await Promise.all(promises); + if (eventStore.isLoaded && podsStore.isLoaded) { + eventStore.subscribe(); + podsStore.subscribe(); + } + if (nodesStore.isLoaded) { + nodesStore.subscribe(); + } + + reaction(() => this.warningsCount, (count) => { + broadcastMessage(`cluster-warning-event-count:${cluster.id}`, count); + }); + } + + @computed + get warningsCount() { + let warnings = sum(nodesStore.items + .map(node => node.getWarningConditions().length)); + warnings = warnings + eventStore.getWarnings().length; + + return warnings; + } + get startURL() { if (isAllowedResource(["events", "nodes", "pods"])) { return clusterURL(); @@ -150,6 +188,7 @@ export class App extends React.Component { + diff --git a/src/renderer/components/cluster-icon/cluster-icon.tsx b/src/renderer/components/cluster-icon/cluster-icon.tsx index d3e7f05eee..8ee7c79aaf 100644 --- a/src/renderer/components/cluster-icon/cluster-icon.tsx +++ b/src/renderer/components/cluster-icon/cluster-icon.tsx @@ -1,13 +1,17 @@ import "./cluster-icon.scss"; import React, { DOMAttributes } from "react"; -import { observer } from "mobx-react"; +import { disposeOnUnmount, observer } from "mobx-react"; import { Params as HashiconParams } from "@emeraldpay/hashicon"; import { Hashicon } from "@emeraldpay/hashicon-react"; import { Cluster } from "../../../main/cluster"; import { cssNames, IClassName } from "../../utils"; import { Badge } from "../badge"; import { Tooltip } from "../tooltip"; +import { eventStore } from "../+events/event.store"; +import { forCluster } from "../../api/kube-api"; +import { subscribeToBroadcast, unsubscribeAllFromBroadcast } from "../../../common/ipc"; +import { observable, when } from "mobx"; interface Props extends DOMAttributes { cluster: Cluster; @@ -29,12 +33,29 @@ const defaultProps: Partial = { export class ClusterIcon extends React.Component { static defaultProps = defaultProps as object; + @observable eventCount = 0; + + get eventCountBroadcast() { + return `cluster-warning-event-count:${this.props.cluster.id}`; + } + + componentDidMount() { + const subscriber = subscribeToBroadcast(this.eventCountBroadcast, (ev, eventCount) => { + this.eventCount = eventCount; + }); + + disposeOnUnmount(this, [ + subscriber + ]); + } + render() { const { cluster, showErrors, showTooltip, errorClass, options, interactive, isActive, children, ...elemProps } = this.props; - const { isAdmin, name, eventCount, preferences, id: clusterId } = cluster; + const { name, preferences, id: clusterId } = cluster; + const eventCount = this.eventCount; const { icon } = preferences; const clusterIconId = `cluster-icon-${clusterId}`; const className = cssNames("ClusterIcon flex inline", this.props.className, { @@ -48,7 +69,7 @@ export class ClusterIcon extends React.Component { )} {icon && {name}/} {!icon && } - {showErrors && isAdmin && eventCount > 0 && ( + {showErrors && eventCount > 0 && !isActive && ( = 1000 ? Math.ceil(eventCount / 1000) + "k+" : eventCount} diff --git a/src/renderer/components/dock/pod-log-search.tsx b/src/renderer/components/dock/pod-log-search.tsx index 296022a40f..dbbcf4901a 100644 --- a/src/renderer/components/dock/pod-log-search.tsx +++ b/src/renderer/components/dock/pod-log-search.tsx @@ -60,7 +60,7 @@ export const PodLogSearch = observer((props: PodLogSearchProps) => { 0 && findCounts} onClear={onClear} onKeyDown={onKeyDown} diff --git a/src/renderer/components/input/drop-file-input.tsx b/src/renderer/components/input/drop-file-input.tsx index 70dd8ddf9c..a99e61ef2b 100644 --- a/src/renderer/components/input/drop-file-input.tsx +++ b/src/renderer/components/input/drop-file-input.tsx @@ -61,7 +61,7 @@ export class DropFileInput extends React.Component< const isValidContentElem = React.isValidElement(contentElem); if (isValidContentElem) { const contentElemProps: React.HTMLProps = { - className: cssNames("DropFileInput", className, { + className: cssNames("DropFileInput", contentElem.props.className, className, { droppable: this.dropAreaActive, }), onDragEnter, diff --git a/src/renderer/components/input/input.scss b/src/renderer/components/input/input.scss index b4c3e21703..48d0d3f353 100644 --- a/src/renderer/components/input/input.scss +++ b/src/renderer/components/input/input.scss @@ -89,8 +89,10 @@ &.theme { &.round-black { - &.invalid label { - border-color: $colorSoftError !important; + &.invalid.dirty { + label { + border-color: $colorSoftError; + } } label { diff --git a/src/renderer/components/input/input.tsx b/src/renderer/components/input/input.tsx index cddfcb92eb..1fa460f18a 100644 --- a/src/renderer/components/input/input.tsx +++ b/src/renderer/components/input/input.tsx @@ -3,13 +3,13 @@ import "./input.scss"; import React, { DOMAttributes, InputHTMLAttributes, TextareaHTMLAttributes } from "react"; import { autobind, cssNames, debouncePromise, getRandId } from "../../utils"; import { Icon } from "../icon"; +import { Tooltip, TooltipProps } from "../tooltip"; import * as Validators from "./input_validators"; import { InputValidator } from "./input_validators"; import isString from "lodash/isString"; import isFunction from "lodash/isFunction"; import isBoolean from "lodash/isBoolean"; import uniqueId from "lodash/uniqueId"; -import { Tooltip } from "../tooltip"; const { conditionalValidators, ...InputValidators } = Validators; export { InputValidators, InputValidator }; @@ -26,7 +26,7 @@ export type InputProps = Omit; // show validation errors as a tooltip :hover (instead of block below) iconLeft?: string | React.ReactNode; // material-icon name in case of string-type iconRight?: string | React.ReactNode; contentRight?: string | React.ReactNode; // Any component of string goes after iconRight @@ -63,6 +63,10 @@ export class Input extends React.Component { errors: [], }; + isValid() { + return this.state.valid; + } + setValue(value: string) { if (value !== this.getValue()) { const nativeInputValueSetter = Object.getOwnPropertyDescriptor(this.input.constructor.prototype, "value").set; @@ -268,7 +272,8 @@ export class Input extends React.Component { render() { const { multiLine, showValidationLine, validators, theme, maxRows, children, showErrorsAsTooltip, - maxLength, rows, disabled, autoSelectOnFocus, iconLeft, iconRight, contentRight, + maxLength, rows, disabled, autoSelectOnFocus, iconLeft, iconRight, contentRight, id, + dirty: _dirty, // excluded from passing to input-element ...inputProps } = this.props; const { focused, dirty, valid, validating, errors } = this.state; @@ -294,29 +299,35 @@ export class Input extends React.Component { ref: this.bindRef, spellCheck: "false", }); - const tooltipId = showErrorsAsTooltip ? getRandId({ prefix: "input_tooltip_id" }) : undefined; const showErrors = errors.length > 0 && !valid && dirty; const errorsInfo = (
{errors.map((error, i) =>

{error}

)}
); + 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 = ( + +
+ + {errorsInfo} +
+
+ ); + } return ( -
+
+ {tooltipError}