diff --git a/.azure-pipelines-k8s-matrix.yml b/.azure-pipelines-k8s-matrix.yml new file mode 100644 index 0000000000..77129460a3 --- /dev/null +++ b/.azure-pipelines-k8s-matrix.yml @@ -0,0 +1,57 @@ +variables: + YARN_CACHE_FOLDER: $(Pipeline.Workspace)/.yarn + node_version: 12.x +pr: + branches: + include: + - master + - releases/* + paths: + exclude: + - .github/* + - docs/* + - mkdocs/* +trigger: none +jobs: + - job: Linux + pool: + vmImage: ubuntu-16.04 + strategy: + matrix: + kube_1.16: + kubernetes_version: v1.16.15 + kube_1.17: + kubernetes_version: v1.17.15 + kube_1.18: + kubernetes_version: v1.18.13 + kube_1.19: + kubernetes_version: v1.19.5 + kube_1.20: + kubernetes_version: v1.20.0 + steps: + - task: NodeTool@0 + inputs: + versionSpec: $(node_version) + displayName: Install Node.js + - task: Cache@2 + inputs: + key: 'yarn | "$(Agent.OS)" | yarn.lock' + restoreKeys: | + yarn | "$(Agent.OS)" + path: $(YARN_CACHE_FOLDER) + displayName: Cache Yarn packages + - bash: | + sudo apt-get update + sudo apt-get install libgconf-2-4 conntrack -y + curl -LO https://storage.googleapis.com/minikube/releases/latest/minikube-linux-amd64 + sudo install minikube-linux-amd64 /usr/local/bin/minikube + sudo minikube start --driver=none --kubernetes-version $(kubernetes_version) + # Although the kube and minikube config files are in placed $HOME they are owned by root + sudo chown -R $USER $HOME/.kube $HOME/.minikube + displayName: Install integration test dependencies + - script: make node_modules + displayName: Install dependencies + - script: make -j2 build + displayName: Run build + - script: xvfb-run --auto-servernum --server-args='-screen 0, 1600x900x24' yarn integration + displayName: Run integration tests for Kubernetes $(kubernetes_version) diff --git a/.azure-pipelines.yml b/.azure-pipelines.yml index 8db2859598..24bf47c221 100644 --- a/.azure-pipelines.yml +++ b/.azure-pipelines.yml @@ -1,8 +1,15 @@ variables: YARN_CACHE_FOLDER: $(Pipeline.Workspace)/.yarn pr: - - master - - releases/* + branches: + include: + - master + - releases/* + paths: + exclude: + - .github/* + - docs/* + - mkdocs/* trigger: branches: include: @@ -10,6 +17,11 @@ trigger: tags: include: - "*" + paths: + exclude: + - .github/* + - docs/* + - mkdocs/* jobs: - job: Windows pool: diff --git a/Makefile b/Makefile index 1f8d4f5392..40eff8445f 100644 --- a/Makefile +++ b/Makefile @@ -13,7 +13,7 @@ binaries/client: yarn download-bins node_modules: - yarn install --frozen-lockfile --verbose + yarn install --frozen-lockfile yarn check --verify-tree --integrity static/build/LensDev.html: diff --git a/docs/extensions/guides/README.md b/docs/extensions/guides/README.md index 0e15268611..8db209dc82 100644 --- a/docs/extensions/guides/README.md +++ b/docs/extensions/guides/README.md @@ -1,14 +1,14 @@ # Extension Guides -The basics of the Lens Extension API are covered in [Your First Extension](../get-started/your-first-extension.md). In this section detailed code guides and samples are used to explain how to use specific Lens Extension APIs. +This section explains how to use specific Lens Extension APIs. It includes detailed guides and code samples. For introductory information about the Lens Extension API, please see [Your First Extension](../get-started/your-first-extension.md). -Each guide or sample will include: +Each guide or code sample includes the following: - Clearly commented source code. - Instructions for running the sample extension. -- Image of the sample extension's appearance and usage. -- Listing of Extension API being used. -- Explanation of Extension API concepts. +- An image showing the sample extension's appearance and usage. +- A listing of the Extension API being used. +- An explanation of the concepts relevant to the Extension. ## Guides diff --git a/docs/extensions/guides/generator.md b/docs/extensions/guides/generator.md index 68f0ad2e7c..46524e97d5 100644 --- a/docs/extensions/guides/generator.md +++ b/docs/extensions/guides/generator.md @@ -1,15 +1,20 @@ -# New Extension Project with Generator +# Lens Extension Generator -The [Lens Extension Generator](https://github.com/lensapp/generator-lens-ext) scaffolds a project ready for development. Install Yeoman and Lens Extension Generator with: +The [Lens Extension Generator](https://github.com/lensapp/generator-lens-ext) creates a directory with the necessary files for developing an extension. + +## Installing and Getting Started with the Generator + +To begin, install Yeoman and the Lens Extension Generator with the following command: ```bash npm install -g yo generator-lens-ext ``` -Run the generator and fill out a few fields for a TypeScript project: +Run the generator by entering the following command: `yo lens-ext`. + +Answer the following questions: ```bash -yo lens-ext # ? What type of extension do you want to create? New Extension (TypeScript) # ? What's the name of your extension? my-first-lens-ext # ? What's the description of your extension? My hello world extension @@ -17,24 +22,25 @@ yo lens-ext # ? Initialize a git repository? Yes # ? Install dependencies after initialization? Yes # ? Which package manager to use? yarn -# ? symlink created extension folder to ~/.k8slens/extensions (mac/linux) or :User -s\\.k8slens\extensions (windows)? Yes +# ? symlink created extension folder to ~/.k8slens/extensions (mac/linux) or :Users\\.k8slens\extensions (windows)? Yes ``` -Start webpack, which watches the `my-first-lens-ext` folder. +Next, you'll need to have webpack watch the `my-first-lens-ext` folder. Start webpack by entering: ```bash cd my-first-lens-ext npm start # start the webpack server in watch mode ``` -Then, open Lens, you should see a Hello World item in the menu: +Open Lens and you will see a **Hello World** item in the left-side menu under **Custom Resources**: ![Hello World](images/hello-world.png) ## Developing the Extension -Try to change `my-first-lens-ext/renderer.tsx` to "Hello Lens!": +Next, you'll try changing the way the new menu item appears in the UI. You'll change it from "Hello World" to "Hello Lens". + +Open `my-first-lens-ext/renderer.tsx` and change the value of `title` from `"Hello World"` to `"Hello Lens"`: ```tsx clusterPageMenus = [ @@ -48,18 +54,18 @@ clusterPageMenus = [ ] ``` -Then, Reload Lens by CMD+R (Mac) / Ctrl+R (Linux/Windows), you should see the menu item text changes: +Reload Lens and you will see that the menu item text has changed to "Hello Lens." To reload Lens, enter `CMD+R` on Mac and `Ctrl+R` on Windows/Linux. ![Hello World](images/hello-lens.png) ## Debugging the Extension -[Testing](../testing-and-publishing/testing.md) +To debug your extension, please see our instructions on [Testing Extensions](../testing-and-publishing/testing.md). -## Next steps +## Next Steps -You can take a closer look at [Common Capabilities](../capabilities/common-capabilities.md) of extension, how to [style](../capabilities/styling.md) the extension. Or the [Extension Anatomy](anatomy.md). +To dive deeper, consider looking at [Common Capabilities](../capabilities/common-capabilities.md), [Styling](../capabilities/styling.md), or [Extension Anatomy](anatomy.md). -You are welcome to raise an [issue](https://github.com/lensapp/generator-lens-ext/issues) for Lens Extension Generator, if you find problems, or have feature requests. +If you find problems with the Lens Extension Generator, or have feature requests, you are welcome to raise an [issue](https://github.com/lensapp/generator-lens-ext/issues). You can find the Lens contribution guidelines [here](../../contributing/README.md). -The source code of the generator is hosted at [Github](https://github.com/lensapp/generator-lens-ext) +The Generator source code is hosted at [Github](https://github.com/lensapp/generator-lens-ext). diff --git a/docs/extensions/guides/main-extension.md b/docs/extensions/guides/main-extension.md index e1249da0d4..42bb9eedc3 100644 --- a/docs/extensions/guides/main-extension.md +++ b/docs/extensions/guides/main-extension.md @@ -1,12 +1,14 @@ # Main Extension -The main extension api is the interface to Lens' main process (Lens runs in main and renderer processes). It allows you to access, configure, and customize Lens data, add custom application menu items, and generally run custom code in Lens' main process. +The Main Extension API is the interface to Lens's main process. Lens runs in both main and renderer processes. The Main Extension API allows you to access, configure, and customize Lens data, add custom application menu items, and run custom code in Lens's main process. ## `LensMainExtension` Class +### `onActivate()` and `onDeactivate()` Methods + To create a main extension simply extend the `LensMainExtension` class: -``` typescript +```typescript import { LensMainExtension } from "@k8slens/extensions"; export default class ExampleExtensionMain extends LensMainExtension { @@ -20,11 +22,18 @@ export default class ExampleExtensionMain extends LensMainExtension { } ``` -There are two methods that you can implement to facilitate running your custom code. `onActivate()` is called when your extension has been successfully enabled. By implementing `onActivate()` you can initiate your custom code. `onDeactivate()` is called when the extension is disabled (typically from the [Lens Extensions Page]()) and when implemented gives you a chance to clean up after your extension, if necessary. The example above simply logs messages when the extension is enabled and disabled. Note that to see standard output from the main process there must be a console connected to it. This is typically achieved by starting Lens from the command prompt. +Two methods enable you to run custom code: `onActivate()` and `onDeactivate()`. Enabling your extension calls `onActivate()` and disabling your extension calls `onDeactivate()`. You can initiate custom code by implementing `onActivate()`. Implementing `onDeactivate()` gives you the opportunity to clean up after your extension. -The following example is a little more interesting in that it accesses some Lens state data and periodically logs the name of the currently active cluster in Lens. +Disable extensions from the Lens Extensions page: -``` typescript +1. Navigate to **File** > **Extensions** in the top menu bar. (On Mac, it is **Lens** > **Extensions**.) +2. Click **Disable** on the extension you want to disable. + +The example above logs messages when the extension is enabled and disabled. To see standard output from the main process there must be a console connected to it. Achieve this by starting Lens from the command prompt. + +The following example is a little more interesting. It accesses some Lens state data, and it periodically logs the name of the cluster that is currently active in Lens. + +```typescript import { LensMainExtension, Store } from "@k8slens/extensions"; const clusterStore = Store.clusterStore @@ -51,11 +60,11 @@ export default class ActiveClusterExtensionMain extends LensMainExtension { } ``` -See the [Stores](../stores) guide for more details on accessing Lens state data. +For more details on accessing Lens state data, please see the [Stores](../stores) guide. ### `appMenus` -The only UI feature customizable in the main extension api is the application menu. Custom menu items can be inserted and linked to custom functionality, such as navigating to a specific page. The following example demonstrates adding a menu item to the Help menu. +The Main Extension API allows you to customize the UI application menu. Note that this is the only UI feature that the Main Extension API allows you to customize. The following example demonstrates adding an item to the **Help** menu. ``` typescript import { LensMainExtension } from "@k8slens/extensions"; @@ -73,4 +82,8 @@ export default class SamplePageMainExtension extends LensMainExtension { } ``` -`appMenus` is an array of objects satisfying the `MenuRegistration` interface. `MenuRegistration` extends React's `MenuItemConstructorOptions` interface. `parentId` is the id of the menu to put this menu item under (todo: is this case sensitive and how do we know what the available ids are?), `label` is the text to show on the menu item, and `click()` is called when the menu item is selected. In this example we simply log a message, but typically you would navigate to a specific page or perform some operation. Pages are associated with the [`LensRendererExtension`](renderer-extension.md) class and can be defined when you extend it. \ No newline at end of file +`appMenus` is an array of objects that satisfy the `MenuRegistration` interface. `MenuRegistration` extends React's `MenuItemConstructorOptions` interface. The properties of the appMenus array objects are defined as follows: + +* `parentId` is the name of the menu where your new menu item will be listed. Valid values include: `"file"`, `"edit"`, `"view"`, and `"help"`. `"lens"` is valid on Mac only. +* `label` is the name of your menu item. +* `click()` is called when the menu item is selected. In this example, we simply log a message. However, you would typically have this navigate to a specific page or perform another operation. Note that pages are associated with the [`LensRendererExtension`](renderer-extension.md) class and can be defined in the process of extending it. diff --git a/extensions/metrics-cluster-feature/package-lock.json b/extensions/metrics-cluster-feature/package-lock.json index e2df4f9b6d..01a2215984 100644 --- a/extensions/metrics-cluster-feature/package-lock.json +++ b/extensions/metrics-cluster-feature/package-lock.json @@ -5362,7 +5362,8 @@ "semver": { "version": "7.3.2", "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.2.tgz", - "integrity": "sha512-OrOb32TeeambH6UrhtShmF7CRDqhL6/5XpPNp2DuRH6+9QLw/orhp72j87v8Qa1ScDkvrrBNpZcDejAirJmfXQ==" + "integrity": "sha512-OrOb32TeeambH6UrhtShmF7CRDqhL6/5XpPNp2DuRH6+9QLw/orhp72j87v8Qa1ScDkvrrBNpZcDejAirJmfXQ==", + "dev": true }, "serialize-javascript": { "version": "4.0.0", diff --git a/extensions/metrics-cluster-feature/package.json b/extensions/metrics-cluster-feature/package.json index 3b098c6bd9..1b1b5850ec 100644 --- a/extensions/metrics-cluster-feature/package.json +++ b/extensions/metrics-cluster-feature/package.json @@ -12,14 +12,13 @@ "dev": "npm run build --watch", "test": "jest --passWithNoTests --env=jsdom src $@" }, - "dependencies": { - "semver": "^7.3.2" - }, + "dependencies": {}, "devDependencies": { "@k8slens/extensions": "file:../../src/extensions/npm/extensions", "jest": "^26.6.3", "mobx": "^5.15.5", "react": "^16.13.1", + "semver": "^7.3.2", "ts-loader": "^8.0.4", "typescript": "^4.0.3", "webpack": "^4.44.2" diff --git a/extensions/telemetry/src/tracker.ts b/extensions/telemetry/src/tracker.ts index 398142305c..28595a6a93 100644 --- a/extensions/telemetry/src/tracker.ts +++ b/extensions/telemetry/src/tracker.ts @@ -74,6 +74,7 @@ export class Tracker extends Util.Singleton { } reportPeriodically() { + this.reportData(); this.reportInterval = setInterval(() => { this.reportData(); }, 60 * 60 * 1000); // report every 1h diff --git a/integration/__tests__/app.tests.ts b/integration/__tests__/app.tests.ts index 285af70ceb..410712e4f0 100644 --- a/integration/__tests__/app.tests.ts +++ b/integration/__tests__/app.tests.ts @@ -226,7 +226,7 @@ describe("Lens integration tests", () => { pages: [{ name: "Cluster", href: "cluster", - expectedSelector: "div.Cluster div.label", + expectedSelector: "div.ClusterOverview div.label", expectedText: "Master" }] }, diff --git a/locales/en/messages.po b/locales/en/messages.po index ec9ac00f25..e120570ac4 100644 --- a/locales/en/messages.po +++ b/locales/en/messages.po @@ -2664,6 +2664,10 @@ msgstr "Status" #~ msgid "Status URI" #~ msgstr "Status URI" +#: src/renderer/components/+workloads-pods/pod-details-container.tsx:140 +msgid "Startup" +msgstr "Startup" + #: src/renderer/components/+storage-volume-claims/volume-claim-details.tsx:56 #: src/renderer/components/layout/sidebar.tsx:85 msgid "Storage" diff --git a/locales/fi/messages.po b/locales/fi/messages.po index 02995c74db..2a8a650953 100644 --- a/locales/fi/messages.po +++ b/locales/fi/messages.po @@ -2646,6 +2646,10 @@ msgstr "" #~ msgid "Status URI" #~ msgstr "" +#: src/renderer/components/+workloads-pods/pod-details-container.tsx:140 +msgid "Startup" +msgstr "" + #: src/renderer/components/+storage-volume-claims/volume-claim-details.tsx:56 #: src/renderer/components/layout/sidebar.tsx:85 msgid "Storage" diff --git a/locales/ru/messages.po b/locales/ru/messages.po index 0f5fb2f9e2..6975d03529 100644 --- a/locales/ru/messages.po +++ b/locales/ru/messages.po @@ -2664,6 +2664,10 @@ msgstr "Статус" #~ msgid "Status URI" #~ msgstr "Адрес статуса" +#: src/renderer/components/+workloads-pods/pod-details-container.tsx:140 +msgid "Startup" +msgstr "Cтарт" + #: src/renderer/components/+storage-volume-claims/volume-claim-details.tsx:56 #: src/renderer/components/layout/sidebar.tsx:85 msgid "Storage" diff --git a/package.json b/package.json index bade249608..3e5aca0c42 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "kontena-lens", "productName": "Lens", "description": "Lens - The Kubernetes IDE", - "version": "4.0.0-rc.3", + "version": "4.1.0-alpha.0", "main": "static/build/main.js", "copyright": "© 2020, Mirantis, Inc.", "license": "MIT", @@ -44,7 +44,7 @@ "typedocs-extensions-api": "yarn run typedoc --ignoreCompilerErrors --readme docs/extensions/typedoc-readme.md.tpl --name @k8slens/extensions --out docs/extensions/api --mode library --excludePrivate --hideBreadcrumbs --includes src/ src/extensions/extension-api.ts" }, "config": { - "bundledKubectlVersion": "1.17.11", + "bundledKubectlVersion": "1.17.15", "bundledHelmVersion": "3.3.4" }, "engines": { diff --git a/src/extensions/extension-discovery.ts b/src/extensions/extension-discovery.ts index 2b26d79a9c..7d0da112bb 100644 --- a/src/extensions/extension-discovery.ts +++ b/src/extensions/extension-discovery.ts @@ -266,18 +266,30 @@ export class ExtensionDiscovery { logger.info(`${logModule} loading extensions from ${extensionInstaller.extensionPackagesRoot}`); - if (fs.existsSync(path.join(extensionInstaller.extensionPackagesRoot, "package-lock.json"))) { - await fs.remove(path.join(extensionInstaller.extensionPackagesRoot, "package-lock.json")); - } + // fs.remove won't throw if path is missing + await fs.remove(path.join(extensionInstaller.extensionPackagesRoot, "package-lock.json")); + try { + // Verify write access to static/extensions, which is needed for symlinking await fs.access(this.inTreeFolderPath, fs.constants.W_OK); + + // Set bundled folder path to static/extensions this.bundledFolderPath = this.inTreeFolderPath; } catch { - // we need to copy in-tree extensions so that we can symlink them properly on "npm install" + // If there is error accessing static/extensions, we need to copy in-tree extensions so that we can symlink them properly on "npm install". + // The error can happen if there is read-only rights to static/extensions, which would fail symlinking. + + // Remove e.g. /Users//Library/Application Support/LensDev/extensions await fs.remove(this.inTreeTargetPath); + + // Create folder e.g. /Users//Library/Application Support/LensDev/extensions await fs.ensureDir(this.inTreeTargetPath); + + // Copy static/extensions to e.g. /Users//Library/Application Support/LensDev/extensions await fs.copy(this.inTreeFolderPath, this.inTreeTargetPath); + + // Set bundled folder path to e.g. /Users//Library/Application Support/LensDev/extensions this.bundledFolderPath = this.inTreeTargetPath; } @@ -287,7 +299,7 @@ export class ExtensionDiscovery { const extensions = await this.loadExtensions(); this.isLoaded = true; - + return extensions; } @@ -343,6 +355,8 @@ export class ExtensionDiscovery { async loadExtensions(): Promise> { const bundledExtensions = await this.loadBundledExtensions(); + + await this.installPackages(); // install in-tree as a separate step const localExtensions = await this.loadFromFolder(this.localFolderPath); await this.installPackages(); diff --git a/src/extensions/extension-installer.ts b/src/extensions/extension-installer.ts index 2143c62287..75b30d0b9a 100644 --- a/src/extensions/extension-installer.ts +++ b/src/extensions/extension-installer.ts @@ -33,16 +33,26 @@ export class ExtensionInstaller { installDependencies(): Promise { return new Promise((resolve, reject) => { logger.info(`${logModule} installing dependencies at ${extensionPackagesRoot()}`); - const child = child_process.fork(this.npmPath, ["install", "--silent", "--no-audit", "--only=prod", "--prefer-offline", "--no-package-lock"], { + const child = child_process.fork(this.npmPath, ["install", "--no-audit", "--only=prod", "--prefer-offline", "--no-package-lock"], { cwd: extensionPackagesRoot(), silent: true }); + let stderr = ""; - child.on("close", () => { - resolve(); + child.stderr.on("data", data => { + stderr += String(data); }); - child.on("error", (err) => { - reject(err); + + child.on("close", (code) => { + if (code !== 0) { + reject(new Error(stderr)); + } else { + resolve(); + } + }); + + child.on("error", error => { + reject(error); }); }); } diff --git a/src/extensions/renderer-api/components.ts b/src/extensions/renderer-api/components.ts index 242799c749..358dc6c214 100644 --- a/src/extensions/renderer-api/components.ts +++ b/src/extensions/renderer-api/components.ts @@ -27,6 +27,7 @@ export * from "../../renderer/components/menu"; export * from "../../renderer/components/notifications"; export * from "../../renderer/components/spinner"; export * from "../../renderer/components/stepper"; +export * from "../../renderer/components/+workloads-pods/pod-details-list"; // kube helpers export * from "../../renderer/components/kube-object"; diff --git a/src/main/cluster-detectors/distribution-detector.ts b/src/main/cluster-detectors/distribution-detector.ts index be0cadb1bd..041a8b9158 100644 --- a/src/main/cluster-detectors/distribution-detector.ts +++ b/src/main/cluster-detectors/distribution-detector.ts @@ -56,12 +56,12 @@ export class DistributionDetector extends BaseClusterDetector { return { value: "docker-desktop", accuracy: 80}; } - if (this.isCustom()) { - return { value: "custom", accuracy: 10}; + if (this.isCustom() && await this.isOpenshift()) { + return { value: "openshift", accuracy: 90}; } - if (await this.isOpenshift()) { - return { value: "openshift", accuracy: 90}; + if (this.isCustom()) { + return { value: "custom", accuracy: 10}; } return { value: "unknown", accuracy: 10}; @@ -88,7 +88,7 @@ export class DistributionDetector extends BaseClusterDetector { } protected isAKS() { - return this.cluster.apiUrl.endsWith("azmk8s.io"); + return this.cluster.apiUrl.includes("azmk8s.io"); } protected isMirantis() { diff --git a/src/main/extension-filesystem.ts b/src/main/extension-filesystem.ts index c4ee622e1d..eddb7b747f 100644 --- a/src/main/extension-filesystem.ts +++ b/src/main/extension-filesystem.ts @@ -1,6 +1,6 @@ import { randomBytes } from "crypto"; import { SHA256 } from "crypto-js"; -import { app } from "electron"; +import { app, remote } from "electron"; import fse from "fs-extra"; import { action, observable, toJS } from "mobx"; import path from "path"; @@ -31,7 +31,7 @@ export class FilesystemProvisionerStore extends BaseStore { if (!this.registeredExtensions.has(extensionName)) { const salt = randomBytes(32).toString("hex"); const hashedName = SHA256(`${extensionName}/${salt}`).toString(); - const dirPath = path.resolve(app.getPath("userData"), "extension_data", hashedName); + const dirPath = path.resolve((app || remote.app).getPath("userData"), "extension_data", hashedName); this.registeredExtensions.set(extensionName, dirPath); } diff --git a/src/main/index.ts b/src/main/index.ts index ea3c53f9b9..8da9be1a01 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -4,7 +4,7 @@ import "../common/system-ca"; import "../common/prometheus-providers"; import * as Mobx from "mobx"; import * as LensExtensions from "../extensions/core-api"; -import { app, dialog } from "electron"; +import { app, dialog, powerMonitor } from "electron"; import { appName } from "../common/vars"; import path from "path"; import { LensProxy } from "./lens-proxy"; @@ -45,10 +45,24 @@ if (app.commandLine.getSwitchValue("proxy-server") !== "") { process.env.HTTPS_PROXY = app.commandLine.getSwitchValue("proxy-server"); } +const instanceLock = app.requestSingleInstanceLock(); + +if (!instanceLock) { + app.exit(); +} + +app.on("second-instance", () => { + windowManager?.ensureMainWindow(); +}); + app.on("ready", async () => { logger.info(`🚀 Starting Lens from "${workingDir}"`); await shellSync(); + powerMonitor.on("shutdown", () => { + app.exit(); + }); + const updater = new AppUpdater(); updater.start(); @@ -83,8 +97,8 @@ app.on("ready", async () => { // eslint-disable-next-line unused-imports/no-unused-vars-ts proxyServer = LensProxy.create(proxyPort, clusterManager); } catch (error) { - logger.error(`Could not start proxy (127.0.0:${proxyPort}): ${error.message}`); - dialog.showErrorBox("Lens Error", `Could not start proxy (127.0.0:${proxyPort}): ${error.message || "unknown error"}`); + logger.error(`Could not start proxy (127.0.0:${proxyPort}): ${error?.message}`); + dialog.showErrorBox("Lens Error", `Could not start proxy (127.0.0:${proxyPort}): ${error?.message || "unknown error"}`); app.exit(); } @@ -94,17 +108,21 @@ app.on("ready", async () => { windowManager = WindowManager.getInstance(proxyPort); // call after windowManager to see splash earlier - const extensions = await extensionDiscovery.load(); + try { + const extensions = await extensionDiscovery.load(); - // Subscribe to extensions that are copied or deleted to/from the extensions folder - extensionDiscovery.events.on("add", (extension: InstalledExtension) => { - extensionLoader.addExtension(extension); - }); - extensionDiscovery.events.on("remove", (lensExtensionId: LensExtensionId) => { - extensionLoader.removeExtension(lensExtensionId); - }); + // Subscribe to extensions that are copied or deleted to/from the extensions folder + extensionDiscovery.events.on("add", (extension: InstalledExtension) => { + extensionLoader.addExtension(extension); + }); + extensionDiscovery.events.on("remove", (lensExtensionId: LensExtensionId) => { + extensionLoader.removeExtension(lensExtensionId); + }); - extensionLoader.initExtensions(extensions); + extensionLoader.initExtensions(extensions); + } catch (error) { + dialog.showErrorBox("Lens Error", `Could not load extensions${error?.message ? `: ${error.message}` : ""}`); + } setTimeout(() => { appEventBus.emit({ name: "service", action: "start" }); diff --git a/src/renderer/api/api-manager.ts b/src/renderer/api/api-manager.ts index 030467f653..db06077f0b 100644 --- a/src/renderer/api/api-manager.ts +++ b/src/renderer/api/api-manager.ts @@ -17,6 +17,10 @@ export class ApiManager { return Array.from(this.apis.values()).find(pathOrCallback ?? (() => true)); } + getApiByKind(kind: string, apiVersion: string) { + return Array.from(this.apis.values()).find((api) => api.kind === kind && api.apiVersion === apiVersion); + } + registerApi(apiBase: string, api: KubeApi) { if (!this.apis.has(apiBase)) { this.apis.set(apiBase, api); diff --git a/src/renderer/api/endpoints/deployment.api.ts b/src/renderer/api/endpoints/deployment.api.ts index d876616470..107e970d98 100644 --- a/src/renderer/api/endpoints/deployment.api.ts +++ b/src/renderer/api/endpoints/deployment.api.ts @@ -46,6 +46,26 @@ export class DeploymentApi extends KubeApi { } } +interface IContainerProbe { + httpGet?: { + path?: string; + port: number; + scheme: string; + host?: string; + }; + exec?: { + command: string[]; + }; + tcpSocket?: { + port: number; + }; + initialDelaySeconds?: number; + timeoutSeconds?: number; + periodSeconds?: number; + successThreshold?: number; + failureThreshold?: number; +} + @autobind() export class Deployment extends WorkloadKubeObject { static kind = "Deployment"; @@ -89,30 +109,9 @@ export class Deployment extends WorkloadKubeObject { name: string; mountPath: string; }[]; - livenessProbe?: { - httpGet: { - path: string; - port: number; - scheme: string; - }; - initialDelaySeconds: number; - timeoutSeconds: number; - periodSeconds: number; - successThreshold: number; - failureThreshold: number; - }; - readinessProbe?: { - httpGet: { - path: string; - port: number; - scheme: string; - }; - initialDelaySeconds: number; - timeoutSeconds: number; - periodSeconds: number; - successThreshold: number; - failureThreshold: number; - }; + livenessProbe?: IContainerProbe; + readinessProbe?: IContainerProbe; + startupProbe?: IContainerProbe; terminationMessagePath: string; terminationMessagePolicy: string; imagePullPolicy: string; diff --git a/src/renderer/api/endpoints/pods.api.ts b/src/renderer/api/endpoints/pods.api.ts index 447503558b..eca15251a7 100644 --- a/src/renderer/api/endpoints/pods.api.ts +++ b/src/renderer/api/endpoints/pods.api.ts @@ -112,6 +112,7 @@ export interface IPodContainer { }[]; livenessProbe?: IContainerProbe; readinessProbe?: IContainerProbe; + startupProbe?: IContainerProbe; imagePullPolicy: string; } @@ -429,6 +430,10 @@ export class Pod extends WorkloadKubeObject { return this.getProbe(container.readinessProbe); } + getStartupProbe(container: IPodContainer) { + return this.getProbe(container.startupProbe); + } + getProbe(probeData: IContainerProbe) { if (!probeData) return []; const { diff --git a/src/renderer/api/kube-api.ts b/src/renderer/api/kube-api.ts index e7934675c6..8a3a2517c2 100644 --- a/src/renderer/api/kube-api.ts +++ b/src/renderer/api/kube-api.ts @@ -79,6 +79,18 @@ export function forCluster(cluster: IKubeApiCluster, kubeC }); } +export function ensureObjectSelfLink(api: KubeApi, object: KubeJsonApiData) { + if (!object.metadata.selfLink) { + object.metadata.selfLink = createKubeApiURL({ + apiPrefix: api.apiPrefix, + apiVersion: api.apiVersionWithGroup, + resource: api.apiResource, + namespace: api.isNamespaced ? object.metadata.namespace : undefined, + name: object.metadata.name, + }); + } +} + export class KubeApi { static parseApi = parseKubeApi; @@ -260,7 +272,11 @@ export class KubeApi { const KubeObjectConstructor = this.objectConstructor; if (KubeObject.isJsonApiData(data)) { - return new KubeObjectConstructor(data); + const object = new KubeObjectConstructor(data); + + ensureObjectSelfLink(this, object); + + return object; } // process items list response @@ -270,11 +286,17 @@ export class KubeApi { this.setResourceVersion(namespace, metadata.resourceVersion); this.setResourceVersion("", metadata.resourceVersion); - return items.map(item => new KubeObjectConstructor({ - kind: this.kind, - apiVersion, - ...item, - })); + return items.map((item) => { + const object = new KubeObjectConstructor({ + kind: this.kind, + apiVersion, + ...item, + }); + + ensureObjectSelfLink(this, object); + + return object; + }); } // custom apis might return array for list response, e.g. users, groups, etc. diff --git a/src/renderer/api/kube-watch-api.ts b/src/renderer/api/kube-watch-api.ts index 58665a11a1..78ca25256e 100644 --- a/src/renderer/api/kube-watch-api.ts +++ b/src/renderer/api/kube-watch-api.ts @@ -5,7 +5,7 @@ import { stringify } from "querystring"; import { autobind, EventEmitter } from "../utils"; import { KubeJsonApiData } from "./kube-json-api"; import type { KubeObjectStore } from "../kube-object.store"; -import { KubeApi } from "./kube-api"; +import { ensureObjectSelfLink, KubeApi } from "./kube-api"; import { apiManager } from "./api-manager"; import { apiPrefix, isDevelopment } from "../../common/vars"; import { getHostedCluster } from "../../common/cluster-store"; @@ -158,12 +158,14 @@ export class KubeWatchApi { addListener(store: KubeObjectStore, callback: (evt: IKubeWatchEvent) => void) { const listener = (evt: IKubeWatchEvent) => { - const { selfLink, namespace, resourceVersion } = evt.object.metadata; - const api = apiManager.getApi(selfLink); + const { namespace, resourceVersion } = evt.object.metadata; + const api = apiManager.getApiByKind(evt.object.kind, evt.object.apiVersion); api.setResourceVersion(namespace, resourceVersion); api.setResourceVersion("", resourceVersion); + ensureObjectSelfLink(api, evt.object); + if (store == apiManager.getStore(api)) { callback(evt); } diff --git a/src/renderer/components/+cluster/cluster-issues.scss b/src/renderer/components/+cluster/cluster-issues.scss index b552cf6877..8886fa40e3 100644 --- a/src/renderer/components/+cluster/cluster-issues.scss +++ b/src/renderer/components/+cluster/cluster-issues.scss @@ -1,17 +1,14 @@ .ClusterIssues { min-height: 350px; position: relative; + grid-column-start: 1; + grid-column-end: 3; @include media("<1024px") { grid-column-start: 1!important; grid-column-end: 1!important; } - &.wide { - grid-column-start: 1; - grid-column-end: 3; - } - .SubHeader { .Icon { font-size: 130%; diff --git a/src/renderer/components/+cluster/cluster-metric-switchers.tsx b/src/renderer/components/+cluster/cluster-metric-switchers.tsx index f2e090cdbb..02ffbd8755 100644 --- a/src/renderer/components/+cluster/cluster-metric-switchers.tsx +++ b/src/renderer/components/+cluster/cluster-metric-switchers.tsx @@ -6,10 +6,10 @@ import { observer } from "mobx-react"; import { nodesStore } from "../+nodes/nodes.store"; import { cssNames } from "../../utils"; import { Radio, RadioGroup } from "../radio"; -import { clusterStore, MetricNodeRole, MetricType } from "./cluster.store"; +import { clusterOverviewStore, MetricNodeRole, MetricType } from "./cluster-overview.store"; export const ClusterMetricSwitchers = observer(() => { - const { metricType, metricNodeRole, getMetricsValues, metrics } = clusterStore; + const { metricType, metricNodeRole, getMetricsValues, metrics } = clusterOverviewStore; const { masterNodes, workerNodes } = nodesStore; const metricsValues = getMetricsValues(metrics); const disableRoles = !masterNodes.length || !workerNodes.length; @@ -22,7 +22,7 @@ export const ClusterMetricSwitchers = observer(() => { asButtons className={cssNames("RadioGroup flex gaps", { disabled: disableRoles })} value={metricNodeRole} - onChange={(metric: MetricNodeRole) => clusterStore.metricNodeRole = metric} + onChange={(metric: MetricNodeRole) => clusterOverviewStore.metricNodeRole = metric} > Master} value={MetricNodeRole.MASTER}/> Worker} value={MetricNodeRole.WORKER}/> @@ -33,7 +33,7 @@ export const ClusterMetricSwitchers = observer(() => { asButtons className={cssNames("RadioGroup flex gaps", { disabled: disableMetrics })} value={metricType} - onChange={(value: MetricType) => clusterStore.metricType = value} + onChange={(value: MetricType) => clusterOverviewStore.metricType = value} > CPU} value={MetricType.CPU}/> Memory} value={MetricType.MEMORY}/> diff --git a/src/renderer/components/+cluster/cluster-metrics.tsx b/src/renderer/components/+cluster/cluster-metrics.tsx index b049cfc2f4..6461bae7f3 100644 --- a/src/renderer/components/+cluster/cluster-metrics.tsx +++ b/src/renderer/components/+cluster/cluster-metrics.tsx @@ -3,7 +3,7 @@ import "./cluster-metrics.scss"; import React from "react"; import { observer } from "mobx-react"; import { ChartOptions, ChartPoint } from "chart.js"; -import { clusterStore, MetricType } from "./cluster.store"; +import { clusterOverviewStore, MetricType } from "./cluster-overview.store"; import { BarChart } from "../chart"; import { bytesToUnits } from "../../utils"; import { Spinner } from "../spinner"; @@ -13,10 +13,9 @@ import { ClusterMetricSwitchers } from "./cluster-metric-switchers"; import { getMetricLastPoints } from "../../api/endpoints/metrics.api"; export const ClusterMetrics = observer(() => { - const { metricType, metricNodeRole, getMetricsValues, metricsLoaded, metrics, liveMetrics } = clusterStore; - const { memoryCapacity, cpuCapacity } = getMetricLastPoints(clusterStore.metrics); + const { metricType, metricNodeRole, getMetricsValues, metricsLoaded, metrics } = clusterOverviewStore; + const { memoryCapacity, cpuCapacity } = getMetricLastPoints(clusterOverviewStore.metrics); const metricValues = getMetricsValues(metrics); - const liveMetricValues = getMetricsValues(liveMetrics); const colors = { cpu: "#3D90CE", memory: "#C93DCE" }; const data = metricValues.map(value => ({ x: value[0], @@ -70,7 +69,7 @@ export const ClusterMetrics = observer(() => { const options = metricType === MetricType.CPU ? cpuOptions : memoryOptions; const renderMetrics = () => { - if ((!metricValues.length || !liveMetricValues.length) && !metricsLoaded) { + if (!metricValues.length && !metricsLoaded) { return ; } diff --git a/src/renderer/components/+cluster/cluster.scss b/src/renderer/components/+cluster/cluster-overview.scss similarity index 95% rename from src/renderer/components/+cluster/cluster.scss rename to src/renderer/components/+cluster/cluster-overview.scss index 32739c378c..c0534f4fff 100644 --- a/src/renderer/components/+cluster/cluster.scss +++ b/src/renderer/components/+cluster/cluster-overview.scss @@ -1,4 +1,4 @@ -.Cluster { +.ClusterOverview { $gridGap: $margin * 2; position: relative; diff --git a/src/renderer/components/+cluster/cluster.store.ts b/src/renderer/components/+cluster/cluster-overview.store.ts similarity index 76% rename from src/renderer/components/+cluster/cluster.store.ts rename to src/renderer/components/+cluster/cluster-overview.store.ts index 04bc1d8658..64faa2394c 100644 --- a/src/renderer/components/+cluster/cluster.store.ts +++ b/src/renderer/components/+cluster/cluster-overview.store.ts @@ -1,4 +1,4 @@ -import { observable, reaction, when } from "mobx"; +import { action, observable, reaction, when } from "mobx"; import { KubeObjectStore } from "../../kube-object.store"; import { Cluster, clusterApi, IClusterMetrics } from "../../api/endpoints"; import { autobind, createStorage } from "../../utils"; @@ -17,11 +17,10 @@ export enum MetricNodeRole { } @autobind() -export class ClusterStore extends KubeObjectStore { +export class ClusterOverviewStore extends KubeObjectStore { api = clusterApi; @observable metrics: Partial = {}; - @observable liveMetrics: Partial = {}; @observable metricsLoaded = false; @observable metricType: MetricType; @observable metricNodeRole: MetricNodeRole; @@ -46,9 +45,8 @@ export class ClusterStore extends KubeObjectStore { reaction(() => this.metricNodeRole, () => { if (!this.metricsLoaded) return; this.metrics = {}; - this.liveMetrics = {}; this.metricsLoaded = false; - this.getAllMetrics(); + this.loadMetrics(); }); // check which node type to select @@ -60,33 +58,16 @@ export class ClusterStore extends KubeObjectStore { }); } + @action async loadMetrics(params?: IMetricsReqParams) { await when(() => nodesStore.isLoaded); const { masterNodes, workerNodes } = nodesStore; const nodes = this.metricNodeRole === MetricNodeRole.MASTER && masterNodes.length ? masterNodes : workerNodes; - return clusterApi.getMetrics(nodes.map(node => node.getName()), params); - } - - async getAllMetrics() { - await this.getMetrics(); - await this.getLiveMetrics(); + this.metrics = await clusterApi.getMetrics(nodes.map(node => node.getName()), params); this.metricsLoaded = true; } - async getMetrics() { - this.metrics = await this.loadMetrics(); - } - - async getLiveMetrics() { - const step = 3; - const range = 15; - const end = Date.now() / 1000; - const start = end - range; - - this.liveMetrics = await this.loadMetrics({ start, end, step, range }); - } - getMetricsValues(source: Partial): [number, string][] { switch (this.metricType) { case MetricType.CPU: @@ -111,5 +92,5 @@ export class ClusterStore extends KubeObjectStore { } } -export const clusterStore = new ClusterStore(); -apiManager.registerStore(clusterStore); +export const clusterOverviewStore = new ClusterOverviewStore(); +apiManager.registerStore(clusterOverviewStore); diff --git a/src/renderer/components/+cluster/cluster-overview.tsx b/src/renderer/components/+cluster/cluster-overview.tsx new file mode 100644 index 0000000000..104c6fd022 --- /dev/null +++ b/src/renderer/components/+cluster/cluster-overview.tsx @@ -0,0 +1,79 @@ +import "./cluster-overview.scss"; + +import React from "react"; +import { reaction } from "mobx"; +import { disposeOnUnmount, observer } from "mobx-react"; + +import { eventStore } from "../+events/event.store"; +import { nodesStore } from "../+nodes/nodes.store"; +import { podsStore } from "../+workloads-pods/pods.store"; +import { getHostedCluster } from "../../../common/cluster-store"; +import { isAllowedResource } from "../../../common/rbac"; +import { KubeObjectStore } from "../../kube-object.store"; +import { interval } from "../../utils"; +import { TabLayout } from "../layout/tab-layout"; +import { Spinner } from "../spinner"; +import { ClusterIssues } from "./cluster-issues"; +import { ClusterMetrics } from "./cluster-metrics"; +import { clusterOverviewStore } from "./cluster-overview.store"; +import { ClusterPieCharts } from "./cluster-pie-charts"; + +@observer +export class ClusterOverview extends React.Component { + private stores: KubeObjectStore[] = []; + private subscribers: Array<() => void> = []; + private metricPoller = interval(60, this.loadMetrics); + + @disposeOnUnmount + fetchMetrics = reaction( + () => clusterOverviewStore.metricNodeRole, // Toggle Master/Worker node switcher + () => this.metricPoller.restart(true) + ); + + loadMetrics() { + getHostedCluster().available && clusterOverviewStore.loadMetrics(); + } + + async componentDidMount() { + if (isAllowedResource("nodes")) { + this.stores.push(nodesStore); + } + + if (isAllowedResource("pods")) { + this.stores.push(podsStore); + } + + if (isAllowedResource("events")) { + this.stores.push(eventStore); + } + + await Promise.all(this.stores.map(store => store.loadAll())); + this.loadMetrics(); + + this.subscribers = this.stores.map(store => store.subscribe()); + this.metricPoller.start(); + } + + componentWillUnmount() { + this.subscribers.forEach(dispose => dispose()); // unsubscribe all + this.metricPoller.stop(); + } + + render() { + const isLoaded = nodesStore.isLoaded && podsStore.isLoaded; + + return ( + +
+ {!isLoaded ? : ( + <> + + + + + )} +
+
+ ); + } +} diff --git a/src/renderer/components/+cluster/cluster-pie-charts.tsx b/src/renderer/components/+cluster/cluster-pie-charts.tsx index 246af6a3ca..684233f8ca 100644 --- a/src/renderer/components/+cluster/cluster-pie-charts.tsx +++ b/src/renderer/components/+cluster/cluster-pie-charts.tsx @@ -4,7 +4,7 @@ import React from "react"; import { observer } from "mobx-react"; import { t, Trans } from "@lingui/macro"; import { useLingui } from "@lingui/react"; -import { clusterStore, MetricNodeRole } from "./cluster.store"; +import { clusterOverviewStore, MetricNodeRole } from "./cluster-overview.store"; import { Spinner } from "../spinner"; import { Icon } from "../icon"; import { nodesStore } from "../+nodes/nodes.store"; @@ -27,7 +27,7 @@ export const ClusterPieCharts = observer(() => { }; const renderCharts = () => { - const data = getMetricLastPoints(clusterStore.metrics); + const data = getMetricLastPoints(clusterOverviewStore.metrics); const { memoryUsage, memoryRequests, memoryCapacity, memoryLimits } = data; const { cpuUsage, cpuRequests, cpuCapacity, cpuLimits } = data; const { podUsage, podCapacity } = data; @@ -173,7 +173,7 @@ export const ClusterPieCharts = observer(() => { const renderContent = () => { const { masterNodes, workerNodes } = nodesStore; - const { metricNodeRole, metricsLoaded } = clusterStore; + const { metricNodeRole, metricsLoaded } = clusterOverviewStore; const nodes = metricNodeRole === MetricNodeRole.MASTER ? masterNodes : workerNodes; if (!nodes.length) { @@ -192,7 +192,7 @@ export const ClusterPieCharts = observer(() => { ); } - const { memoryCapacity, cpuCapacity, podCapacity } = getMetricLastPoints(clusterStore.metrics); + const { memoryCapacity, cpuCapacity, podCapacity } = getMetricLastPoints(clusterOverviewStore.metrics); if (!memoryCapacity || !cpuCapacity || !podCapacity) { return ; diff --git a/src/renderer/components/+cluster/cluster.tsx b/src/renderer/components/+cluster/cluster.tsx deleted file mode 100644 index f99f65c479..0000000000 --- a/src/renderer/components/+cluster/cluster.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import "./cluster.scss"; - -import React from "react"; -import { computed, reaction } from "mobx"; -import { disposeOnUnmount, observer } from "mobx-react"; -import { TabLayout } from "../layout/tab-layout"; -import { ClusterIssues } from "./cluster-issues"; -import { Spinner } from "../spinner"; -import { cssNames, interval, isElectron } from "../../utils"; -import { ClusterPieCharts } from "./cluster-pie-charts"; -import { ClusterMetrics } from "./cluster-metrics"; -import { nodesStore } from "../+nodes/nodes.store"; -import { podsStore } from "../+workloads-pods/pods.store"; -import { clusterStore } from "./cluster.store"; -import { eventStore } from "../+events/event.store"; -import { isAllowedResource } from "../../../common/rbac"; -import { getHostedCluster } from "../../../common/cluster-store"; - -@observer -export class Cluster extends React.Component { - private dependentStores = [nodesStore, podsStore]; - - private watchers = [ - interval(60, () => { getHostedCluster().available && clusterStore.getMetrics();}), - interval(20, () => { getHostedCluster().available && eventStore.loadAll();}) - ]; - - @computed get isLoaded() { - return nodesStore.isLoaded && podsStore.isLoaded; - } - - // todo: refactor - async componentDidMount() { - const { dependentStores } = this; - - if (!isAllowedResource("nodes")) { - dependentStores.splice(dependentStores.indexOf(nodesStore), 1); - } - this.watchers.forEach(watcher => watcher.start(true)); - - await Promise.all([ - ...dependentStores.map(store => store.loadAll()), - clusterStore.getAllMetrics() - ]); - - disposeOnUnmount(this, [ - ...dependentStores.map(store => store.subscribe()), - () => this.watchers.forEach(watcher => watcher.stop()), - reaction( - () => clusterStore.metricNodeRole, - () => this.watchers.forEach(watcher => watcher.restart()) - ) - ]); - } - - render() { - const { isLoaded } = this; - - return ( - -
- {!isLoaded && } - {isLoaded && ( - <> - - - - - )} -
-
- ); - } -} diff --git a/src/renderer/components/+config-autoscalers/hpa-details.tsx b/src/renderer/components/+config-autoscalers/hpa-details.tsx index beb3ccfed6..e9ac05fcbd 100644 --- a/src/renderer/components/+config-autoscalers/hpa-details.tsx +++ b/src/renderer/components/+config-autoscalers/hpa-details.tsx @@ -133,7 +133,7 @@ export class HpaDetails extends React.Component { kubeObjectDetailRegistry.add({ kind: "HorizontalPodAutoscaler", - apiVersions: ["autoscaling/v1"], + apiVersions: ["autoscaling/v2beta1"], components: { Details: (props) => } @@ -141,7 +141,7 @@ kubeObjectDetailRegistry.add({ kubeObjectDetailRegistry.add({ kind: "HorizontalPodAutoscaler", - apiVersions: ["autoscaling/v1"], + apiVersions: ["autoscaling/v2beta1"], priority: 5, components: { Details: (props) => diff --git a/src/renderer/components/+custom-resources/crd-resources.tsx b/src/renderer/components/+custom-resources/crd-resources.tsx index a4a52ef867..cca7ac7015 100644 --- a/src/renderer/components/+custom-resources/crd-resources.tsx +++ b/src/renderer/components/+custom-resources/crd-resources.tsx @@ -93,7 +93,7 @@ export class CrdResources extends React.Component { isNamespaced && crdInstance.getNs(), ...extraColumns.map(column => ({ renderBoolean: true, - children: jsonPath.value(crdInstance, column.jsonPath.slice(1)), + children: JSON.stringify(jsonPath.value(crdInstance, column.jsonPath.slice(1))), })), crdInstance.getAge(), ]} diff --git a/src/renderer/components/+nodes/node-charts.tsx b/src/renderer/components/+nodes/node-charts.tsx index 5900e574df..52caa37ba7 100644 --- a/src/renderer/components/+nodes/node-charts.tsx +++ b/src/renderer/components/+nodes/node-charts.tsx @@ -26,9 +26,11 @@ export const NodeCharts = observer(() => { const [ memoryUsage, memoryRequests, + _memoryLimits, // eslint-disable-line unused-imports/no-unused-vars-ts memoryCapacity, cpuUsage, cpuRequests, + _cpuLimits, // eslint-disable-line unused-imports/no-unused-vars-ts cpuCapacity, podUsage, podCapacity, diff --git a/src/renderer/components/+workloads-cronjobs/cronjob-details.tsx b/src/renderer/components/+workloads-cronjobs/cronjob-details.tsx index 4d8b56a177..60fa680148 100644 --- a/src/renderer/components/+workloads-cronjobs/cronjob-details.tsx +++ b/src/renderer/components/+workloads-cronjobs/cronjob-details.tsx @@ -90,14 +90,14 @@ export class CronJobDetails extends React.Component { kubeObjectDetailRegistry.add({ kind: "CronJob", - apiVersions: ["batch/v1"], + apiVersions: ["batch/v1beta1"], components: { Details: (props) => } }); kubeObjectDetailRegistry.add({ kind: "CronJob", - apiVersions: ["batch/v1"], + apiVersions: ["batch/v1beta1"], priority: 5, components: { Details: (props) => diff --git a/src/renderer/components/+workloads-deployments/deployment-scale-dialog.test.tsx b/src/renderer/components/+workloads-deployments/deployment-scale-dialog.test.tsx index ead4a37487..e3d18669f9 100644 --- a/src/renderer/components/+workloads-deployments/deployment-scale-dialog.test.tsx +++ b/src/renderer/components/+workloads-deployments/deployment-scale-dialog.test.tsx @@ -128,33 +128,45 @@ describe("", () => { const initReplicas = 1; deploymentApi.getReplicas = jest.fn().mockImplementationOnce(async () => initReplicas); - const { getByTestId } = render(); + const component = render(); DeploymentScaleDialog.open(dummyDeployment); await waitFor(async () => { - const desiredScale = await getByTestId("desired-scale"); - - expect(desiredScale).toHaveTextContent(`${initReplicas}`); + expect(await component.getByTestId("desired-scale")).toHaveTextContent(`${initReplicas}`); + expect(await component.getByTestId("current-scale")).toHaveTextContent(`${initReplicas}`); + expect((await component.baseElement.querySelector("input").value)).toBe(`${initReplicas}`); }); - const up = await getByTestId("desired-replicas-up"); - const down = await getByTestId("desired-replicas-down"); + const up = await component.getByTestId("desired-replicas-up"); + const down = await component.getByTestId("desired-replicas-down"); fireEvent.click(up); - expect(await getByTestId("desired-scale")).toHaveTextContent(`${initReplicas + 1}`); - fireEvent.click(down); - expect(await getByTestId("desired-scale")).toHaveTextContent("1"); - // edge case, desiredScale must > 0 - fireEvent.click(down); - fireEvent.click(down); - expect(await getByTestId("desired-scale")).toHaveTextContent("1"); - const times = 120; + expect(await component.getByTestId("desired-scale")).toHaveTextContent(`${initReplicas + 1}`); + expect(await component.getByTestId("current-scale")).toHaveTextContent(`${initReplicas}`); + expect((await component.baseElement.querySelector("input").value)).toBe(`${initReplicas + 1}`); + + fireEvent.click(down); + expect(await component.getByTestId("desired-scale")).toHaveTextContent(`${initReplicas}`); + expect(await component.getByTestId("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.getByTestId("desired-scale")).toHaveTextContent("0"); + expect((await component.baseElement.querySelector("input").value)).toBe("0"); + + // edge case, desiredScale must = 100 scaleMax (100) + times = 120; - // edge case, desiredScale must < scaleMax (100) for (let i = 0; i < times; i++) { fireEvent.click(up); } - expect(await getByTestId("desired-scale")).toHaveTextContent("100"); + expect(await component.getByTestId("desired-scale")).toHaveTextContent("100"); + expect((component.baseElement.querySelector("input").value)).toBe("100"); + expect(await component.getByTestId("warning")) + .toHaveTextContent("High number of replicas may cause cluster performance issues"); }); - }); - diff --git a/src/renderer/components/+workloads-deployments/deployment-scale-dialog.tsx b/src/renderer/components/+workloads-deployments/deployment-scale-dialog.tsx index 5fb43368d5..42105aeac7 100644 --- a/src/renderer/components/+workloads-deployments/deployment-scale-dialog.tsx +++ b/src/renderer/components/+workloads-deployments/deployment-scale-dialog.tsx @@ -91,7 +91,7 @@ export class DeploymentScaleDialog extends Component { }; desiredReplicasDown = () => { - this.desiredReplicas > 1 && this.desiredReplicas--; + this.desiredReplicas > 0 && this.desiredReplicas--; }; renderContents() { @@ -124,7 +124,7 @@ export class DeploymentScaleDialog extends Component { {warning && -
+
High number of replicas may cause cluster performance issues
diff --git a/src/renderer/components/+workloads-pods/pod-details-container.tsx b/src/renderer/components/+workloads-pods/pod-details-container.tsx index b883835076..cd95e11b43 100644 --- a/src/renderer/components/+workloads-pods/pod-details-container.tsx +++ b/src/renderer/components/+workloads-pods/pod-details-container.tsx @@ -57,6 +57,7 @@ export class PodDetailsContainer extends React.Component { const ready = status ? status.ready : ""; const liveness = pod.getLivenessProbe(container); const readiness = pod.getReadinessProbe(container); + const startup = pod.getStartupProbe(container); const isInitContainer = !!pod.getInitContainers().find(c => c.name == name); const metricTabs = [ CPU, @@ -140,6 +141,15 @@ export class PodDetailsContainer extends React.Component { } } + {startup.length > 0 && + Startup} labelsOnly> + { + startup.map((value, index) => ( + + )) + } + + } {command && Command}> {command.join(" ")} diff --git a/src/renderer/components/app.tsx b/src/renderer/components/app.tsx index 8820298c86..6ad41318ab 100755 --- a/src/renderer/components/app.tsx +++ b/src/renderer/components/app.tsx @@ -16,7 +16,7 @@ import { Workloads, workloadsRoute, workloadsURL } from "./+workloads"; import { Namespaces, namespacesRoute } from "./+namespaces"; import { Network, networkRoute } from "./+network"; import { Storage, storageRoute } from "./+storage"; -import { Cluster } from "./+cluster/cluster"; +import { ClusterOverview } from "./+cluster/cluster-overview"; import { Config, configRoute } from "./+config"; import { Events } from "./+events/events"; import { eventRoute } from "./+events"; @@ -180,7 +180,7 @@ export class App extends React.Component { - + diff --git a/src/renderer/components/layout/__test__/main-layout-header.test.tsx b/src/renderer/components/layout/__test__/main-layout-header.test.tsx index 91fcc5dc7f..b2a7bb5d93 100644 --- a/src/renderer/components/layout/__test__/main-layout-header.test.tsx +++ b/src/renderer/components/layout/__test__/main-layout-header.test.tsx @@ -41,9 +41,9 @@ describe("", () => { expect(mockBroadcastIpc).toBeCalledWith("renderer:navigate", "/cluster/foo/settings"); }); - it("renders cluster name", async () => { + it("renders cluster name", () => { const { getByText } = render(); - expect(await getByText("minikube")).toBeTruthy(); + expect(getByText("minikube")).toBeInTheDocument(); }); }); \ No newline at end of file diff --git a/src/renderer/components/layout/sidebar-context.ts b/src/renderer/components/layout/sidebar-context.ts new file mode 100644 index 0000000000..fff192cba3 --- /dev/null +++ b/src/renderer/components/layout/sidebar-context.ts @@ -0,0 +1,7 @@ +import React from "react"; + +export const SidebarContext = React.createContext({ pinned: false }); + +export type SidebarContextValue = { + pinned: boolean; +}; \ No newline at end of file diff --git a/src/renderer/components/layout/sidebar-nav-item.scss b/src/renderer/components/layout/sidebar-nav-item.scss new file mode 100644 index 0000000000..24469dbd0f --- /dev/null +++ b/src/renderer/components/layout/sidebar-nav-item.scss @@ -0,0 +1,76 @@ +.SidebarNavItem { + $itemSpacing: floor($unit / 2.6) floor($unit / 1.6); + + width: 100%; + user-select: none; + flex-shrink: 0; + + .nav-item { + cursor: pointer; + width: inherit; + display: flex; + align-items: center; + text-decoration: none; + border: none; + padding: $itemSpacing; + + &.active, &:hover { + background: $lensBlue; + color: $sidebarActiveColor; + } + } + + .expand-icon { + --size: 20px; + } + + .sub-menu { + border-left: 4px solid transparent; + + &.active { + border-left-color: $lensBlue; + } + + a, .SidebarNavItem { + display: block; + border: none; + text-decoration: none; + color: $textColorPrimary; + font-weight: normal; + padding-left: 40px; // parent icon width + overflow: hidden; + text-overflow: ellipsis; + line-height: 0px; // hidden by default + max-height: 0px; + opacity: 0; + transition: 125ms line-height ease-out, 200ms 100ms opacity; + + &.visible { + line-height: 28px; + max-height: 1000px; + opacity: 1; + } + + &.active, &:hover { + color: $sidebarSubmenuActiveColor; + } + } + + .sub-menu-parent { + padding-left: 27px; + font-weight: 500; + + .nav-item { + &:hover { + background: transparent; + } + } + + .sub-menu { + a { + padding-left: $padding * 3; + } + } + } + } +} \ No newline at end of file diff --git a/src/renderer/components/layout/sidebar-nav-item.tsx b/src/renderer/components/layout/sidebar-nav-item.tsx new file mode 100644 index 0000000000..7dfcbb50e6 --- /dev/null +++ b/src/renderer/components/layout/sidebar-nav-item.tsx @@ -0,0 +1,83 @@ +import "./sidebar-nav-item.scss"; + +import React from "react"; +import { computed, observable, reaction } from "mobx"; +import { observer } from "mobx-react"; +import { NavLink } from "react-router-dom"; + +import { createStorage, cssNames } from "../../utils"; +import { Icon } from "../icon"; +import { SidebarContext } from "./sidebar-context"; + +import type { TabLayoutRoute } from "./tab-layout"; +import type { SidebarContextValue } from "./sidebar-context"; + +interface SidebarNavItemProps { + id: string; // Used to save nav item collapse/expand state in local storage + url: string; + text: React.ReactNode | string; + className?: string; + icon?: React.ReactNode; + isHidden?: boolean; + isActive?: boolean; + subMenus?: TabLayoutRoute[]; +} + +const navItemStorage = createStorage<[string, boolean][]>("sidebar_menu_item", []); +const navItemState = observable.map(navItemStorage.get()); + +reaction(() => [...navItemState], (value) => navItemStorage.set(value)); + +@observer +export class SidebarNavItem extends React.Component { + static contextType = SidebarContext; + public context: SidebarContextValue; + + @computed get isExpanded() { + return navItemState.get(this.props.id); + } + + toggleSubMenu = () => { + navItemState.set(this.props.id, !this.isExpanded); + }; + + render() { + const { isHidden, isActive, subMenus = [], icon, text, url, children, className, id } = this.props; + + if (isHidden) { + return null; + } + const extendedView = (subMenus.length > 0 || children) && this.context.pinned; + + if (extendedView) { + return ( +
+
+ {icon} + {text} + +
+
    + {subMenus.map(({ title, url }) => ( + + {title} + + ))} + {React.Children.toArray(children).map((child: React.ReactElement) => { + return React.cloneElement(child, { + className: cssNames(child.props.className, { visible: this.isExpanded }), + }); + })} +
+
+ ); + } + + return ( + isActive}> + {icon} + {text} + + ); + } +} diff --git a/src/renderer/components/layout/sidebar.scss b/src/renderer/components/layout/sidebar.scss index 1c5932b8d9..1379c87d9e 100644 --- a/src/renderer/components/layout/sidebar.scss +++ b/src/renderer/components/layout/sidebar.scss @@ -1,22 +1,7 @@ .Sidebar { $iconSize: 24px; - $activeBgc: $lensBlue; - $activeTextColor: $sidebarActiveColor; $itemSpacing: floor($unit / 2.6) floor($unit / 1.6); - @mixin activeLinkState { - &.active { - background: $activeBgc; - color: $activeTextColor; - } - @media (hover: hover) { // only for devices supported "true" hover (with mouse or similar) - &:hover { - background: $activeBgc; - color: $activeTextColor; - } - } - } - &.pinned { .sidebar-nav { overflow: auto; @@ -77,13 +62,16 @@ } > a { - @include activeLinkState; - display: flex; align-items: center; text-decoration: none; border: none; padding: $itemSpacing; + + &.active, &:hover { + background: $lensBlue; + color: $sidebarActiveColor; + } } hr { @@ -91,78 +79,6 @@ } } - .SidebarNavItem { - width: 100%; - user-select: none; - flex-shrink: 0; - - .nav-item { - @include activeLinkState; - - cursor: pointer; - width: inherit; - display: flex; - align-items: center; - text-decoration: none; - border: none; - padding: $itemSpacing; - } - - .expand-icon { - --size: 20px; - } - - .sub-menu { - border-left: 4px solid transparent; - - &.active { - border-left-color: $activeBgc; - } - - a, .SidebarNavItem { - display: block; - border: none; - text-decoration: none; - color: $textColorPrimary; - font-weight: normal; - padding-left: 40px; // parent icon width - overflow: hidden; - text-overflow: ellipsis; - line-height: 0px; // hidden by default - max-height: 0px; - opacity: 0; - transition: 125ms line-height ease-out, 200ms 100ms opacity; - - &.visible { - line-height: 28px; - max-height: 1000px; - opacity: 1; - } - - &.active, &:hover { - color: $sidebarSubmenuActiveColor; - } - } - - .sub-menu-parent { - padding-left: 27px; - font-weight: 500; - - .nav-item { - &:hover { - background: transparent; - } - } - - .sub-menu { - a { - padding-left: $padding * 3; - } - } - } - } - } - .loading { padding: $padding; text-align: center; diff --git a/src/renderer/components/layout/sidebar.tsx b/src/renderer/components/layout/sidebar.tsx index 6f876e48b1..c26a2e5a96 100644 --- a/src/renderer/components/layout/sidebar.tsx +++ b/src/renderer/components/layout/sidebar.tsx @@ -1,12 +1,11 @@ -import type { TabLayoutRoute } from "./tab-layout"; import "./sidebar.scss"; import React from "react"; -import { computed, observable, reaction } from "mobx"; +import type { TabLayoutRoute } from "./tab-layout"; import { observer } from "mobx-react"; import { NavLink } from "react-router-dom"; import { Trans } from "@lingui/macro"; -import { createStorage, cssNames } from "../../utils"; +import { cssNames } from "../../utils"; import { Icon } from "../icon"; import { workloadsRoute, workloadsURL } from "../+workloads/workloads.route"; import { namespacesRoute, namespacesURL } from "../+namespaces/namespaces.route"; @@ -30,12 +29,8 @@ import { isActiveRoute } from "../../navigation"; import { isAllowedResource } from "../../../common/rbac"; import { Spinner } from "../spinner"; import { ClusterPageMenuRegistration, clusterPageMenuRegistry, clusterPageRegistry, getExtensionPageUrl } from "../../../extensions/registries"; - -const SidebarContext = React.createContext({ pinned: false }); - -type SidebarContextValue = { - pinned: boolean; -}; +import { SidebarNavItem } from "./sidebar-nav-item"; +import { SidebarContext } from "./sidebar-context"; interface Props { className?: string; @@ -69,6 +64,7 @@ export class Sidebar extends React.Component { return ( { } renderRegisteredMenus() { - return clusterPageMenuRegistry.getRootItems().map((menuItem) => { - const registeredPage = clusterPageRegistry.getByPageTarget(menuItem.target); - - if (!registeredPage) { - return; - } - - let pageUrl = getExtensionPageUrl(menuItem.target); - let isActive = isActiveRoute(registeredPage.url); + return clusterPageMenuRegistry.getRootItems().map((menuItem, index) => { + const registeredPage = clusterPageRegistry.getByPageMenuTarget(menuItem.target); const tabRoutes = this.getTabLayoutRoutes(menuItem); + const id = `registered-item-${index}`; + let pageUrl: string; + let isActive = false; - if (tabRoutes.length > 0) { + if (registeredPage) { + const { extensionId, id: pageId } = registeredPage; + + pageUrl = getExtensionPageUrl({ extensionId, pageId, params: menuItem.target.params }); + isActive = isActiveRoute(registeredPage.routePath); + } else if (tabRoutes.length > 0) { pageUrl = tabRoutes[0].url; isActive = isActiveRoute(tabRoutes.map((tab) => tab.routePath)); + } else { + return; } return ( } @@ -155,7 +155,7 @@ export class Sidebar extends React.Component {
{ icon={} /> { icon={} /> { icon={} /> { icon={} /> { icon={} /> { text={Storage} /> { text={Namespaces} /> { text={Events} /> { text={Apps} /> { text={Access Control} /> { ); } } - -interface SidebarNavItemProps { - url: string; - text: React.ReactNode | string; - className?: string; - icon?: React.ReactNode; - isHidden?: boolean; - isActive?: boolean; - subMenus?: TabLayoutRoute[]; - testId?: string; // data-test-id="" property for integration tests -} - -const navItemStorage = createStorage<[string, boolean][]>("sidebar_menu_item", []); -const navItemState = observable.map(navItemStorage.get()); - -reaction(() => [...navItemState], (value) => navItemStorage.set(value)); - -@observer -class SidebarNavItem extends React.Component { - static contextType = SidebarContext; - public context: SidebarContextValue; - - get itemId() { - const url = new URL(this.props.url, `${window.location.protocol}//${window.location.host}`); - - return url.pathname; // pathname without get params - } - - @computed get isExpanded() { - return navItemState.get(this.itemId); - } - - toggleSubMenu = () => { - navItemState.set(this.itemId, !this.isExpanded); - }; - - render() { - const { isHidden, isActive, subMenus = [], icon, text, url, children, className, testId } = this.props; - - if (isHidden) { - return null; - } - const extendedView = (subMenus.length > 0 || children) && this.context.pinned; - - if (extendedView) { - return ( -
-
- {icon} - {text} - -
-
    - {subMenus.map(({ title, url }) => ( - - {title} - - ))} - {React.Children.toArray(children).map((child: React.ReactElement) => { - return React.cloneElement(child, { - className: cssNames(child.props.className, { visible: this.isExpanded }), - }); - })} -
-
- ); - } - - return ( - isActive}> - {icon} - {text} - - ); - } -} diff --git a/src/renderer/kube-object.store.ts b/src/renderer/kube-object.store.ts index e23adf3566..bb2fffd819 100644 --- a/src/renderer/kube-object.store.ts +++ b/src/renderer/kube-object.store.ts @@ -195,10 +195,9 @@ export abstract class KubeObjectStore extends ItemSt const items = this.items.toJS(); for (const {type, object} of this.eventsBuffer.clear()) { - const { uid, selfLink } = object.metadata; - const index = items.findIndex(item => item.getId() === uid); + const index = items.findIndex(item => item.getId() === object.metadata?.uid); const item = items[index]; - const api = apiManager.getApi(selfLink); + const api = apiManager.getApiByKind(object.kind, object.apiVersion); switch (type) { case "ADDED": diff --git a/static/RELEASE_NOTES.md b/static/RELEASE_NOTES.md index 3cd54f31b7..b030c0d8b5 100644 --- a/static/RELEASE_NOTES.md +++ b/static/RELEASE_NOTES.md @@ -2,7 +2,27 @@ Here you can find description of changes we've built into each release. While we try our best to make each upgrade automatic and as smooth as possible, there may be some cases where you might need to do something to ensure the application works smoothly. So please read through the release highlights! -## 4.0.0-rc.3 (current version) +## 4.0.2 (current version) + +We are aware some users are encountering issues and regressions from previous version. Many of these issues are something we have not seen as part of our automated or manual testing process. To make it worse, some of them are really difficult to reproduce. We want to ensure we are putting all our energy and effort trying to resolve these issues. We hope you are patient. Expect to see new patch releases still in the coming days! Fixes in this version: + +- Fix: use correct apiversion for HPA details +- Fix: use correct apiversion fro CronJob details +- Fix: wrong values in node metrics +- Fix: Deployment scale button "minus" +- Fix: remove symlink on extension install and manual runtime uninstall +- Fix: logs autoscroll behaviour +- Performance fixes + +## 4.0.1 + +- Extension install/uninstall fixes +- Fix status brick styles in pod-menu-extension +- MacOS: fix error on app start +- Performance fix: query all objects using single api call if admin and namespace list is not overridden +- Extension API fix: register a cluster page component properly to a route + +## 4.0.0 - Extension API - Improved pod logs @@ -28,6 +48,7 @@ Here you can find description of changes we've built into each release. While we - Replace cluster warning event polling with watches - Detect more Kubernetes distributions - Performance fix when cluster has lots of namespaces +- Store more than largest kube api request amount in the event store - Fix pod usage metrics on Kubernetes >=1.19 - Fix proxy upgrade socket timeouts - Fix UI staleness after network issues