1
0
mirror of https://github.com/lensapp/lens.git synced 2025-05-20 05:10:56 +00:00

Merge remote-tracking branch 'origin/master' into pages-url-params

# Conflicts:
#	src/renderer/components/layout/sidebar.tsx
This commit is contained in:
Roman 2020-12-18 15:48:10 +02:00
commit 69e5e5eaab
49 changed files with 656 additions and 448 deletions

View File

@ -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)

View File

@ -1,8 +1,15 @@
variables: variables:
YARN_CACHE_FOLDER: $(Pipeline.Workspace)/.yarn YARN_CACHE_FOLDER: $(Pipeline.Workspace)/.yarn
pr: pr:
- master branches:
- releases/* include:
- master
- releases/*
paths:
exclude:
- .github/*
- docs/*
- mkdocs/*
trigger: trigger:
branches: branches:
include: include:
@ -10,6 +17,11 @@ trigger:
tags: tags:
include: include:
- "*" - "*"
paths:
exclude:
- .github/*
- docs/*
- mkdocs/*
jobs: jobs:
- job: Windows - job: Windows
pool: pool:

View File

@ -13,7 +13,7 @@ binaries/client:
yarn download-bins yarn download-bins
node_modules: node_modules:
yarn install --frozen-lockfile --verbose yarn install --frozen-lockfile
yarn check --verify-tree --integrity yarn check --verify-tree --integrity
static/build/LensDev.html: static/build/LensDev.html:

View File

@ -1,14 +1,14 @@
# Extension Guides # 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. - Clearly commented source code.
- Instructions for running the sample extension. - Instructions for running the sample extension.
- Image of the sample extension's appearance and usage. - An image showing the sample extension's appearance and usage.
- Listing of Extension API being used. - A listing of the Extension API being used.
- Explanation of Extension API concepts. - An explanation of the concepts relevant to the Extension.
## Guides ## Guides

View File

@ -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 ```bash
npm install -g yo generator-lens-ext 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 ```bash
yo lens-ext
# ? What type of extension do you want to create? New Extension (TypeScript) # ? 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 name of your extension? my-first-lens-ext
# ? What's the description of your extension? My hello world extension # ? What's the description of your extension? My hello world extension
@ -17,24 +22,25 @@ yo lens-ext
# ? Initialize a git repository? Yes # ? Initialize a git repository? Yes
# ? Install dependencies after initialization? Yes # ? Install dependencies after initialization? Yes
# ? Which package manager to use? yarn # ? Which package manager to use? yarn
# ? symlink created extension folder to ~/.k8slens/extensions (mac/linux) or :User # ? symlink created extension folder to ~/.k8slens/extensions (mac/linux) or :Users\<user>\.k8slens\extensions (windows)? Yes
s\<user>\.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 ```bash
cd my-first-lens-ext cd my-first-lens-ext
npm start # start the webpack server in watch mode 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) ![Hello World](images/hello-world.png)
## Developing the Extension ## 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 ```tsx
clusterPageMenus = [ 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) ![Hello World](images/hello-lens.png)
## Debugging the Extension ## 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).

View File

@ -1,12 +1,14 @@
# Main Extension # 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 ## `LensMainExtension` Class
### `onActivate()` and `onDeactivate()` Methods
To create a main extension simply extend the `LensMainExtension` class: To create a main extension simply extend the `LensMainExtension` class:
``` typescript ```typescript
import { LensMainExtension } from "@k8slens/extensions"; import { LensMainExtension } from "@k8slens/extensions";
export default class ExampleExtensionMain extends LensMainExtension { 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"; import { LensMainExtension, Store } from "@k8slens/extensions";
const clusterStore = Store.clusterStore 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` ### `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 ``` typescript
import { LensMainExtension } from "@k8slens/extensions"; 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. `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.

View File

@ -5362,7 +5362,8 @@
"semver": { "semver": {
"version": "7.3.2", "version": "7.3.2",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.3.2.tgz", "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": { "serialize-javascript": {
"version": "4.0.0", "version": "4.0.0",

View File

@ -12,14 +12,13 @@
"dev": "npm run build --watch", "dev": "npm run build --watch",
"test": "jest --passWithNoTests --env=jsdom src $@" "test": "jest --passWithNoTests --env=jsdom src $@"
}, },
"dependencies": { "dependencies": {},
"semver": "^7.3.2"
},
"devDependencies": { "devDependencies": {
"@k8slens/extensions": "file:../../src/extensions/npm/extensions", "@k8slens/extensions": "file:../../src/extensions/npm/extensions",
"jest": "^26.6.3", "jest": "^26.6.3",
"mobx": "^5.15.5", "mobx": "^5.15.5",
"react": "^16.13.1", "react": "^16.13.1",
"semver": "^7.3.2",
"ts-loader": "^8.0.4", "ts-loader": "^8.0.4",
"typescript": "^4.0.3", "typescript": "^4.0.3",
"webpack": "^4.44.2" "webpack": "^4.44.2"

View File

@ -74,6 +74,7 @@ export class Tracker extends Util.Singleton {
} }
reportPeriodically() { reportPeriodically() {
this.reportData();
this.reportInterval = setInterval(() => { this.reportInterval = setInterval(() => {
this.reportData(); this.reportData();
}, 60 * 60 * 1000); // report every 1h }, 60 * 60 * 1000); // report every 1h

View File

@ -226,7 +226,7 @@ describe("Lens integration tests", () => {
pages: [{ pages: [{
name: "Cluster", name: "Cluster",
href: "cluster", href: "cluster",
expectedSelector: "div.Cluster div.label", expectedSelector: "div.ClusterOverview div.label",
expectedText: "Master" expectedText: "Master"
}] }]
}, },

View File

@ -2664,6 +2664,10 @@ msgstr "Status"
#~ msgid "Status URI" #~ msgid "Status URI"
#~ msgstr "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/+storage-volume-claims/volume-claim-details.tsx:56
#: src/renderer/components/layout/sidebar.tsx:85 #: src/renderer/components/layout/sidebar.tsx:85
msgid "Storage" msgid "Storage"

View File

@ -2646,6 +2646,10 @@ msgstr ""
#~ msgid "Status URI" #~ msgid "Status URI"
#~ msgstr "" #~ 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/+storage-volume-claims/volume-claim-details.tsx:56
#: src/renderer/components/layout/sidebar.tsx:85 #: src/renderer/components/layout/sidebar.tsx:85
msgid "Storage" msgid "Storage"

View File

@ -2664,6 +2664,10 @@ msgstr "Статус"
#~ msgid "Status URI" #~ msgid "Status URI"
#~ msgstr "Адрес статуса" #~ 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/+storage-volume-claims/volume-claim-details.tsx:56
#: src/renderer/components/layout/sidebar.tsx:85 #: src/renderer/components/layout/sidebar.tsx:85
msgid "Storage" msgid "Storage"

View File

@ -2,7 +2,7 @@
"name": "kontena-lens", "name": "kontena-lens",
"productName": "Lens", "productName": "Lens",
"description": "Lens - The Kubernetes IDE", "description": "Lens - The Kubernetes IDE",
"version": "4.0.0-rc.3", "version": "4.1.0-alpha.0",
"main": "static/build/main.js", "main": "static/build/main.js",
"copyright": "© 2020, Mirantis, Inc.", "copyright": "© 2020, Mirantis, Inc.",
"license": "MIT", "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" "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": { "config": {
"bundledKubectlVersion": "1.17.11", "bundledKubectlVersion": "1.17.15",
"bundledHelmVersion": "3.3.4" "bundledHelmVersion": "3.3.4"
}, },
"engines": { "engines": {

View File

@ -266,18 +266,30 @@ export class ExtensionDiscovery {
logger.info(`${logModule} loading extensions from ${extensionInstaller.extensionPackagesRoot}`); logger.info(`${logModule} loading extensions from ${extensionInstaller.extensionPackagesRoot}`);
if (fs.existsSync(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")); await fs.remove(path.join(extensionInstaller.extensionPackagesRoot, "package-lock.json"));
}
try { try {
// Verify write access to static/extensions, which is needed for symlinking
await fs.access(this.inTreeFolderPath, fs.constants.W_OK); await fs.access(this.inTreeFolderPath, fs.constants.W_OK);
// Set bundled folder path to static/extensions
this.bundledFolderPath = this.inTreeFolderPath; this.bundledFolderPath = this.inTreeFolderPath;
} catch { } 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/<username>/Library/Application Support/LensDev/extensions
await fs.remove(this.inTreeTargetPath); await fs.remove(this.inTreeTargetPath);
// Create folder e.g. /Users/<username>/Library/Application Support/LensDev/extensions
await fs.ensureDir(this.inTreeTargetPath); await fs.ensureDir(this.inTreeTargetPath);
// Copy static/extensions to e.g. /Users/<username>/Library/Application Support/LensDev/extensions
await fs.copy(this.inTreeFolderPath, this.inTreeTargetPath); await fs.copy(this.inTreeFolderPath, this.inTreeTargetPath);
// Set bundled folder path to e.g. /Users/<username>/Library/Application Support/LensDev/extensions
this.bundledFolderPath = this.inTreeTargetPath; this.bundledFolderPath = this.inTreeTargetPath;
} }
@ -287,7 +299,7 @@ export class ExtensionDiscovery {
const extensions = await this.loadExtensions(); const extensions = await this.loadExtensions();
this.isLoaded = true; this.isLoaded = true;
return extensions; return extensions;
} }
@ -343,6 +355,8 @@ export class ExtensionDiscovery {
async loadExtensions(): Promise<Map<LensExtensionId, InstalledExtension>> { async loadExtensions(): Promise<Map<LensExtensionId, InstalledExtension>> {
const bundledExtensions = await this.loadBundledExtensions(); const bundledExtensions = await this.loadBundledExtensions();
await this.installPackages(); // install in-tree as a separate step
const localExtensions = await this.loadFromFolder(this.localFolderPath); const localExtensions = await this.loadFromFolder(this.localFolderPath);
await this.installPackages(); await this.installPackages();

View File

@ -33,16 +33,26 @@ export class ExtensionInstaller {
installDependencies(): Promise<void> { installDependencies(): Promise<void> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
logger.info(`${logModule} installing dependencies at ${extensionPackagesRoot()}`); 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(), cwd: extensionPackagesRoot(),
silent: true silent: true
}); });
let stderr = "";
child.on("close", () => { child.stderr.on("data", data => {
resolve(); 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);
}); });
}); });
} }

View File

@ -27,6 +27,7 @@ export * from "../../renderer/components/menu";
export * from "../../renderer/components/notifications"; export * from "../../renderer/components/notifications";
export * from "../../renderer/components/spinner"; export * from "../../renderer/components/spinner";
export * from "../../renderer/components/stepper"; export * from "../../renderer/components/stepper";
export * from "../../renderer/components/+workloads-pods/pod-details-list";
// kube helpers // kube helpers
export * from "../../renderer/components/kube-object"; export * from "../../renderer/components/kube-object";

View File

@ -56,12 +56,12 @@ export class DistributionDetector extends BaseClusterDetector {
return { value: "docker-desktop", accuracy: 80}; return { value: "docker-desktop", accuracy: 80};
} }
if (this.isCustom()) { if (this.isCustom() && await this.isOpenshift()) {
return { value: "custom", accuracy: 10}; return { value: "openshift", accuracy: 90};
} }
if (await this.isOpenshift()) { if (this.isCustom()) {
return { value: "openshift", accuracy: 90}; return { value: "custom", accuracy: 10};
} }
return { value: "unknown", accuracy: 10}; return { value: "unknown", accuracy: 10};
@ -88,7 +88,7 @@ export class DistributionDetector extends BaseClusterDetector {
} }
protected isAKS() { protected isAKS() {
return this.cluster.apiUrl.endsWith("azmk8s.io"); return this.cluster.apiUrl.includes("azmk8s.io");
} }
protected isMirantis() { protected isMirantis() {

View File

@ -1,6 +1,6 @@
import { randomBytes } from "crypto"; import { randomBytes } from "crypto";
import { SHA256 } from "crypto-js"; import { SHA256 } from "crypto-js";
import { app } from "electron"; import { app, remote } from "electron";
import fse from "fs-extra"; import fse from "fs-extra";
import { action, observable, toJS } from "mobx"; import { action, observable, toJS } from "mobx";
import path from "path"; import path from "path";
@ -31,7 +31,7 @@ export class FilesystemProvisionerStore extends BaseStore<FSProvisionModel> {
if (!this.registeredExtensions.has(extensionName)) { if (!this.registeredExtensions.has(extensionName)) {
const salt = randomBytes(32).toString("hex"); const salt = randomBytes(32).toString("hex");
const hashedName = SHA256(`${extensionName}/${salt}`).toString(); 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); this.registeredExtensions.set(extensionName, dirPath);
} }

View File

@ -4,7 +4,7 @@ import "../common/system-ca";
import "../common/prometheus-providers"; import "../common/prometheus-providers";
import * as Mobx from "mobx"; import * as Mobx from "mobx";
import * as LensExtensions from "../extensions/core-api"; import * as LensExtensions from "../extensions/core-api";
import { app, dialog } from "electron"; import { app, dialog, powerMonitor } from "electron";
import { appName } from "../common/vars"; import { appName } from "../common/vars";
import path from "path"; import path from "path";
import { LensProxy } from "./lens-proxy"; import { LensProxy } from "./lens-proxy";
@ -45,10 +45,24 @@ if (app.commandLine.getSwitchValue("proxy-server") !== "") {
process.env.HTTPS_PROXY = 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 () => { app.on("ready", async () => {
logger.info(`🚀 Starting Lens from "${workingDir}"`); logger.info(`🚀 Starting Lens from "${workingDir}"`);
await shellSync(); await shellSync();
powerMonitor.on("shutdown", () => {
app.exit();
});
const updater = new AppUpdater(); const updater = new AppUpdater();
updater.start(); updater.start();
@ -83,8 +97,8 @@ app.on("ready", async () => {
// eslint-disable-next-line unused-imports/no-unused-vars-ts // eslint-disable-next-line unused-imports/no-unused-vars-ts
proxyServer = LensProxy.create(proxyPort, clusterManager); proxyServer = LensProxy.create(proxyPort, clusterManager);
} catch (error) { } catch (error) {
logger.error(`Could not start proxy (127.0.0:${proxyPort}): ${error.message}`); 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"}`); dialog.showErrorBox("Lens Error", `Could not start proxy (127.0.0:${proxyPort}): ${error?.message || "unknown error"}`);
app.exit(); app.exit();
} }
@ -94,17 +108,21 @@ app.on("ready", async () => {
windowManager = WindowManager.getInstance<WindowManager>(proxyPort); windowManager = WindowManager.getInstance<WindowManager>(proxyPort);
// call after windowManager to see splash earlier // 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 // Subscribe to extensions that are copied or deleted to/from the extensions folder
extensionDiscovery.events.on("add", (extension: InstalledExtension) => { extensionDiscovery.events.on("add", (extension: InstalledExtension) => {
extensionLoader.addExtension(extension); extensionLoader.addExtension(extension);
}); });
extensionDiscovery.events.on("remove", (lensExtensionId: LensExtensionId) => { extensionDiscovery.events.on("remove", (lensExtensionId: LensExtensionId) => {
extensionLoader.removeExtension(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(() => { setTimeout(() => {
appEventBus.emit({ name: "service", action: "start" }); appEventBus.emit({ name: "service", action: "start" });

View File

@ -17,6 +17,10 @@ export class ApiManager {
return Array.from(this.apis.values()).find(pathOrCallback ?? (() => true)); 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) { registerApi(apiBase: string, api: KubeApi) {
if (!this.apis.has(apiBase)) { if (!this.apis.has(apiBase)) {
this.apis.set(apiBase, api); this.apis.set(apiBase, api);

View File

@ -46,6 +46,26 @@ export class DeploymentApi extends KubeApi<Deployment> {
} }
} }
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() @autobind()
export class Deployment extends WorkloadKubeObject { export class Deployment extends WorkloadKubeObject {
static kind = "Deployment"; static kind = "Deployment";
@ -89,30 +109,9 @@ export class Deployment extends WorkloadKubeObject {
name: string; name: string;
mountPath: string; mountPath: string;
}[]; }[];
livenessProbe?: { livenessProbe?: IContainerProbe;
httpGet: { readinessProbe?: IContainerProbe;
path: string; startupProbe?: IContainerProbe;
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;
};
terminationMessagePath: string; terminationMessagePath: string;
terminationMessagePolicy: string; terminationMessagePolicy: string;
imagePullPolicy: string; imagePullPolicy: string;

View File

@ -112,6 +112,7 @@ export interface IPodContainer {
}[]; }[];
livenessProbe?: IContainerProbe; livenessProbe?: IContainerProbe;
readinessProbe?: IContainerProbe; readinessProbe?: IContainerProbe;
startupProbe?: IContainerProbe;
imagePullPolicy: string; imagePullPolicy: string;
} }
@ -429,6 +430,10 @@ export class Pod extends WorkloadKubeObject {
return this.getProbe(container.readinessProbe); return this.getProbe(container.readinessProbe);
} }
getStartupProbe(container: IPodContainer) {
return this.getProbe(container.startupProbe);
}
getProbe(probeData: IContainerProbe) { getProbe(probeData: IContainerProbe) {
if (!probeData) return []; if (!probeData) return [];
const { const {

View File

@ -79,6 +79,18 @@ export function forCluster<T extends KubeObject>(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<T extends KubeObject = any> { export class KubeApi<T extends KubeObject = any> {
static parseApi = parseKubeApi; static parseApi = parseKubeApi;
@ -260,7 +272,11 @@ export class KubeApi<T extends KubeObject = any> {
const KubeObjectConstructor = this.objectConstructor; const KubeObjectConstructor = this.objectConstructor;
if (KubeObject.isJsonApiData(data)) { if (KubeObject.isJsonApiData(data)) {
return new KubeObjectConstructor(data); const object = new KubeObjectConstructor(data);
ensureObjectSelfLink(this, object);
return object;
} }
// process items list response // process items list response
@ -270,11 +286,17 @@ export class KubeApi<T extends KubeObject = any> {
this.setResourceVersion(namespace, metadata.resourceVersion); this.setResourceVersion(namespace, metadata.resourceVersion);
this.setResourceVersion("", metadata.resourceVersion); this.setResourceVersion("", metadata.resourceVersion);
return items.map(item => new KubeObjectConstructor({ return items.map((item) => {
kind: this.kind, const object = new KubeObjectConstructor({
apiVersion, kind: this.kind,
...item, apiVersion,
})); ...item,
});
ensureObjectSelfLink(this, object);
return object;
});
} }
// custom apis might return array for list response, e.g. users, groups, etc. // custom apis might return array for list response, e.g. users, groups, etc.

View File

@ -5,7 +5,7 @@ import { stringify } from "querystring";
import { autobind, EventEmitter } from "../utils"; import { autobind, EventEmitter } from "../utils";
import { KubeJsonApiData } from "./kube-json-api"; import { KubeJsonApiData } from "./kube-json-api";
import type { KubeObjectStore } from "../kube-object.store"; import type { KubeObjectStore } from "../kube-object.store";
import { KubeApi } from "./kube-api"; import { ensureObjectSelfLink, KubeApi } from "./kube-api";
import { apiManager } from "./api-manager"; import { apiManager } from "./api-manager";
import { apiPrefix, isDevelopment } from "../../common/vars"; import { apiPrefix, isDevelopment } from "../../common/vars";
import { getHostedCluster } from "../../common/cluster-store"; import { getHostedCluster } from "../../common/cluster-store";
@ -158,12 +158,14 @@ export class KubeWatchApi {
addListener(store: KubeObjectStore, callback: (evt: IKubeWatchEvent) => void) { addListener(store: KubeObjectStore, callback: (evt: IKubeWatchEvent) => void) {
const listener = (evt: IKubeWatchEvent<KubeJsonApiData>) => { const listener = (evt: IKubeWatchEvent<KubeJsonApiData>) => {
const { selfLink, namespace, resourceVersion } = evt.object.metadata; const { namespace, resourceVersion } = evt.object.metadata;
const api = apiManager.getApi(selfLink); const api = apiManager.getApiByKind(evt.object.kind, evt.object.apiVersion);
api.setResourceVersion(namespace, resourceVersion); api.setResourceVersion(namespace, resourceVersion);
api.setResourceVersion("", resourceVersion); api.setResourceVersion("", resourceVersion);
ensureObjectSelfLink(api, evt.object);
if (store == apiManager.getStore(api)) { if (store == apiManager.getStore(api)) {
callback(evt); callback(evt);
} }

View File

@ -1,17 +1,14 @@
.ClusterIssues { .ClusterIssues {
min-height: 350px; min-height: 350px;
position: relative; position: relative;
grid-column-start: 1;
grid-column-end: 3;
@include media("<1024px") { @include media("<1024px") {
grid-column-start: 1!important; grid-column-start: 1!important;
grid-column-end: 1!important; grid-column-end: 1!important;
} }
&.wide {
grid-column-start: 1;
grid-column-end: 3;
}
.SubHeader { .SubHeader {
.Icon { .Icon {
font-size: 130%; font-size: 130%;

View File

@ -6,10 +6,10 @@ import { observer } from "mobx-react";
import { nodesStore } from "../+nodes/nodes.store"; import { nodesStore } from "../+nodes/nodes.store";
import { cssNames } from "../../utils"; import { cssNames } from "../../utils";
import { Radio, RadioGroup } from "../radio"; import { Radio, RadioGroup } from "../radio";
import { clusterStore, MetricNodeRole, MetricType } from "./cluster.store"; import { clusterOverviewStore, MetricNodeRole, MetricType } from "./cluster-overview.store";
export const ClusterMetricSwitchers = observer(() => { export const ClusterMetricSwitchers = observer(() => {
const { metricType, metricNodeRole, getMetricsValues, metrics } = clusterStore; const { metricType, metricNodeRole, getMetricsValues, metrics } = clusterOverviewStore;
const { masterNodes, workerNodes } = nodesStore; const { masterNodes, workerNodes } = nodesStore;
const metricsValues = getMetricsValues(metrics); const metricsValues = getMetricsValues(metrics);
const disableRoles = !masterNodes.length || !workerNodes.length; const disableRoles = !masterNodes.length || !workerNodes.length;
@ -22,7 +22,7 @@ export const ClusterMetricSwitchers = observer(() => {
asButtons asButtons
className={cssNames("RadioGroup flex gaps", { disabled: disableRoles })} className={cssNames("RadioGroup flex gaps", { disabled: disableRoles })}
value={metricNodeRole} value={metricNodeRole}
onChange={(metric: MetricNodeRole) => clusterStore.metricNodeRole = metric} onChange={(metric: MetricNodeRole) => clusterOverviewStore.metricNodeRole = metric}
> >
<Radio label={<Trans>Master</Trans>} value={MetricNodeRole.MASTER}/> <Radio label={<Trans>Master</Trans>} value={MetricNodeRole.MASTER}/>
<Radio label={<Trans>Worker</Trans>} value={MetricNodeRole.WORKER}/> <Radio label={<Trans>Worker</Trans>} value={MetricNodeRole.WORKER}/>
@ -33,7 +33,7 @@ export const ClusterMetricSwitchers = observer(() => {
asButtons asButtons
className={cssNames("RadioGroup flex gaps", { disabled: disableMetrics })} className={cssNames("RadioGroup flex gaps", { disabled: disableMetrics })}
value={metricType} value={metricType}
onChange={(value: MetricType) => clusterStore.metricType = value} onChange={(value: MetricType) => clusterOverviewStore.metricType = value}
> >
<Radio label={<Trans>CPU</Trans>} value={MetricType.CPU}/> <Radio label={<Trans>CPU</Trans>} value={MetricType.CPU}/>
<Radio label={<Trans>Memory</Trans>} value={MetricType.MEMORY}/> <Radio label={<Trans>Memory</Trans>} value={MetricType.MEMORY}/>

View File

@ -3,7 +3,7 @@ import "./cluster-metrics.scss";
import React from "react"; import React from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { ChartOptions, ChartPoint } from "chart.js"; import { ChartOptions, ChartPoint } from "chart.js";
import { clusterStore, MetricType } from "./cluster.store"; import { clusterOverviewStore, MetricType } from "./cluster-overview.store";
import { BarChart } from "../chart"; import { BarChart } from "../chart";
import { bytesToUnits } from "../../utils"; import { bytesToUnits } from "../../utils";
import { Spinner } from "../spinner"; import { Spinner } from "../spinner";
@ -13,10 +13,9 @@ import { ClusterMetricSwitchers } from "./cluster-metric-switchers";
import { getMetricLastPoints } from "../../api/endpoints/metrics.api"; import { getMetricLastPoints } from "../../api/endpoints/metrics.api";
export const ClusterMetrics = observer(() => { export const ClusterMetrics = observer(() => {
const { metricType, metricNodeRole, getMetricsValues, metricsLoaded, metrics, liveMetrics } = clusterStore; const { metricType, metricNodeRole, getMetricsValues, metricsLoaded, metrics } = clusterOverviewStore;
const { memoryCapacity, cpuCapacity } = getMetricLastPoints(clusterStore.metrics); const { memoryCapacity, cpuCapacity } = getMetricLastPoints(clusterOverviewStore.metrics);
const metricValues = getMetricsValues(metrics); const metricValues = getMetricsValues(metrics);
const liveMetricValues = getMetricsValues(liveMetrics);
const colors = { cpu: "#3D90CE", memory: "#C93DCE" }; const colors = { cpu: "#3D90CE", memory: "#C93DCE" };
const data = metricValues.map(value => ({ const data = metricValues.map(value => ({
x: value[0], x: value[0],
@ -70,7 +69,7 @@ export const ClusterMetrics = observer(() => {
const options = metricType === MetricType.CPU ? cpuOptions : memoryOptions; const options = metricType === MetricType.CPU ? cpuOptions : memoryOptions;
const renderMetrics = () => { const renderMetrics = () => {
if ((!metricValues.length || !liveMetricValues.length) && !metricsLoaded) { if (!metricValues.length && !metricsLoaded) {
return <Spinner center/>; return <Spinner center/>;
} }

View File

@ -1,4 +1,4 @@
.Cluster { .ClusterOverview {
$gridGap: $margin * 2; $gridGap: $margin * 2;
position: relative; position: relative;

View File

@ -1,4 +1,4 @@
import { observable, reaction, when } from "mobx"; import { action, observable, reaction, when } from "mobx";
import { KubeObjectStore } from "../../kube-object.store"; import { KubeObjectStore } from "../../kube-object.store";
import { Cluster, clusterApi, IClusterMetrics } from "../../api/endpoints"; import { Cluster, clusterApi, IClusterMetrics } from "../../api/endpoints";
import { autobind, createStorage } from "../../utils"; import { autobind, createStorage } from "../../utils";
@ -17,11 +17,10 @@ export enum MetricNodeRole {
} }
@autobind() @autobind()
export class ClusterStore extends KubeObjectStore<Cluster> { export class ClusterOverviewStore extends KubeObjectStore<Cluster> {
api = clusterApi; api = clusterApi;
@observable metrics: Partial<IClusterMetrics> = {}; @observable metrics: Partial<IClusterMetrics> = {};
@observable liveMetrics: Partial<IClusterMetrics> = {};
@observable metricsLoaded = false; @observable metricsLoaded = false;
@observable metricType: MetricType; @observable metricType: MetricType;
@observable metricNodeRole: MetricNodeRole; @observable metricNodeRole: MetricNodeRole;
@ -46,9 +45,8 @@ export class ClusterStore extends KubeObjectStore<Cluster> {
reaction(() => this.metricNodeRole, () => { reaction(() => this.metricNodeRole, () => {
if (!this.metricsLoaded) return; if (!this.metricsLoaded) return;
this.metrics = {}; this.metrics = {};
this.liveMetrics = {};
this.metricsLoaded = false; this.metricsLoaded = false;
this.getAllMetrics(); this.loadMetrics();
}); });
// check which node type to select // check which node type to select
@ -60,33 +58,16 @@ export class ClusterStore extends KubeObjectStore<Cluster> {
}); });
} }
@action
async loadMetrics(params?: IMetricsReqParams) { async loadMetrics(params?: IMetricsReqParams) {
await when(() => nodesStore.isLoaded); await when(() => nodesStore.isLoaded);
const { masterNodes, workerNodes } = nodesStore; const { masterNodes, workerNodes } = nodesStore;
const nodes = this.metricNodeRole === MetricNodeRole.MASTER && masterNodes.length ? masterNodes : workerNodes; const nodes = this.metricNodeRole === MetricNodeRole.MASTER && masterNodes.length ? masterNodes : workerNodes;
return clusterApi.getMetrics(nodes.map(node => node.getName()), params); this.metrics = await clusterApi.getMetrics(nodes.map(node => node.getName()), params);
}
async getAllMetrics() {
await this.getMetrics();
await this.getLiveMetrics();
this.metricsLoaded = true; 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<IClusterMetrics>): [number, string][] { getMetricsValues(source: Partial<IClusterMetrics>): [number, string][] {
switch (this.metricType) { switch (this.metricType) {
case MetricType.CPU: case MetricType.CPU:
@ -111,5 +92,5 @@ export class ClusterStore extends KubeObjectStore<Cluster> {
} }
} }
export const clusterStore = new ClusterStore(); export const clusterOverviewStore = new ClusterOverviewStore();
apiManager.registerStore(clusterStore); apiManager.registerStore(clusterOverviewStore);

View File

@ -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<any>[] = [];
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 (
<TabLayout>
<div className="ClusterOverview">
{!isLoaded ? <Spinner center/> : (
<>
<ClusterMetrics/>
<ClusterPieCharts/>
<ClusterIssues/>
</>
)}
</div>
</TabLayout>
);
}
}

View File

@ -4,7 +4,7 @@ import React from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { t, Trans } from "@lingui/macro"; import { t, Trans } from "@lingui/macro";
import { useLingui } from "@lingui/react"; import { useLingui } from "@lingui/react";
import { clusterStore, MetricNodeRole } from "./cluster.store"; import { clusterOverviewStore, MetricNodeRole } from "./cluster-overview.store";
import { Spinner } from "../spinner"; import { Spinner } from "../spinner";
import { Icon } from "../icon"; import { Icon } from "../icon";
import { nodesStore } from "../+nodes/nodes.store"; import { nodesStore } from "../+nodes/nodes.store";
@ -27,7 +27,7 @@ export const ClusterPieCharts = observer(() => {
}; };
const renderCharts = () => { const renderCharts = () => {
const data = getMetricLastPoints(clusterStore.metrics); const data = getMetricLastPoints(clusterOverviewStore.metrics);
const { memoryUsage, memoryRequests, memoryCapacity, memoryLimits } = data; const { memoryUsage, memoryRequests, memoryCapacity, memoryLimits } = data;
const { cpuUsage, cpuRequests, cpuCapacity, cpuLimits } = data; const { cpuUsage, cpuRequests, cpuCapacity, cpuLimits } = data;
const { podUsage, podCapacity } = data; const { podUsage, podCapacity } = data;
@ -173,7 +173,7 @@ export const ClusterPieCharts = observer(() => {
const renderContent = () => { const renderContent = () => {
const { masterNodes, workerNodes } = nodesStore; const { masterNodes, workerNodes } = nodesStore;
const { metricNodeRole, metricsLoaded } = clusterStore; const { metricNodeRole, metricsLoaded } = clusterOverviewStore;
const nodes = metricNodeRole === MetricNodeRole.MASTER ? masterNodes : workerNodes; const nodes = metricNodeRole === MetricNodeRole.MASTER ? masterNodes : workerNodes;
if (!nodes.length) { if (!nodes.length) {
@ -192,7 +192,7 @@ export const ClusterPieCharts = observer(() => {
</div> </div>
); );
} }
const { memoryCapacity, cpuCapacity, podCapacity } = getMetricLastPoints(clusterStore.metrics); const { memoryCapacity, cpuCapacity, podCapacity } = getMetricLastPoints(clusterOverviewStore.metrics);
if (!memoryCapacity || !cpuCapacity || !podCapacity) { if (!memoryCapacity || !cpuCapacity || !podCapacity) {
return <ClusterNoMetrics className="empty"/>; return <ClusterNoMetrics className="empty"/>;

View File

@ -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 (
<TabLayout>
<div className="Cluster">
{!isLoaded && <Spinner center/>}
{isLoaded && (
<>
<ClusterMetrics/>
<ClusterPieCharts/>
<ClusterIssues className={cssNames({ wide: isElectron })}/>
</>
)}
</div>
</TabLayout>
);
}
}

View File

@ -133,7 +133,7 @@ export class HpaDetails extends React.Component<Props> {
kubeObjectDetailRegistry.add({ kubeObjectDetailRegistry.add({
kind: "HorizontalPodAutoscaler", kind: "HorizontalPodAutoscaler",
apiVersions: ["autoscaling/v1"], apiVersions: ["autoscaling/v2beta1"],
components: { components: {
Details: (props) => <HpaDetails {...props} /> Details: (props) => <HpaDetails {...props} />
} }
@ -141,7 +141,7 @@ kubeObjectDetailRegistry.add({
kubeObjectDetailRegistry.add({ kubeObjectDetailRegistry.add({
kind: "HorizontalPodAutoscaler", kind: "HorizontalPodAutoscaler",
apiVersions: ["autoscaling/v1"], apiVersions: ["autoscaling/v2beta1"],
priority: 5, priority: 5,
components: { components: {
Details: (props) => <KubeEventDetails {...props} /> Details: (props) => <KubeEventDetails {...props} />

View File

@ -93,7 +93,7 @@ export class CrdResources extends React.Component<Props> {
isNamespaced && crdInstance.getNs(), isNamespaced && crdInstance.getNs(),
...extraColumns.map(column => ({ ...extraColumns.map(column => ({
renderBoolean: true, renderBoolean: true,
children: jsonPath.value(crdInstance, column.jsonPath.slice(1)), children: JSON.stringify(jsonPath.value(crdInstance, column.jsonPath.slice(1))),
})), })),
crdInstance.getAge(), crdInstance.getAge(),
]} ]}

View File

@ -26,9 +26,11 @@ export const NodeCharts = observer(() => {
const [ const [
memoryUsage, memoryUsage,
memoryRequests, memoryRequests,
_memoryLimits, // eslint-disable-line unused-imports/no-unused-vars-ts
memoryCapacity, memoryCapacity,
cpuUsage, cpuUsage,
cpuRequests, cpuRequests,
_cpuLimits, // eslint-disable-line unused-imports/no-unused-vars-ts
cpuCapacity, cpuCapacity,
podUsage, podUsage,
podCapacity, podCapacity,

View File

@ -90,14 +90,14 @@ export class CronJobDetails extends React.Component<Props> {
kubeObjectDetailRegistry.add({ kubeObjectDetailRegistry.add({
kind: "CronJob", kind: "CronJob",
apiVersions: ["batch/v1"], apiVersions: ["batch/v1beta1"],
components: { components: {
Details: (props) => <CronJobDetails {...props} /> Details: (props) => <CronJobDetails {...props} />
} }
}); });
kubeObjectDetailRegistry.add({ kubeObjectDetailRegistry.add({
kind: "CronJob", kind: "CronJob",
apiVersions: ["batch/v1"], apiVersions: ["batch/v1beta1"],
priority: 5, priority: 5,
components: { components: {
Details: (props) => <KubeEventDetails {...props} /> Details: (props) => <KubeEventDetails {...props} />

View File

@ -128,33 +128,45 @@ describe("<DeploymentScaleDialog />", () => {
const initReplicas = 1; const initReplicas = 1;
deploymentApi.getReplicas = jest.fn().mockImplementationOnce(async () => initReplicas); deploymentApi.getReplicas = jest.fn().mockImplementationOnce(async () => initReplicas);
const { getByTestId } = render(<DeploymentScaleDialog />); const component = render(<DeploymentScaleDialog />);
DeploymentScaleDialog.open(dummyDeployment); DeploymentScaleDialog.open(dummyDeployment);
await waitFor(async () => { await waitFor(async () => {
const desiredScale = await getByTestId("desired-scale"); expect(await component.getByTestId("desired-scale")).toHaveTextContent(`${initReplicas}`);
expect(await component.getByTestId("current-scale")).toHaveTextContent(`${initReplicas}`);
expect(desiredScale).toHaveTextContent(`${initReplicas}`); expect((await component.baseElement.querySelector("input").value)).toBe(`${initReplicas}`);
}); });
const up = await getByTestId("desired-replicas-up"); const up = await component.getByTestId("desired-replicas-up");
const down = await getByTestId("desired-replicas-down"); const down = await component.getByTestId("desired-replicas-down");
fireEvent.click(up); fireEvent.click(up);
expect(await getByTestId("desired-scale")).toHaveTextContent(`${initReplicas + 1}`); expect(await component.getByTestId("desired-scale")).toHaveTextContent(`${initReplicas + 1}`);
fireEvent.click(down); expect(await component.getByTestId("current-scale")).toHaveTextContent(`${initReplicas}`);
expect(await getByTestId("desired-scale")).toHaveTextContent("1"); expect((await component.baseElement.querySelector("input").value)).toBe(`${initReplicas + 1}`);
// edge case, desiredScale must > 0
fireEvent.click(down); fireEvent.click(down);
fireEvent.click(down); expect(await component.getByTestId("desired-scale")).toHaveTextContent(`${initReplicas}`);
expect(await getByTestId("desired-scale")).toHaveTextContent("1"); expect(await component.getByTestId("current-scale")).toHaveTextContent(`${initReplicas}`);
const times = 120; 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++) { for (let i = 0; i < times; i++) {
fireEvent.click(up); 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");
}); });
}); });

View File

@ -91,7 +91,7 @@ export class DeploymentScaleDialog extends Component<Props> {
}; };
desiredReplicasDown = () => { desiredReplicasDown = () => {
this.desiredReplicas > 1 && this.desiredReplicas--; this.desiredReplicas > 0 && this.desiredReplicas--;
}; };
renderContents() { renderContents() {
@ -124,7 +124,7 @@ export class DeploymentScaleDialog extends Component<Props> {
</div> </div>
</div> </div>
{warning && {warning &&
<div className="warning"> <div className="warning" data-testid="warning">
<Icon material="warning"/> <Icon material="warning"/>
<Trans>High number of replicas may cause cluster performance issues</Trans> <Trans>High number of replicas may cause cluster performance issues</Trans>
</div> </div>

View File

@ -57,6 +57,7 @@ export class PodDetailsContainer extends React.Component<Props> {
const ready = status ? status.ready : ""; const ready = status ? status.ready : "";
const liveness = pod.getLivenessProbe(container); const liveness = pod.getLivenessProbe(container);
const readiness = pod.getReadinessProbe(container); const readiness = pod.getReadinessProbe(container);
const startup = pod.getStartupProbe(container);
const isInitContainer = !!pod.getInitContainers().find(c => c.name == name); const isInitContainer = !!pod.getInitContainers().find(c => c.name == name);
const metricTabs = [ const metricTabs = [
<Trans key="cpu">CPU</Trans>, <Trans key="cpu">CPU</Trans>,
@ -140,6 +141,15 @@ export class PodDetailsContainer extends React.Component<Props> {
} }
</DrawerItem> </DrawerItem>
} }
{startup.length > 0 &&
<DrawerItem name={<Trans>Startup</Trans>} labelsOnly>
{
startup.map((value, index) => (
<Badge key={index} label={value}/>
))
}
</DrawerItem>
}
{command && {command &&
<DrawerItem name={<Trans>Command</Trans>}> <DrawerItem name={<Trans>Command</Trans>}>
{command.join(" ")} {command.join(" ")}

View File

@ -16,7 +16,7 @@ import { Workloads, workloadsRoute, workloadsURL } from "./+workloads";
import { Namespaces, namespacesRoute } from "./+namespaces"; import { Namespaces, namespacesRoute } from "./+namespaces";
import { Network, networkRoute } from "./+network"; import { Network, networkRoute } from "./+network";
import { Storage, storageRoute } from "./+storage"; import { Storage, storageRoute } from "./+storage";
import { Cluster } from "./+cluster/cluster"; import { ClusterOverview } from "./+cluster/cluster-overview";
import { Config, configRoute } from "./+config"; import { Config, configRoute } from "./+config";
import { Events } from "./+events/events"; import { Events } from "./+events/events";
import { eventRoute } from "./+events"; import { eventRoute } from "./+events";
@ -180,7 +180,7 @@ export class App extends React.Component {
<ErrorBoundary> <ErrorBoundary>
<MainLayout> <MainLayout>
<Switch> <Switch>
<Route component={Cluster} {...clusterRoute}/> <Route component={ClusterOverview} {...clusterRoute}/>
<Route component={Nodes} {...nodesRoute}/> <Route component={Nodes} {...nodesRoute}/>
<Route component={Workloads} {...workloadsRoute}/> <Route component={Workloads} {...workloadsRoute}/>
<Route component={Config} {...configRoute}/> <Route component={Config} {...configRoute}/>

View File

@ -41,9 +41,9 @@ describe("<MainLayoutHeader />", () => {
expect(mockBroadcastIpc).toBeCalledWith("renderer:navigate", "/cluster/foo/settings"); expect(mockBroadcastIpc).toBeCalledWith("renderer:navigate", "/cluster/foo/settings");
}); });
it("renders cluster name", async () => { it("renders cluster name", () => {
const { getByText } = render(<MainLayoutHeader cluster={cluster} />); const { getByText } = render(<MainLayoutHeader cluster={cluster} />);
expect(await getByText("minikube")).toBeTruthy(); expect(getByText("minikube")).toBeInTheDocument();
}); });
}); });

View File

@ -0,0 +1,7 @@
import React from "react";
export const SidebarContext = React.createContext<SidebarContextValue>({ pinned: false });
export type SidebarContextValue = {
pinned: boolean;
};

View File

@ -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;
}
}
}
}
}

View File

@ -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<string, boolean>(navItemStorage.get());
reaction(() => [...navItemState], (value) => navItemStorage.set(value));
@observer
export class SidebarNavItem extends React.Component<SidebarNavItemProps> {
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 (
<div className={cssNames("SidebarNavItem", className)} data-test-id={id}>
<div className={cssNames("nav-item", { active: isActive })} onClick={this.toggleSubMenu}>
{icon}
<span className="link-text">{text}</span>
<Icon className="expand-icon" material={this.isExpanded ? "keyboard_arrow_up" : "keyboard_arrow_down"}/>
</div>
<ul className={cssNames("sub-menu", { active: isActive })}>
{subMenus.map(({ title, url }) => (
<NavLink key={url} to={url} className={cssNames({ visible: this.isExpanded })}>
{title}
</NavLink>
))}
{React.Children.toArray(children).map((child: React.ReactElement<any>) => {
return React.cloneElement(child, {
className: cssNames(child.props.className, { visible: this.isExpanded }),
});
})}
</ul>
</div>
);
}
return (
<NavLink className={cssNames("SidebarNavItem", className)} to={url} isActive={() => isActive}>
{icon}
<span className="link-text">{text}</span>
</NavLink>
);
}
}

View File

@ -1,22 +1,7 @@
.Sidebar { .Sidebar {
$iconSize: 24px; $iconSize: 24px;
$activeBgc: $lensBlue;
$activeTextColor: $sidebarActiveColor;
$itemSpacing: floor($unit / 2.6) floor($unit / 1.6); $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 { &.pinned {
.sidebar-nav { .sidebar-nav {
overflow: auto; overflow: auto;
@ -77,13 +62,16 @@
} }
> a { > a {
@include activeLinkState;
display: flex; display: flex;
align-items: center; align-items: center;
text-decoration: none; text-decoration: none;
border: none; border: none;
padding: $itemSpacing; padding: $itemSpacing;
&.active, &:hover {
background: $lensBlue;
color: $sidebarActiveColor;
}
} }
hr { 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 { .loading {
padding: $padding; padding: $padding;
text-align: center; text-align: center;

View File

@ -1,12 +1,11 @@
import type { TabLayoutRoute } from "./tab-layout";
import "./sidebar.scss"; import "./sidebar.scss";
import React from "react"; import React from "react";
import { computed, observable, reaction } from "mobx"; import type { TabLayoutRoute } from "./tab-layout";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { NavLink } from "react-router-dom"; import { NavLink } from "react-router-dom";
import { Trans } from "@lingui/macro"; import { Trans } from "@lingui/macro";
import { createStorage, cssNames } from "../../utils"; import { cssNames } from "../../utils";
import { Icon } from "../icon"; import { Icon } from "../icon";
import { workloadsRoute, workloadsURL } from "../+workloads/workloads.route"; import { workloadsRoute, workloadsURL } from "../+workloads/workloads.route";
import { namespacesRoute, namespacesURL } from "../+namespaces/namespaces.route"; import { namespacesRoute, namespacesURL } from "../+namespaces/namespaces.route";
@ -30,12 +29,8 @@ import { isActiveRoute } from "../../navigation";
import { isAllowedResource } from "../../../common/rbac"; import { isAllowedResource } from "../../../common/rbac";
import { Spinner } from "../spinner"; import { Spinner } from "../spinner";
import { ClusterPageMenuRegistration, clusterPageMenuRegistry, clusterPageRegistry, getExtensionPageUrl } from "../../../extensions/registries"; import { ClusterPageMenuRegistration, clusterPageMenuRegistry, clusterPageRegistry, getExtensionPageUrl } from "../../../extensions/registries";
import { SidebarNavItem } from "./sidebar-nav-item";
const SidebarContext = React.createContext<SidebarContextValue>({ pinned: false }); import { SidebarContext } from "./sidebar-context";
type SidebarContextValue = {
pinned: boolean;
};
interface Props { interface Props {
className?: string; className?: string;
@ -69,6 +64,7 @@ export class Sidebar extends React.Component<Props> {
return ( return (
<SidebarNavItem <SidebarNavItem
key={group} key={group}
id={`crd-${group}`}
className="sub-menu-parent" className="sub-menu-parent"
url={crdURL({ query: { groups: group } })} url={crdURL({ query: { groups: group } })}
subMenus={submenus} subMenus={submenus}
@ -104,25 +100,29 @@ export class Sidebar extends React.Component<Props> {
} }
renderRegisteredMenus() { renderRegisteredMenus() {
return clusterPageMenuRegistry.getRootItems().map((menuItem) => { return clusterPageMenuRegistry.getRootItems().map((menuItem, index) => {
const registeredPage = clusterPageRegistry.getByPageTarget(menuItem.target); const registeredPage = clusterPageRegistry.getByPageMenuTarget(menuItem.target);
if (!registeredPage) {
return;
}
let pageUrl = getExtensionPageUrl(menuItem.target);
let isActive = isActiveRoute(registeredPage.url);
const tabRoutes = this.getTabLayoutRoutes(menuItem); 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; pageUrl = tabRoutes[0].url;
isActive = isActiveRoute(tabRoutes.map((tab) => tab.routePath)); isActive = isActiveRoute(tabRoutes.map((tab) => tab.routePath));
} else {
return;
} }
return ( return (
<SidebarNavItem <SidebarNavItem
key={pageUrl} key={id}
id={id}
url={pageUrl} url={pageUrl}
text={menuItem.title} text={menuItem.title}
icon={<menuItem.components.Icon/>} icon={<menuItem.components.Icon/>}
@ -155,7 +155,7 @@ export class Sidebar extends React.Component<Props> {
</div> </div>
<div className="sidebar-nav flex column box grow-fixed"> <div className="sidebar-nav flex column box grow-fixed">
<SidebarNavItem <SidebarNavItem
testId="cluster" id="cluster"
isActive={isActiveRoute(clusterRoute)} isActive={isActiveRoute(clusterRoute)}
isHidden={!isAllowedResource("nodes")} isHidden={!isAllowedResource("nodes")}
url={clusterURL()} url={clusterURL()}
@ -163,7 +163,7 @@ export class Sidebar extends React.Component<Props> {
icon={<Icon svg="kube"/>} icon={<Icon svg="kube"/>}
/> />
<SidebarNavItem <SidebarNavItem
testId="nodes" id="nodes"
isActive={isActiveRoute(nodesRoute)} isActive={isActiveRoute(nodesRoute)}
isHidden={!isAllowedResource("nodes")} isHidden={!isAllowedResource("nodes")}
url={nodesURL()} url={nodesURL()}
@ -171,7 +171,7 @@ export class Sidebar extends React.Component<Props> {
icon={<Icon svg="nodes"/>} icon={<Icon svg="nodes"/>}
/> />
<SidebarNavItem <SidebarNavItem
testId="workloads" id="workloads"
isActive={isActiveRoute(workloadsRoute)} isActive={isActiveRoute(workloadsRoute)}
isHidden={Workloads.tabRoutes.length == 0} isHidden={Workloads.tabRoutes.length == 0}
url={workloadsURL({ query })} url={workloadsURL({ query })}
@ -180,7 +180,7 @@ export class Sidebar extends React.Component<Props> {
icon={<Icon svg="workloads"/>} icon={<Icon svg="workloads"/>}
/> />
<SidebarNavItem <SidebarNavItem
testId="config" id="config"
isActive={isActiveRoute(configRoute)} isActive={isActiveRoute(configRoute)}
isHidden={Config.tabRoutes.length == 0} isHidden={Config.tabRoutes.length == 0}
url={configURL({ query })} url={configURL({ query })}
@ -189,7 +189,7 @@ export class Sidebar extends React.Component<Props> {
icon={<Icon material="list"/>} icon={<Icon material="list"/>}
/> />
<SidebarNavItem <SidebarNavItem
testId="networks" id="networks"
isActive={isActiveRoute(networkRoute)} isActive={isActiveRoute(networkRoute)}
isHidden={Network.tabRoutes.length == 0} isHidden={Network.tabRoutes.length == 0}
url={networkURL({ query })} url={networkURL({ query })}
@ -198,7 +198,7 @@ export class Sidebar extends React.Component<Props> {
icon={<Icon material="device_hub"/>} icon={<Icon material="device_hub"/>}
/> />
<SidebarNavItem <SidebarNavItem
testId="storage" id="storage"
isActive={isActiveRoute(storageRoute)} isActive={isActiveRoute(storageRoute)}
isHidden={Storage.tabRoutes.length == 0} isHidden={Storage.tabRoutes.length == 0}
url={storageURL({ query })} url={storageURL({ query })}
@ -207,7 +207,7 @@ export class Sidebar extends React.Component<Props> {
text={<Trans>Storage</Trans>} text={<Trans>Storage</Trans>}
/> />
<SidebarNavItem <SidebarNavItem
testId="namespaces" id="namespaces"
isActive={isActiveRoute(namespacesRoute)} isActive={isActiveRoute(namespacesRoute)}
isHidden={!isAllowedResource("namespaces")} isHidden={!isAllowedResource("namespaces")}
url={namespacesURL()} url={namespacesURL()}
@ -215,7 +215,7 @@ export class Sidebar extends React.Component<Props> {
text={<Trans>Namespaces</Trans>} text={<Trans>Namespaces</Trans>}
/> />
<SidebarNavItem <SidebarNavItem
testId="events" id="events"
isActive={isActiveRoute(eventRoute)} isActive={isActiveRoute(eventRoute)}
isHidden={!isAllowedResource("events")} isHidden={!isAllowedResource("events")}
url={eventsURL({ query })} url={eventsURL({ query })}
@ -223,7 +223,7 @@ export class Sidebar extends React.Component<Props> {
text={<Trans>Events</Trans>} text={<Trans>Events</Trans>}
/> />
<SidebarNavItem <SidebarNavItem
testId="apps" id="apps"
isActive={isActiveRoute(appsRoute)} isActive={isActiveRoute(appsRoute)}
url={appsURL({ query })} url={appsURL({ query })}
subMenus={Apps.tabRoutes} subMenus={Apps.tabRoutes}
@ -231,7 +231,7 @@ export class Sidebar extends React.Component<Props> {
text={<Trans>Apps</Trans>} text={<Trans>Apps</Trans>}
/> />
<SidebarNavItem <SidebarNavItem
testId="users" id="users"
isActive={isActiveRoute(usersManagementRoute)} isActive={isActiveRoute(usersManagementRoute)}
url={usersManagementURL({ query })} url={usersManagementURL({ query })}
subMenus={UserManagement.tabRoutes} subMenus={UserManagement.tabRoutes}
@ -239,7 +239,7 @@ export class Sidebar extends React.Component<Props> {
text={<Trans>Access Control</Trans>} text={<Trans>Access Control</Trans>}
/> />
<SidebarNavItem <SidebarNavItem
testId="custom-resources" id="custom-resources"
isActive={isActiveRoute(crdRoute)} isActive={isActiveRoute(crdRoute)}
isHidden={!isAllowedResource("customresourcedefinitions")} isHidden={!isAllowedResource("customresourcedefinitions")}
url={crdURL()} url={crdURL()}
@ -256,79 +256,3 @@ export class Sidebar extends React.Component<Props> {
); );
} }
} }
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<string, boolean>(navItemStorage.get());
reaction(() => [...navItemState], (value) => navItemStorage.set(value));
@observer
class SidebarNavItem extends React.Component<SidebarNavItemProps> {
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 (
<div className={cssNames("SidebarNavItem", className)} data-test-id={testId}>
<div className={cssNames("nav-item", { active: isActive })} onClick={this.toggleSubMenu}>
{icon}
<span className="link-text">{text}</span>
<Icon className="expand-icon" material={this.isExpanded ? "keyboard_arrow_up" : "keyboard_arrow_down"}/>
</div>
<ul className={cssNames("sub-menu", { active: isActive })}>
{subMenus.map(({ title, url }) => (
<NavLink key={url} to={url} className={cssNames({ visible: this.isExpanded })}>
{title}
</NavLink>
))}
{React.Children.toArray(children).map((child: React.ReactElement<any>) => {
return React.cloneElement(child, {
className: cssNames(child.props.className, { visible: this.isExpanded }),
});
})}
</ul>
</div>
);
}
return (
<NavLink className={cssNames("SidebarNavItem", className)} to={url} isActive={() => isActive}>
{icon}
<span className="link-text">{text}</span>
</NavLink>
);
}
}

View File

@ -195,10 +195,9 @@ export abstract class KubeObjectStore<T extends KubeObject = any> extends ItemSt
const items = this.items.toJS(); const items = this.items.toJS();
for (const {type, object} of this.eventsBuffer.clear()) { for (const {type, object} of this.eventsBuffer.clear()) {
const { uid, selfLink } = object.metadata; const index = items.findIndex(item => item.getId() === object.metadata?.uid);
const index = items.findIndex(item => item.getId() === uid);
const item = items[index]; const item = items[index];
const api = apiManager.getApi(selfLink); const api = apiManager.getApiByKind(object.kind, object.apiVersion);
switch (type) { switch (type) {
case "ADDED": case "ADDED":

View File

@ -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! 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 - Extension API
- Improved pod logs - 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 - Replace cluster warning event polling with watches
- Detect more Kubernetes distributions - Detect more Kubernetes distributions
- Performance fix when cluster has lots of namespaces - 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 pod usage metrics on Kubernetes >=1.19
- Fix proxy upgrade socket timeouts - Fix proxy upgrade socket timeouts
- Fix UI staleness after network issues - Fix UI staleness after network issues