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:
commit
69e5e5eaab
57
.azure-pipelines-k8s-matrix.yml
Normal file
57
.azure-pipelines-k8s-matrix.yml
Normal 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)
|
||||
@ -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:
|
||||
|
||||
2
Makefile
2
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:
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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\<user>\.k8slens\extensions (windows)? Yes
|
||||
# ? symlink created extension folder to ~/.k8slens/extensions (mac/linux) or :Users\<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
|
||||
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**:
|
||||
|
||||

|
||||
|
||||
## 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.
|
||||
|
||||

|
||||
|
||||
## 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).
|
||||
|
||||
@ -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.
|
||||
`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.
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -74,6 +74,7 @@ export class Tracker extends Util.Singleton {
|
||||
}
|
||||
|
||||
reportPeriodically() {
|
||||
this.reportData();
|
||||
this.reportInterval = setInterval(() => {
|
||||
this.reportData();
|
||||
}, 60 * 60 * 1000); // report every 1h
|
||||
|
||||
@ -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"
|
||||
}]
|
||||
},
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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/<username>/Library/Application Support/LensDev/extensions
|
||||
await fs.remove(this.inTreeTargetPath);
|
||||
|
||||
// Create folder e.g. /Users/<username>/Library/Application Support/LensDev/extensions
|
||||
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);
|
||||
|
||||
// Set bundled folder path to e.g. /Users/<username>/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<Map<LensExtensionId, InstalledExtension>> {
|
||||
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();
|
||||
|
||||
@ -33,16 +33,26 @@ export class ExtensionInstaller {
|
||||
installDependencies(): Promise<void> {
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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<FSProvisionModel> {
|
||||
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);
|
||||
}
|
||||
|
||||
@ -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<WindowManager>(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" });
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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()
|
||||
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;
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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> {
|
||||
static parseApi = parseKubeApi;
|
||||
|
||||
@ -260,7 +272,11 @@ export class KubeApi<T extends KubeObject = any> {
|
||||
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<T extends KubeObject = any> {
|
||||
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.
|
||||
|
||||
@ -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<KubeJsonApiData>) => {
|
||||
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);
|
||||
}
|
||||
|
||||
@ -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%;
|
||||
|
||||
@ -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}
|
||||
>
|
||||
<Radio label={<Trans>Master</Trans>} value={MetricNodeRole.MASTER}/>
|
||||
<Radio label={<Trans>Worker</Trans>} 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}
|
||||
>
|
||||
<Radio label={<Trans>CPU</Trans>} value={MetricType.CPU}/>
|
||||
<Radio label={<Trans>Memory</Trans>} value={MetricType.MEMORY}/>
|
||||
|
||||
@ -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 <Spinner center/>;
|
||||
}
|
||||
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
.Cluster {
|
||||
.ClusterOverview {
|
||||
$gridGap: $margin * 2;
|
||||
|
||||
position: relative;
|
||||
@ -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<Cluster> {
|
||||
export class ClusterOverviewStore extends KubeObjectStore<Cluster> {
|
||||
api = clusterApi;
|
||||
|
||||
@observable metrics: Partial<IClusterMetrics> = {};
|
||||
@observable liveMetrics: Partial<IClusterMetrics> = {};
|
||||
@observable metricsLoaded = false;
|
||||
@observable metricType: MetricType;
|
||||
@observable metricNodeRole: MetricNodeRole;
|
||||
@ -46,9 +45,8 @@ export class ClusterStore extends KubeObjectStore<Cluster> {
|
||||
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<Cluster> {
|
||||
});
|
||||
}
|
||||
|
||||
@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<IClusterMetrics>): [number, string][] {
|
||||
switch (this.metricType) {
|
||||
case MetricType.CPU:
|
||||
@ -111,5 +92,5 @@ export class ClusterStore extends KubeObjectStore<Cluster> {
|
||||
}
|
||||
}
|
||||
|
||||
export const clusterStore = new ClusterStore();
|
||||
apiManager.registerStore(clusterStore);
|
||||
export const clusterOverviewStore = new ClusterOverviewStore();
|
||||
apiManager.registerStore(clusterOverviewStore);
|
||||
79
src/renderer/components/+cluster/cluster-overview.tsx
Normal file
79
src/renderer/components/+cluster/cluster-overview.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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(() => {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const { memoryCapacity, cpuCapacity, podCapacity } = getMetricLastPoints(clusterStore.metrics);
|
||||
const { memoryCapacity, cpuCapacity, podCapacity } = getMetricLastPoints(clusterOverviewStore.metrics);
|
||||
|
||||
if (!memoryCapacity || !cpuCapacity || !podCapacity) {
|
||||
return <ClusterNoMetrics className="empty"/>;
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -133,7 +133,7 @@ export class HpaDetails extends React.Component<Props> {
|
||||
|
||||
kubeObjectDetailRegistry.add({
|
||||
kind: "HorizontalPodAutoscaler",
|
||||
apiVersions: ["autoscaling/v1"],
|
||||
apiVersions: ["autoscaling/v2beta1"],
|
||||
components: {
|
||||
Details: (props) => <HpaDetails {...props} />
|
||||
}
|
||||
@ -141,7 +141,7 @@ kubeObjectDetailRegistry.add({
|
||||
|
||||
kubeObjectDetailRegistry.add({
|
||||
kind: "HorizontalPodAutoscaler",
|
||||
apiVersions: ["autoscaling/v1"],
|
||||
apiVersions: ["autoscaling/v2beta1"],
|
||||
priority: 5,
|
||||
components: {
|
||||
Details: (props) => <KubeEventDetails {...props} />
|
||||
|
||||
@ -93,7 +93,7 @@ export class CrdResources extends React.Component<Props> {
|
||||
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(),
|
||||
]}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -90,14 +90,14 @@ export class CronJobDetails extends React.Component<Props> {
|
||||
|
||||
kubeObjectDetailRegistry.add({
|
||||
kind: "CronJob",
|
||||
apiVersions: ["batch/v1"],
|
||||
apiVersions: ["batch/v1beta1"],
|
||||
components: {
|
||||
Details: (props) => <CronJobDetails {...props} />
|
||||
}
|
||||
});
|
||||
kubeObjectDetailRegistry.add({
|
||||
kind: "CronJob",
|
||||
apiVersions: ["batch/v1"],
|
||||
apiVersions: ["batch/v1beta1"],
|
||||
priority: 5,
|
||||
components: {
|
||||
Details: (props) => <KubeEventDetails {...props} />
|
||||
|
||||
@ -128,33 +128,45 @@ describe("<DeploymentScaleDialog />", () => {
|
||||
const initReplicas = 1;
|
||||
|
||||
deploymentApi.getReplicas = jest.fn().mockImplementationOnce(async () => initReplicas);
|
||||
const { getByTestId } = render(<DeploymentScaleDialog />);
|
||||
const component = render(<DeploymentScaleDialog />);
|
||||
|
||||
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");
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
|
||||
@ -91,7 +91,7 @@ export class DeploymentScaleDialog extends Component<Props> {
|
||||
};
|
||||
|
||||
desiredReplicasDown = () => {
|
||||
this.desiredReplicas > 1 && this.desiredReplicas--;
|
||||
this.desiredReplicas > 0 && this.desiredReplicas--;
|
||||
};
|
||||
|
||||
renderContents() {
|
||||
@ -124,7 +124,7 @@ export class DeploymentScaleDialog extends Component<Props> {
|
||||
</div>
|
||||
</div>
|
||||
{warning &&
|
||||
<div className="warning">
|
||||
<div className="warning" data-testid="warning">
|
||||
<Icon material="warning"/>
|
||||
<Trans>High number of replicas may cause cluster performance issues</Trans>
|
||||
</div>
|
||||
|
||||
@ -57,6 +57,7 @@ export class PodDetailsContainer extends React.Component<Props> {
|
||||
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 = [
|
||||
<Trans key="cpu">CPU</Trans>,
|
||||
@ -140,6 +141,15 @@ export class PodDetailsContainer extends React.Component<Props> {
|
||||
}
|
||||
</DrawerItem>
|
||||
}
|
||||
{startup.length > 0 &&
|
||||
<DrawerItem name={<Trans>Startup</Trans>} labelsOnly>
|
||||
{
|
||||
startup.map((value, index) => (
|
||||
<Badge key={index} label={value}/>
|
||||
))
|
||||
}
|
||||
</DrawerItem>
|
||||
}
|
||||
{command &&
|
||||
<DrawerItem name={<Trans>Command</Trans>}>
|
||||
{command.join(" ")}
|
||||
|
||||
@ -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 {
|
||||
<ErrorBoundary>
|
||||
<MainLayout>
|
||||
<Switch>
|
||||
<Route component={Cluster} {...clusterRoute}/>
|
||||
<Route component={ClusterOverview} {...clusterRoute}/>
|
||||
<Route component={Nodes} {...nodesRoute}/>
|
||||
<Route component={Workloads} {...workloadsRoute}/>
|
||||
<Route component={Config} {...configRoute}/>
|
||||
|
||||
@ -41,9 +41,9 @@ describe("<MainLayoutHeader />", () => {
|
||||
expect(mockBroadcastIpc).toBeCalledWith("renderer:navigate", "/cluster/foo/settings");
|
||||
});
|
||||
|
||||
it("renders cluster name", async () => {
|
||||
it("renders cluster name", () => {
|
||||
const { getByText } = render(<MainLayoutHeader cluster={cluster} />);
|
||||
|
||||
expect(await getByText("minikube")).toBeTruthy();
|
||||
expect(getByText("minikube")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
7
src/renderer/components/layout/sidebar-context.ts
Normal file
7
src/renderer/components/layout/sidebar-context.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import React from "react";
|
||||
|
||||
export const SidebarContext = React.createContext<SidebarContextValue>({ pinned: false });
|
||||
|
||||
export type SidebarContextValue = {
|
||||
pinned: boolean;
|
||||
};
|
||||
76
src/renderer/components/layout/sidebar-nav-item.scss
Normal file
76
src/renderer/components/layout/sidebar-nav-item.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
83
src/renderer/components/layout/sidebar-nav-item.tsx
Normal file
83
src/renderer/components/layout/sidebar-nav-item.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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<SidebarContextValue>({ 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<Props> {
|
||||
return (
|
||||
<SidebarNavItem
|
||||
key={group}
|
||||
id={`crd-${group}`}
|
||||
className="sub-menu-parent"
|
||||
url={crdURL({ query: { groups: group } })}
|
||||
subMenus={submenus}
|
||||
@ -104,25 +100,29 @@ export class Sidebar extends React.Component<Props> {
|
||||
}
|
||||
|
||||
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 (
|
||||
<SidebarNavItem
|
||||
key={pageUrl}
|
||||
key={id}
|
||||
id={id}
|
||||
url={pageUrl}
|
||||
text={menuItem.title}
|
||||
icon={<menuItem.components.Icon/>}
|
||||
@ -155,7 +155,7 @@ export class Sidebar extends React.Component<Props> {
|
||||
</div>
|
||||
<div className="sidebar-nav flex column box grow-fixed">
|
||||
<SidebarNavItem
|
||||
testId="cluster"
|
||||
id="cluster"
|
||||
isActive={isActiveRoute(clusterRoute)}
|
||||
isHidden={!isAllowedResource("nodes")}
|
||||
url={clusterURL()}
|
||||
@ -163,7 +163,7 @@ export class Sidebar extends React.Component<Props> {
|
||||
icon={<Icon svg="kube"/>}
|
||||
/>
|
||||
<SidebarNavItem
|
||||
testId="nodes"
|
||||
id="nodes"
|
||||
isActive={isActiveRoute(nodesRoute)}
|
||||
isHidden={!isAllowedResource("nodes")}
|
||||
url={nodesURL()}
|
||||
@ -171,7 +171,7 @@ export class Sidebar extends React.Component<Props> {
|
||||
icon={<Icon svg="nodes"/>}
|
||||
/>
|
||||
<SidebarNavItem
|
||||
testId="workloads"
|
||||
id="workloads"
|
||||
isActive={isActiveRoute(workloadsRoute)}
|
||||
isHidden={Workloads.tabRoutes.length == 0}
|
||||
url={workloadsURL({ query })}
|
||||
@ -180,7 +180,7 @@ export class Sidebar extends React.Component<Props> {
|
||||
icon={<Icon svg="workloads"/>}
|
||||
/>
|
||||
<SidebarNavItem
|
||||
testId="config"
|
||||
id="config"
|
||||
isActive={isActiveRoute(configRoute)}
|
||||
isHidden={Config.tabRoutes.length == 0}
|
||||
url={configURL({ query })}
|
||||
@ -189,7 +189,7 @@ export class Sidebar extends React.Component<Props> {
|
||||
icon={<Icon material="list"/>}
|
||||
/>
|
||||
<SidebarNavItem
|
||||
testId="networks"
|
||||
id="networks"
|
||||
isActive={isActiveRoute(networkRoute)}
|
||||
isHidden={Network.tabRoutes.length == 0}
|
||||
url={networkURL({ query })}
|
||||
@ -198,7 +198,7 @@ export class Sidebar extends React.Component<Props> {
|
||||
icon={<Icon material="device_hub"/>}
|
||||
/>
|
||||
<SidebarNavItem
|
||||
testId="storage"
|
||||
id="storage"
|
||||
isActive={isActiveRoute(storageRoute)}
|
||||
isHidden={Storage.tabRoutes.length == 0}
|
||||
url={storageURL({ query })}
|
||||
@ -207,7 +207,7 @@ export class Sidebar extends React.Component<Props> {
|
||||
text={<Trans>Storage</Trans>}
|
||||
/>
|
||||
<SidebarNavItem
|
||||
testId="namespaces"
|
||||
id="namespaces"
|
||||
isActive={isActiveRoute(namespacesRoute)}
|
||||
isHidden={!isAllowedResource("namespaces")}
|
||||
url={namespacesURL()}
|
||||
@ -215,7 +215,7 @@ export class Sidebar extends React.Component<Props> {
|
||||
text={<Trans>Namespaces</Trans>}
|
||||
/>
|
||||
<SidebarNavItem
|
||||
testId="events"
|
||||
id="events"
|
||||
isActive={isActiveRoute(eventRoute)}
|
||||
isHidden={!isAllowedResource("events")}
|
||||
url={eventsURL({ query })}
|
||||
@ -223,7 +223,7 @@ export class Sidebar extends React.Component<Props> {
|
||||
text={<Trans>Events</Trans>}
|
||||
/>
|
||||
<SidebarNavItem
|
||||
testId="apps"
|
||||
id="apps"
|
||||
isActive={isActiveRoute(appsRoute)}
|
||||
url={appsURL({ query })}
|
||||
subMenus={Apps.tabRoutes}
|
||||
@ -231,7 +231,7 @@ export class Sidebar extends React.Component<Props> {
|
||||
text={<Trans>Apps</Trans>}
|
||||
/>
|
||||
<SidebarNavItem
|
||||
testId="users"
|
||||
id="users"
|
||||
isActive={isActiveRoute(usersManagementRoute)}
|
||||
url={usersManagementURL({ query })}
|
||||
subMenus={UserManagement.tabRoutes}
|
||||
@ -239,7 +239,7 @@ export class Sidebar extends React.Component<Props> {
|
||||
text={<Trans>Access Control</Trans>}
|
||||
/>
|
||||
<SidebarNavItem
|
||||
testId="custom-resources"
|
||||
id="custom-resources"
|
||||
isActive={isActiveRoute(crdRoute)}
|
||||
isHidden={!isAllowedResource("customresourcedefinitions")}
|
||||
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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -195,10 +195,9 @@ export abstract class KubeObjectStore<T extends KubeObject = any> 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":
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user