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:
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:

View File

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

View File

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

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
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**:
![Hello World](images/hello-world.png)
## Developing the Extension
Try to change `my-first-lens-ext/renderer.tsx` to "Hello Lens!":
Next, you'll try changing the way the new menu item appears in the UI. You'll change it from "Hello World" to "Hello Lens".
Open `my-first-lens-ext/renderer.tsx` and change the value of `title` from `"Hello World"` to `"Hello Lens"`:
```tsx
clusterPageMenus = [
@ -48,18 +54,18 @@ clusterPageMenus = [
]
```
Then, Reload Lens by CMD+R (Mac) / Ctrl+R (Linux/Windows), you should see the menu item text changes:
Reload Lens and you will see that the menu item text has changed to "Hello Lens." To reload Lens, enter `CMD+R` on Mac and `Ctrl+R` on Windows/Linux.
![Hello World](images/hello-lens.png)
## Debugging the Extension
[Testing](../testing-and-publishing/testing.md)
To debug your extension, please see our instructions on [Testing Extensions](../testing-and-publishing/testing.md).
## Next steps
## Next Steps
You can take a closer look at [Common Capabilities](../capabilities/common-capabilities.md) of extension, how to [style](../capabilities/styling.md) the extension. Or the [Extension Anatomy](anatomy.md).
To dive deeper, consider looking at [Common Capabilities](../capabilities/common-capabilities.md), [Styling](../capabilities/styling.md), or [Extension Anatomy](anatomy.md).
You are welcome to raise an [issue](https://github.com/lensapp/generator-lens-ext/issues) for Lens Extension Generator, if you find problems, or have feature requests.
If you find problems with the Lens Extension Generator, or have feature requests, you are welcome to raise an [issue](https://github.com/lensapp/generator-lens-ext/issues). You can find the Lens contribution guidelines [here](../../contributing/README.md).
The source code of the generator is hosted at [Github](https://github.com/lensapp/generator-lens-ext)
The Generator source code is hosted at [Github](https://github.com/lensapp/generator-lens-ext).

View File

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

View File

@ -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",

View File

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

View File

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

View File

@ -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"
}]
},

View File

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

View File

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

View File

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

View File

@ -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": {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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()
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;

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,4 +1,4 @@
.Cluster {
.ClusterOverview {
$gridGap: $margin * 2;
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 { 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);

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 { 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"/>;

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({
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} />

View File

@ -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(),
]}

View File

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

View File

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

View File

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

View File

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

View File

@ -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(" ")}

View File

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

View File

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

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 {
$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;

View File

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

View File

@ -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":

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