mirror of
https://github.com/lensapp/lens.git
synced 2025-05-20 05:10:56 +00:00
Merge branch 'master' into fix/logs-scroll-to-bottom-appearence
This commit is contained in:
commit
7141a5afff
@ -1,6 +1,6 @@
|
||||
# Adding Clusters
|
||||
|
||||
Add clusters by clicking the **Add Cluster** button in the left-side menu.
|
||||
Add clusters by clicking the **Add Cluster** button in the left-side menu.
|
||||
|
||||
1. Click the **Add Cluster** button (indicated with a '+' icon).
|
||||
2. Enter the path to your kubeconfig file. You'll need to have a kubeconfig file for the cluster you want to add. You can either browse for the path from the file system or or enter it directly.
|
||||
@ -13,4 +13,10 @@ Selected [cluster contexts](https://kubernetes.io/docs/concepts/configuration/or
|
||||
|
||||
For more information on kubeconfig see [Kubernetes docs](https://kubernetes.io/docs/concepts/configuration/organize-cluster-access-kubeconfig/).
|
||||
|
||||
To see your currently-enabled config with `kubectl`, enter `kubectl config view --minify --raw` in your terminal.
|
||||
To see your currently-enabled config with `kubectl`, enter `kubectl config view --minify --raw` in your terminal.
|
||||
|
||||
When connecting to a cluster, make sure you have a valid and working kubeconfig for the cluster. Following lists known "gotchas" in some authentication types used in kubeconfig with Lens app.
|
||||
|
||||
## Exec auth plugins
|
||||
|
||||
When using [exec auth](https://kubernetes.io/docs/reference/access-authn-authz/authentication/#configuration) plugins make sure the paths that are used to call any binaries are full paths as Lens app might not be able to call binaries with relative paths. Make also sure that you pass all needed information either as arguments or env variables in the config, Lens app might not have all login shell env variables set automatically.
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 252 KiB After Width: | Height: | Size: 611 KiB |
@ -19,10 +19,11 @@ Each guide or sample will include:
|
||||
| [Stores](stores.md) | |
|
||||
| [Components](components.md) | |
|
||||
| [KubeObjectListLayout](kube-object-list-layout.md) | |
|
||||
| [Working with mobx](working-with-mobx.md) | |
|
||||
|
||||
## Samples
|
||||
|
||||
| Sample | APIs |
|
||||
| ----- | ----- |
|
||||
[helloworld](https://github.com/lensapp/lens-extension-samples/tree/master/helloworld-sample) | LensMainExtension <br> LensRendererExtension <br> Component.Icon <br> Component.IconProps |
|
||||
[minikube](https://github.com/lensapp/lens-extension-samples/tree/master/minikube-sample) | LensMainExtension <br> Store.clusterStore <br> Store.workspaceStore |
|
||||
[minikube](https://github.com/lensapp/lens-extension-samples/tree/master/minikube-sample) | LensMainExtension <br> Store.clusterStore <br> Store.workspaceStore |
|
||||
|
||||
23
docs/extensions/guides/working-with-mobx.md
Normal file
23
docs/extensions/guides/working-with-mobx.md
Normal file
@ -0,0 +1,23 @@
|
||||
# Working with mobx
|
||||
|
||||
## Introduction
|
||||
|
||||
Lens uses `mobx` as its state manager on top of React's state management system.
|
||||
This helps with having a more declarative style of managing state, as opposed to `React`'s native `setState` mechanism.
|
||||
You should already have a basic understanding of how `React` handles state ([read here](https://reactjs.org/docs/faq-state.html) for more information).
|
||||
However, if you do not, here is a quick overview.
|
||||
|
||||
- A `React.Component` is generic over both `Props` and `State` (with default empty object types).
|
||||
- `Props` should be considered read-only from the point of view of the component and is the mechanism for passing in "arguments" to a component.
|
||||
- `State` is a component's internal state and can be read by accessing the parent field `state`.
|
||||
- `State` **must** be updated using the `setState` parent method which merges the new data with the old state.
|
||||
- `React` does do some optimizations around re-rendering components after quick successions of `setState` calls.
|
||||
|
||||
## How mobx works:
|
||||
|
||||
`mobx` is a package that provides an abstraction over `React`'s state management. The three main concepts are:
|
||||
- `observable`: data stored in the component's `state`
|
||||
- `action`: a function that modifies any `observable` data
|
||||
- `computed`: data that is derived from `observable` data but is not actually stored. Think of this as computing `isEmpty` vs an `observable` field called `count`.
|
||||
|
||||
Further reading is available from `mobx`'s [website](https://mobx.js.org/the-gist-of-mobx.html).
|
||||
@ -28,7 +28,7 @@ export class MetricsFeature extends ClusterFeature.Feature {
|
||||
name = "metrics";
|
||||
latestVersion = "v2.17.2-lens1";
|
||||
|
||||
config: MetricsConfiguration = {
|
||||
templateContext: MetricsConfiguration = {
|
||||
persistence: {
|
||||
enabled: false,
|
||||
storageClass: null,
|
||||
@ -53,12 +53,12 @@ export class MetricsFeature extends ClusterFeature.Feature {
|
||||
// Check if there are storageclasses
|
||||
const storageClassApi = K8sApi.forCluster(cluster, K8sApi.StorageClass);
|
||||
const scs = await storageClassApi.list();
|
||||
this.config.persistence.enabled = scs.some(sc => (
|
||||
this.templateContext.persistence.enabled = scs.some(sc => (
|
||||
sc.metadata?.annotations?.['storageclass.kubernetes.io/is-default-class'] === 'true' ||
|
||||
sc.metadata?.annotations?.['storageclass.beta.kubernetes.io/is-default-class'] === 'true'
|
||||
));
|
||||
|
||||
super.applyResources(cluster, super.renderTemplates(path.join(__dirname, "../resources/")));
|
||||
super.applyResources(cluster, path.join(__dirname, "../resources/"));
|
||||
}
|
||||
|
||||
async upgrade(cluster: Store.Cluster): Promise<void> {
|
||||
|
||||
@ -118,7 +118,8 @@ export class Tracker extends Util.Singleton {
|
||||
kubernetesVersion: cluster.metadata.version,
|
||||
distribution: cluster.metadata.distribution,
|
||||
nodesCount: cluster.metadata.nodes,
|
||||
lastSeen: cluster.metadata.lastSeen
|
||||
lastSeen: cluster.metadata.lastSeen,
|
||||
prometheus: cluster.metadata.prometheus
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -136,6 +136,7 @@ describe("Lens integration tests", () => {
|
||||
|
||||
it('adds cluster in test-workspace', async () => {
|
||||
await app.client.click('#current-workspace .Icon');
|
||||
await app.client.waitForVisible('.WorkspaceMenu li[title="test description"]');
|
||||
await app.client.click('.WorkspaceMenu li[title="test description"]');
|
||||
await addMinikubeCluster(app);
|
||||
await app.client.waitUntilTextExists("pre.kube-auth-out", "Authentication proxy started");
|
||||
@ -144,6 +145,7 @@ describe("Lens integration tests", () => {
|
||||
|
||||
it('checks if default workspace has active cluster', async () => {
|
||||
await app.client.click('#current-workspace .Icon');
|
||||
await app.client.waitForVisible('.WorkspaceMenu > li:first-of-type');
|
||||
await app.client.click('.WorkspaceMenu > li:first-of-type');
|
||||
await app.client.waitForVisible(".ClustersMenu .ClusterIcon.active");
|
||||
});
|
||||
|
||||
@ -738,6 +738,7 @@ msgstr "Current / Target"
|
||||
msgid "Current Healthy"
|
||||
msgstr "Current Healthy"
|
||||
|
||||
#: src/renderer/components/+workloads-statefulsets/statefulset-scale-dialog.tsx:101
|
||||
#: src/renderer/components/+workloads-deployments/deployment-scale-dialog.tsx:103
|
||||
msgid "Current replica scale: {currentReplicas}"
|
||||
msgstr "Current replica scale: {currentReplicas}"
|
||||
@ -828,6 +829,7 @@ msgstr "Description"
|
||||
msgid "Desired Healthy"
|
||||
msgstr "Desired Healthy"
|
||||
|
||||
#: src/renderer/components/+workloads-statefulsets/statefulset-scale-dialog.tsx:105
|
||||
#: src/renderer/components/+workloads-deployments/deployment-scale-dialog.tsx:107
|
||||
msgid "Desired number of replicas"
|
||||
msgstr "Desired number of replicas"
|
||||
@ -1091,6 +1093,7 @@ msgstr "Helm branch <0>{0}</0> already in use"
|
||||
msgid "Hide"
|
||||
msgstr "Hide"
|
||||
|
||||
#: src/renderer/components/+workloads-statefulsets/statefulset-scale-dialog.tsx:127
|
||||
#: src/renderer/components/+workloads-deployments/deployment-scale-dialog.tsx:116
|
||||
msgid "High number of replicas may cause cluster performance issues"
|
||||
msgstr "High number of replicas may cause cluster performance issues"
|
||||
@ -2298,6 +2301,7 @@ msgstr "Runtime Class"
|
||||
msgid "Save"
|
||||
msgstr "Save"
|
||||
|
||||
#: src/renderer/components/+workloads-statefulsets/statefulset-scale-dialog.tsx:155
|
||||
#: src/renderer/components/+workloads-deployments/deployment-scale-dialog.tsx:128
|
||||
#: src/renderer/components/+workloads-deployments/deployments.tsx:83
|
||||
#: src/renderer/components/+workloads-deployments/deployments.tsx:84
|
||||
@ -2308,6 +2312,10 @@ msgstr "Scale"
|
||||
msgid "Scale Deployment <0>{deploymentName}</0>"
|
||||
msgstr "Scale Deployment <0>{deploymentName}</0>"
|
||||
|
||||
#: src/renderer/components/+workloads-statefulsets/statefulset-scale-dialog.tsx:139
|
||||
msgid "Scale Stateful Set <0>{statefulSetName}</0>"
|
||||
msgstr "Scale Stateful Set <0>{statefulSetName}</0>"
|
||||
|
||||
#: src/renderer/components/+workloads-cronjobs/cronjob-details.tsx:45
|
||||
#: src/renderer/components/+workloads-cronjobs/cronjobs.tsx:46
|
||||
msgid "Schedule"
|
||||
|
||||
@ -734,6 +734,7 @@ msgstr ""
|
||||
msgid "Current Healthy"
|
||||
msgstr ""
|
||||
|
||||
#: src/renderer/components/+workloads-statefulsets/statefulset-scale-dialog.tsx:101
|
||||
#: src/renderer/components/+workloads-deployments/deployment-scale-dialog.tsx:103
|
||||
msgid "Current replica scale: {currentReplicas}"
|
||||
msgstr ""
|
||||
@ -824,6 +825,7 @@ msgstr ""
|
||||
msgid "Desired Healthy"
|
||||
msgstr ""
|
||||
|
||||
#: src/renderer/components/+workloads-statefulsets/statefulset-scale-dialog.tsx:105
|
||||
#: src/renderer/components/+workloads-deployments/deployment-scale-dialog.tsx:107
|
||||
msgid "Desired number of replicas"
|
||||
msgstr ""
|
||||
@ -1082,6 +1084,7 @@ msgstr ""
|
||||
msgid "Hide"
|
||||
msgstr ""
|
||||
|
||||
#: src/renderer/components/+workloads-statefulsets/statefulset-scale-dialog.tsx:127
|
||||
#: src/renderer/components/+workloads-deployments/deployment-scale-dialog.tsx:116
|
||||
msgid "High number of replicas may cause cluster performance issues"
|
||||
msgstr ""
|
||||
@ -2281,6 +2284,7 @@ msgstr ""
|
||||
msgid "Save"
|
||||
msgstr ""
|
||||
|
||||
#: src/renderer/components/+workloads-statefulsets/statefulset-scale-dialog.tsx:155
|
||||
#: src/renderer/components/+workloads-deployments/deployment-scale-dialog.tsx:128
|
||||
#: src/renderer/components/+workloads-deployments/deployments.tsx:83
|
||||
#: src/renderer/components/+workloads-deployments/deployments.tsx:84
|
||||
@ -2291,6 +2295,10 @@ msgstr ""
|
||||
msgid "Scale Deployment <0>{deploymentName}</0>"
|
||||
msgstr ""
|
||||
|
||||
#: src/renderer/components/+workloads-statefulsets/statefulset-scale-dialog.tsx:139
|
||||
msgid "Scale Stateful Set <0>{statefulSetName}</0>"
|
||||
msgstr ""
|
||||
|
||||
#: src/renderer/components/+workloads-cronjobs/cronjob-details.tsx:45
|
||||
#: src/renderer/components/+workloads-cronjobs/cronjobs.tsx:46
|
||||
msgid "Schedule"
|
||||
|
||||
@ -739,6 +739,7 @@ msgstr "Текущее / Цель"
|
||||
msgid "Current Healthy"
|
||||
msgstr ""
|
||||
|
||||
#: src/renderer/components/+workloads-statefulsets/statefulset-scale-dialog.tsx:101
|
||||
#: src/renderer/components/+workloads-deployments/deployment-scale-dialog.tsx:103
|
||||
msgid "Current replica scale: {currentReplicas}"
|
||||
msgstr "Текущий размер реплики: {currentReplicas}"
|
||||
@ -829,6 +830,7 @@ msgstr "Описание"
|
||||
msgid "Desired Healthy"
|
||||
msgstr ""
|
||||
|
||||
#: src/renderer/components/+workloads-statefulsets/statefulset-scale-dialog.tsx:105
|
||||
#: src/renderer/components/+workloads-deployments/deployment-scale-dialog.tsx:107
|
||||
msgid "Desired number of replicas"
|
||||
msgstr "Нужный уровень реплик"
|
||||
@ -1092,6 +1094,7 @@ msgstr ""
|
||||
msgid "Hide"
|
||||
msgstr "Скрыть"
|
||||
|
||||
#: src/renderer/components/+workloads-statefulsets/statefulset-scale-dialog.tsx:127
|
||||
#: src/renderer/components/+workloads-deployments/deployment-scale-dialog.tsx:116
|
||||
msgid "High number of replicas may cause cluster performance issues"
|
||||
msgstr "Большое количество реплик может вызвать проблемы с производительностью кластера"
|
||||
@ -2299,6 +2302,7 @@ msgstr ""
|
||||
msgid "Save"
|
||||
msgstr "Сохранить"
|
||||
|
||||
#: src/renderer/components/+workloads-statefulsets/statefulset-scale-dialog.tsx:155
|
||||
#: src/renderer/components/+workloads-deployments/deployment-scale-dialog.tsx:128
|
||||
#: src/renderer/components/+workloads-deployments/deployments.tsx:83
|
||||
#: src/renderer/components/+workloads-deployments/deployments.tsx:84
|
||||
@ -2309,6 +2313,10 @@ msgstr "Масштабировать"
|
||||
msgid "Scale Deployment <0>{deploymentName}</0>"
|
||||
msgstr "Масштабировать Deployment <0>{deploymentName}</0>"
|
||||
|
||||
#: src/renderer/components/+workloads-statefulsets/statefulset-scale-dialog.tsx:139
|
||||
msgid "Scale Stateful Set <0>{statefulSetName}</0>"
|
||||
msgstr "Масштабировать Stateful Set <0>{statefulSetName}</0>"
|
||||
|
||||
#: src/renderer/components/+workloads-cronjobs/cronjob-details.tsx:45
|
||||
#: src/renderer/components/+workloads-cronjobs/cronjobs.tsx:46
|
||||
msgid "Schedule"
|
||||
|
||||
@ -33,6 +33,7 @@ nav:
|
||||
- Main Extension: extensions/guides/main-extension.md
|
||||
- Renderer Extension: extensions/guides/renderer-extension.md
|
||||
- Generator: extensions/guides/generator.md
|
||||
- Working with mobx: extensions/guides/working-with-mobx.md
|
||||
- Testing and Publishing:
|
||||
- Testing Extensions: extensions/testing-and-publishing/testing.md
|
||||
- Publishing Extensions: extensions/testing-and-publishing/publishing.md
|
||||
@ -62,7 +63,6 @@ theme:
|
||||
icon: material/toggle-switch-off-outline
|
||||
name: Switch to dark mode
|
||||
features:
|
||||
- navigation.instant
|
||||
- toc.autohide
|
||||
- search.suggest
|
||||
- search.highlight
|
||||
|
||||
@ -21,7 +21,7 @@
|
||||
"compile:main": "yarn run webpack --config webpack.main.ts",
|
||||
"compile:renderer": "yarn run webpack --config webpack.renderer.ts",
|
||||
"compile:i18n": "yarn run lingui compile",
|
||||
"compile:extension-types": "yarn run tsc -p ./tsconfig.extensions.json --outDir src/extensions/npm/extensions/dist",
|
||||
"compile:extension-types": "yarn run webpack --config webpack.extensions.ts",
|
||||
"npm:fix-package-version": "yarn run ts-node build/set_npm_version.ts",
|
||||
"build:linux": "yarn run compile && electron-builder --linux --dir -c.productName=Lens",
|
||||
"build:mac": "yarn run compile && electron-builder --mac --dir -c.productName=Lens",
|
||||
@ -225,7 +225,6 @@
|
||||
"electron-devtools-installer": "^3.1.1",
|
||||
"electron-updater": "^4.3.1",
|
||||
"electron-window-state": "^5.0.3",
|
||||
"file-type": "^14.7.1",
|
||||
"filenamify": "^4.1.0",
|
||||
"fs-extra": "^9.0.1",
|
||||
"handlebars": "^4.7.6",
|
||||
@ -362,6 +361,7 @@
|
||||
"nodemon": "^2.0.4",
|
||||
"patch-package": "^6.2.2",
|
||||
"postinstall-postinstall": "^2.1.0",
|
||||
"prettier": "^2.2.0",
|
||||
"progress-bar-webpack-plugin": "^2.1.0",
|
||||
"raw-loader": "^4.0.1",
|
||||
"react": "^16.14.0",
|
||||
|
||||
@ -2,7 +2,7 @@ import { workspaceStore } from "./workspace-store";
|
||||
import path from "path";
|
||||
import { app, ipcRenderer, remote, webFrame } from "electron";
|
||||
import { unlink } from "fs-extra";
|
||||
import { action, computed, observable, reaction, toJS } from "mobx";
|
||||
import { action, comparer, computed, observable, reaction, toJS } from "mobx";
|
||||
import { BaseStore } from "./base-store";
|
||||
import { Cluster, ClusterState } from "../main/cluster";
|
||||
import migrations from "../migrations/cluster-store";
|
||||
@ -23,9 +23,15 @@ export interface ClusterIconUpload {
|
||||
}
|
||||
|
||||
export interface ClusterMetadata {
|
||||
[key: string]: string | number | boolean;
|
||||
[key: string]: string | number | boolean | object;
|
||||
}
|
||||
|
||||
export type ClusterPrometheusMetadata = {
|
||||
success?: boolean;
|
||||
provider?: string;
|
||||
autoDetected?: boolean;
|
||||
};
|
||||
|
||||
export interface ClusterStoreModel {
|
||||
activeCluster?: ClusterId; // last opened cluster
|
||||
clusters?: ClusterModel[]
|
||||
@ -47,9 +53,15 @@ export interface ClusterModel {
|
||||
kubeConfig?: string; // yaml
|
||||
}
|
||||
|
||||
export interface ClusterPreferences {
|
||||
export interface ClusterPreferences extends ClusterPrometheusPreferences{
|
||||
terminalCWD?: string;
|
||||
clusterName?: string;
|
||||
iconOrder?: number;
|
||||
icon?: string;
|
||||
httpsProxy?: string;
|
||||
}
|
||||
|
||||
export interface ClusterPrometheusPreferences {
|
||||
prometheus?: {
|
||||
namespace: string;
|
||||
service: string;
|
||||
@ -59,9 +71,6 @@ export interface ClusterPreferences {
|
||||
prometheusProvider?: {
|
||||
type: string;
|
||||
};
|
||||
iconOrder?: number;
|
||||
icon?: string;
|
||||
httpsProxy?: string;
|
||||
}
|
||||
|
||||
export class ClusterStore extends BaseStore<ClusterStoreModel> {
|
||||
@ -84,6 +93,9 @@ export class ClusterStore extends BaseStore<ClusterStoreModel> {
|
||||
super({
|
||||
configName: "lens-cluster-store",
|
||||
accessPropertiesByDotNotation: false, // To make dots safe in cluster context names
|
||||
syncOptions: {
|
||||
equals: comparer.structural,
|
||||
},
|
||||
migrations,
|
||||
});
|
||||
|
||||
|
||||
@ -3,6 +3,7 @@ import request from "request";
|
||||
export interface DownloadFileOptions {
|
||||
url: string;
|
||||
gzip?: boolean;
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
export interface DownloadFileTicket {
|
||||
@ -11,9 +12,9 @@ export interface DownloadFileTicket {
|
||||
cancel(): void;
|
||||
}
|
||||
|
||||
export function downloadFile({ url, gzip = true }: DownloadFileOptions): DownloadFileTicket {
|
||||
export function downloadFile({ url, timeout, gzip = true }: DownloadFileOptions): DownloadFileTicket {
|
||||
const fileChunks: Buffer[] = [];
|
||||
const req = request(url, { gzip });
|
||||
const req = request(url, { gzip, timeout });
|
||||
const promise: Promise<Buffer> = new Promise((resolve, reject) => {
|
||||
req.on("data", (chunk: Buffer) => {
|
||||
fileChunks.push(chunk);
|
||||
|
||||
@ -3,6 +3,6 @@
|
||||
* @param items either one item or an array of items
|
||||
* @returns a list of items
|
||||
*/
|
||||
export function recitfy<T>(items: T | T[]): T[] {
|
||||
export function rectify<T>(items: T | T[]): T[] {
|
||||
return Array.isArray(items) ? items : [items];
|
||||
}
|
||||
|
||||
126
src/extensions/__tests__/extension-loader.test.ts
Normal file
126
src/extensions/__tests__/extension-loader.test.ts
Normal file
@ -0,0 +1,126 @@
|
||||
import { ExtensionLoader } from "../extension-loader";
|
||||
|
||||
const manifestPath = "manifest/path";
|
||||
const manifestPath2 = "manifest/path2";
|
||||
const manifestPath3 = "manifest/path3";
|
||||
|
||||
jest.mock(
|
||||
"electron",
|
||||
() => ({
|
||||
ipcRenderer: {
|
||||
invoke: jest.fn(async (channel: string, ...args: any[]) => {
|
||||
if (channel === "extensions:loaded") {
|
||||
return [
|
||||
[
|
||||
manifestPath,
|
||||
{
|
||||
manifest: {
|
||||
name: "TestExtension",
|
||||
version: "1.0.0",
|
||||
},
|
||||
absolutePath: "/test/1",
|
||||
manifestPath,
|
||||
isBundled: false,
|
||||
isEnabled: true,
|
||||
},
|
||||
],
|
||||
[
|
||||
manifestPath2,
|
||||
{
|
||||
manifest: {
|
||||
name: "TestExtension2",
|
||||
version: "2.0.0",
|
||||
},
|
||||
absolutePath: "/test/2",
|
||||
manifestPath: manifestPath2,
|
||||
isBundled: false,
|
||||
isEnabled: true,
|
||||
},
|
||||
],
|
||||
];
|
||||
}
|
||||
}),
|
||||
on: jest.fn(
|
||||
(channel: string, listener: (event: any, ...args: any[]) => void) => {
|
||||
if (channel === "extensions:loaded") {
|
||||
// First initialize with extensions 1 and 2
|
||||
// and then broadcast event to remove extensioin 2 and add extension number 3
|
||||
setTimeout(() => {
|
||||
listener({}, [
|
||||
[
|
||||
manifestPath,
|
||||
{
|
||||
manifest: {
|
||||
name: "TestExtension",
|
||||
version: "1.0.0",
|
||||
},
|
||||
absolutePath: "/test/1",
|
||||
manifestPath,
|
||||
isBundled: false,
|
||||
isEnabled: true,
|
||||
},
|
||||
],
|
||||
[
|
||||
manifestPath3,
|
||||
{
|
||||
manifest: {
|
||||
name: "TestExtension3",
|
||||
version: "3.0.0",
|
||||
},
|
||||
absolutePath: "/test/3",
|
||||
manifestPath: manifestPath3,
|
||||
isBundled: false,
|
||||
isEnabled: true,
|
||||
},
|
||||
],
|
||||
]);
|
||||
}, 10);
|
||||
}
|
||||
}
|
||||
),
|
||||
},
|
||||
}),
|
||||
{
|
||||
virtual: true,
|
||||
}
|
||||
);
|
||||
|
||||
describe("ExtensionLoader", () => {
|
||||
it("renderer updates extension after ipc broadcast", async (done) => {
|
||||
const extensionLoader = new ExtensionLoader();
|
||||
|
||||
expect(extensionLoader.userExtensions).toMatchInlineSnapshot(`Map {}`);
|
||||
|
||||
await extensionLoader.init();
|
||||
|
||||
setTimeout(() => {
|
||||
// Assert the extensions after the extension broadcast event
|
||||
expect(extensionLoader.userExtensions).toMatchInlineSnapshot(`
|
||||
Map {
|
||||
"manifest/path" => Object {
|
||||
"absolutePath": "/test/1",
|
||||
"isBundled": false,
|
||||
"isEnabled": true,
|
||||
"manifest": Object {
|
||||
"name": "TestExtension",
|
||||
"version": "1.0.0",
|
||||
},
|
||||
"manifestPath": "manifest/path",
|
||||
},
|
||||
"manifest/path3" => Object {
|
||||
"absolutePath": "/test/3",
|
||||
"isBundled": false,
|
||||
"isEnabled": true,
|
||||
"manifest": Object {
|
||||
"name": "TestExtension3",
|
||||
"version": "3.0.0",
|
||||
},
|
||||
"manifestPath": "manifest/path3",
|
||||
},
|
||||
}
|
||||
`);
|
||||
|
||||
done();
|
||||
}, 10);
|
||||
});
|
||||
});
|
||||
@ -9,6 +9,7 @@ describe("lens extension", () => {
|
||||
name: "foo-bar",
|
||||
version: "0.1.1"
|
||||
},
|
||||
absolutePath: "/absolute/fake/",
|
||||
manifestPath: "/this/is/fake/package.json",
|
||||
isBundled: false,
|
||||
isEnabled: true
|
||||
|
||||
@ -10,17 +10,27 @@ import { requestMain } from "../common/ipc";
|
||||
import { clusterKubectlApplyAllHandler } from "../common/cluster-ipc";
|
||||
|
||||
export interface ClusterFeatureStatus {
|
||||
/** feature's current version, as set by the implementation */
|
||||
currentVersion: string;
|
||||
installed: boolean;
|
||||
/** feature's latest version, as set by the implementation */
|
||||
latestVersion: string;
|
||||
/** whether the feature is installed or not, as set by the implementation */
|
||||
installed: boolean;
|
||||
/** whether the feature can be upgraded or not, as set by the implementation */
|
||||
canUpgrade: boolean;
|
||||
}
|
||||
|
||||
export abstract class ClusterFeature {
|
||||
name: string;
|
||||
latestVersion: string;
|
||||
config: any;
|
||||
|
||||
/**
|
||||
* this field sets the template parameters that are to be applied to any templated kubernetes resources that are to be installed for the feature.
|
||||
* See the renderTemplates() method for more details
|
||||
*/
|
||||
templateContext: any;
|
||||
|
||||
/**
|
||||
* this field holds the current feature status, is accessed directly by Lens
|
||||
*/
|
||||
@observable status: ClusterFeatureStatus = {
|
||||
currentVersion: null,
|
||||
installed: false,
|
||||
@ -28,15 +38,59 @@ export abstract class ClusterFeature {
|
||||
canUpgrade: false
|
||||
};
|
||||
|
||||
/**
|
||||
* to be implemented in the derived class, this method is typically called by Lens when a user has indicated that this feature is to be installed. The implementation
|
||||
* of this method should install kubernetes resources using the applyResources() method, or by directly accessing the kubernetes api (K8sApi)
|
||||
*
|
||||
* @param cluster the cluster that the feature is to be installed on
|
||||
*/
|
||||
abstract async install(cluster: Cluster): Promise<void>;
|
||||
|
||||
/**
|
||||
* to be implemented in the derived class, this method is typically called by Lens when a user has indicated that this feature is to be ugraded. The implementation
|
||||
* of this method should upgrade the kubernetes resources already installed, if relevant to the feature
|
||||
*
|
||||
* @param cluster the cluster that the feature is to be upgraded on
|
||||
*/
|
||||
abstract async upgrade(cluster: Cluster): Promise<void>;
|
||||
|
||||
/**
|
||||
* to be implemented in the derived class, this method is typically called by Lens when a user has indicated that this feature is to be uninstalled. The implementation
|
||||
* of this method should install kubernetes resources using the kubernetes api (K8sApi)
|
||||
*
|
||||
* @param cluster the cluster that the feature is to be uninstalled from
|
||||
*/
|
||||
abstract async uninstall(cluster: Cluster): Promise<void>;
|
||||
|
||||
/**
|
||||
* to be implemented in the derived class, this method is called periodically by Lens to determine details about the feature's current status. The implementation
|
||||
* of this method should provide the current status information. The currentVersion and latestVersion fields may be displayed by Lens in describing the feature.
|
||||
* The installed field should be set to true if the feature has been installed, otherwise false. Also, Lens relies on the canUpgrade field to determine if the feature
|
||||
* can be upgraded so the implementation should set the canUpgrade field according to specific rules for the feature, if relevant.
|
||||
*
|
||||
* @param cluster the cluster that the feature may be installed on
|
||||
*
|
||||
* @return a promise, resolved with the updated ClusterFeatureStatus
|
||||
*/
|
||||
abstract async updateStatus(cluster: Cluster): Promise<ClusterFeatureStatus>;
|
||||
|
||||
protected async applyResources(cluster: Cluster, resources: string[]) {
|
||||
/**
|
||||
* this is a helper method that conveniently applies kubernetes resources to the cluster.
|
||||
*
|
||||
* @param cluster the cluster that the resources are to be applied to
|
||||
* @param resourceSpec as a string type this is a folder path that is searched for files specifying kubernetes resources. The files are read and if any of the resource
|
||||
* files are templated, the template parameters are filled using the templateContext field (See renderTemplate() method). Finally the resources are applied to the
|
||||
* cluster. As a string[] type resourceSpec is treated as an array of fully formed (not templated) kubernetes resources that are applied to the cluster
|
||||
*/
|
||||
protected async applyResources(cluster: Cluster, resourceSpec: string | string[]) {
|
||||
let resources: string[];
|
||||
|
||||
if ( typeof resourceSpec === "string" ) {
|
||||
resources = this.renderTemplates(resourceSpec);
|
||||
} else {
|
||||
resources = resourceSpec;
|
||||
}
|
||||
|
||||
if (app) {
|
||||
await new ResourceApplier(cluster).kubectlApplyAll(resources);
|
||||
} else {
|
||||
@ -44,6 +98,14 @@ export abstract class ClusterFeature {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* this is a helper method that conveniently reads kubernetes resource files into a string array. It also fills templated resource files with the template parameter values
|
||||
* specified by the templateContext field. Templated files must end with the extension '.hb' and the template syntax must be compatible with handlebars.js
|
||||
*
|
||||
* @param folderPath this is a folder path that is searched for files defining kubernetes resources.
|
||||
*
|
||||
* @return an array of strings, each string being the contents of a resource file found in the folder path. This can be passed directly to applyResources()
|
||||
*/
|
||||
protected renderTemplates(folderPath: string): string[] {
|
||||
const resources: string[] = [];
|
||||
logger.info(`[FEATURE]: render templates from ${folderPath}`);
|
||||
@ -52,7 +114,7 @@ export abstract class ClusterFeature {
|
||||
const raw = fs.readFileSync(file);
|
||||
if (filename.endsWith('.hb')) {
|
||||
const template = hb.compile(raw.toString());
|
||||
resources.push(template(this.config));
|
||||
resources.push(template(this.templateContext));
|
||||
} else {
|
||||
resources.push(raw.toString());
|
||||
}
|
||||
|
||||
@ -11,6 +11,12 @@ import type { LensExtensionId, LensExtensionManifest } from "./lens-extension";
|
||||
|
||||
export interface InstalledExtension {
|
||||
readonly manifest: LensExtensionManifest;
|
||||
|
||||
// Absolute path to the non-symlinked source folder,
|
||||
// e.g. "/Users/user/.k8slens/extensions/helloworld"
|
||||
readonly absolutePath: string;
|
||||
|
||||
// Absolute to the symlinked package.json file
|
||||
readonly manifestPath: string;
|
||||
readonly isBundled: boolean; // defined in project root's package.json
|
||||
isEnabled: boolean;
|
||||
@ -174,6 +180,24 @@ export class ExtensionDiscovery {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Uninstalls extension by path.
|
||||
* The application will detect the folder unlink and remove the extension from the UI automatically.
|
||||
* @param absolutePath Path to the non-symlinked folder of the extension
|
||||
*/
|
||||
async uninstallExtension(absolutePath: string) {
|
||||
logger.info(`${logModule} Uninstalling ${absolutePath}`);
|
||||
|
||||
const exists = await fs.pathExists(absolutePath);
|
||||
|
||||
if (!exists) {
|
||||
throw new Error(`Extension path ${absolutePath} doesn't exist`);
|
||||
}
|
||||
|
||||
// fs.remove does nothing if the path doesn't exist anymore
|
||||
await fs.remove(absolutePath);
|
||||
}
|
||||
|
||||
async load(): Promise<Map<LensExtensionId, InstalledExtension>> {
|
||||
if (this.loadStarted) {
|
||||
// The class is simplified by only supporting .load() to be called once
|
||||
@ -230,6 +254,7 @@ export class ExtensionDiscovery {
|
||||
const isEnabled = isBundled || extensionsStore.isEnabled(installedManifestPath);
|
||||
|
||||
return {
|
||||
absolutePath: path.dirname(manifestPath),
|
||||
manifestPath: installedManifestPath,
|
||||
manifest: manifestJson,
|
||||
isBundled,
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { app, ipcRenderer, remote } from "electron";
|
||||
import { EventEmitter } from "events";
|
||||
import { action, computed, observable, reaction, toJS, when } from "mobx";
|
||||
import path from "path";
|
||||
import { getHostedCluster } from "../common/cluster-store";
|
||||
@ -26,26 +27,32 @@ export class ExtensionLoader {
|
||||
protected instances = observable.map<LensExtensionId, LensExtension>();
|
||||
protected readonly requestExtensionsChannel = "extensions:loaded";
|
||||
|
||||
// emits event "remove" of type LensExtension when the extension is removed
|
||||
private events = new EventEmitter();
|
||||
|
||||
@observable isLoaded = false;
|
||||
whenLoaded = when(() => this.isLoaded);
|
||||
|
||||
@computed get userExtensions(): Map<LensExtensionId, InstalledExtension> {
|
||||
const extensions = this.extensions.toJS();
|
||||
|
||||
extensions.forEach((ext, extId) => {
|
||||
if (ext.isBundled) {
|
||||
extensions.delete(extId);
|
||||
}
|
||||
});
|
||||
|
||||
return extensions;
|
||||
}
|
||||
|
||||
@action
|
||||
async init() {
|
||||
if (ipcRenderer) {
|
||||
this.initRenderer();
|
||||
await this.initRenderer();
|
||||
} else {
|
||||
this.initMain();
|
||||
await this.initMain();
|
||||
}
|
||||
|
||||
extensionsStore.manageState(this);
|
||||
}
|
||||
|
||||
@ -57,8 +64,27 @@ export class ExtensionLoader {
|
||||
this.extensions.set(extension.manifestPath as LensExtensionId, extension);
|
||||
}
|
||||
|
||||
removeInstance(lensExtensionId: LensExtensionId) {
|
||||
logger.info(`${logModule} deleting extension instance ${lensExtensionId}`);
|
||||
const instance = this.instances.get(lensExtensionId);
|
||||
|
||||
if (!instance) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
instance.disable();
|
||||
this.events.emit("remove", instance);
|
||||
this.instances.delete(lensExtensionId);
|
||||
} catch (error) {
|
||||
logger.error(`${logModule}: deactivation extension error`, { lensExtensionId, error });
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
removeExtension(lensExtensionId: LensExtensionId) {
|
||||
// TODO: Remove the extension properly (from menus etc.)
|
||||
this.removeInstance(lensExtensionId);
|
||||
|
||||
if (!this.extensions.delete(lensExtensionId)) {
|
||||
throw new Error(`Can't remove extension ${lensExtensionId}, doesn't exist.`);
|
||||
}
|
||||
@ -79,14 +105,25 @@ export class ExtensionLoader {
|
||||
}
|
||||
|
||||
protected async initRenderer() {
|
||||
const extensionListHandler = ( extensions: [LensExtensionId, InstalledExtension][]) => {
|
||||
const extensionListHandler = (extensions: [LensExtensionId, InstalledExtension][]) => {
|
||||
this.isLoaded = true;
|
||||
const receivedExtensionIds = extensions.map(([lensExtensionId]) => lensExtensionId);
|
||||
|
||||
// Add new extensions
|
||||
extensions.forEach(([extId, ext]) => {
|
||||
if (!this.extensions.has(extId)) {
|
||||
this.extensions.set(extId, ext);
|
||||
}
|
||||
});
|
||||
|
||||
// Remove deleted extensions
|
||||
this.extensions.forEach((_, lensExtensionId) => {
|
||||
if (!receivedExtensionIds.includes(lensExtensionId)) {
|
||||
this.removeExtension(lensExtensionId);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
requestMain(this.requestExtensionsChannel).then(extensionListHandler);
|
||||
subscribeToBroadcast(this.requestExtensionsChannel, (event, extensions: [LensExtensionId, InstalledExtension][]) => {
|
||||
extensionListHandler(extensions);
|
||||
@ -95,36 +132,73 @@ export class ExtensionLoader {
|
||||
|
||||
loadOnMain() {
|
||||
logger.info(`${logModule}: load on main`);
|
||||
this.autoInitExtensions(async (ext: LensMainExtension) => [
|
||||
registries.menuRegistry.add(ext.appMenus)
|
||||
]);
|
||||
this.autoInitExtensions(async (extension: LensMainExtension) => {
|
||||
// Each .add returns a function to remove the item
|
||||
const removeItems = [
|
||||
registries.menuRegistry.add(extension.appMenus)
|
||||
];
|
||||
|
||||
this.events.on("remove", (removedExtension: LensRendererExtension) => {
|
||||
// manifestPath is considered the id
|
||||
if (removedExtension.manifestPath === extension.manifestPath) {
|
||||
removeItems.forEach(remove => {
|
||||
remove();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return removeItems;
|
||||
});
|
||||
}
|
||||
|
||||
loadOnClusterManagerRenderer() {
|
||||
logger.info(`${logModule}: load on main renderer (cluster manager)`);
|
||||
this.autoInitExtensions(async (ext: LensRendererExtension) => [
|
||||
registries.globalPageRegistry.add(ext.globalPages, ext),
|
||||
registries.globalPageMenuRegistry.add(ext.globalPageMenus, ext),
|
||||
registries.appPreferenceRegistry.add(ext.appPreferences),
|
||||
registries.clusterFeatureRegistry.add(ext.clusterFeatures),
|
||||
registries.statusBarRegistry.add(ext.statusBarItems),
|
||||
]);
|
||||
this.autoInitExtensions(async (extension: LensRendererExtension) => {
|
||||
const removeItems = [
|
||||
registries.globalPageRegistry.add(extension.globalPages, extension),
|
||||
registries.globalPageMenuRegistry.add(extension.globalPageMenus, extension),
|
||||
registries.appPreferenceRegistry.add(extension.appPreferences),
|
||||
registries.clusterFeatureRegistry.add(extension.clusterFeatures),
|
||||
registries.statusBarRegistry.add(extension.statusBarItems),
|
||||
];
|
||||
|
||||
this.events.on("remove", (removedExtension: LensRendererExtension) => {
|
||||
if (removedExtension.manifestPath === extension.manifestPath) {
|
||||
removeItems.forEach(remove => {
|
||||
remove();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return removeItems;
|
||||
});
|
||||
}
|
||||
|
||||
loadOnClusterRenderer() {
|
||||
logger.info(`${logModule}: load on cluster renderer (dashboard)`);
|
||||
const cluster = getHostedCluster();
|
||||
this.autoInitExtensions(async (ext: LensRendererExtension) => {
|
||||
if (await ext.isEnabledForCluster(cluster) === false) {
|
||||
this.autoInitExtensions(async (extension: LensRendererExtension) => {
|
||||
if (await extension.isEnabledForCluster(cluster) === false) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
registries.clusterPageRegistry.add(ext.clusterPages, ext),
|
||||
registries.clusterPageMenuRegistry.add(ext.clusterPageMenus, ext),
|
||||
registries.kubeObjectMenuRegistry.add(ext.kubeObjectMenuItems),
|
||||
registries.kubeObjectDetailRegistry.add(ext.kubeObjectDetailItems),
|
||||
registries.kubeObjectStatusRegistry.add(ext.kubeObjectStatusTexts)
|
||||
|
||||
const removeItems = [
|
||||
registries.clusterPageRegistry.add(extension.clusterPages, extension),
|
||||
registries.clusterPageMenuRegistry.add(extension.clusterPageMenus, extension),
|
||||
registries.kubeObjectMenuRegistry.add(extension.kubeObjectMenuItems),
|
||||
registries.kubeObjectDetailRegistry.add(extension.kubeObjectDetailItems),
|
||||
registries.kubeObjectStatusRegistry.add(extension.kubeObjectStatusTexts)
|
||||
];
|
||||
|
||||
this.events.on("remove", (removedExtension: LensRendererExtension) => {
|
||||
if (removedExtension.manifestPath === extension.manifestPath) {
|
||||
removeItems.forEach(remove => {
|
||||
remove();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return removeItems;
|
||||
});
|
||||
}
|
||||
|
||||
@ -148,13 +222,7 @@ export class ExtensionLoader {
|
||||
logger.error(`${logModule}: activation extension error`, { ext, err });
|
||||
}
|
||||
} else if (!ext.isEnabled && alreadyInit) {
|
||||
try {
|
||||
const instance = this.instances.get(extId);
|
||||
instance.disable();
|
||||
this.instances.delete(extId);
|
||||
} catch (err) {
|
||||
logger.error(`${logModule}: deactivation extension error`, { ext, err });
|
||||
}
|
||||
this.removeInstance(extId);
|
||||
}
|
||||
}
|
||||
}, {
|
||||
|
||||
@ -114,3 +114,7 @@ export class LensExtension {
|
||||
export function sanitizeExtensionName(name: string) {
|
||||
return name.replace("@", "").replace("/", "--");
|
||||
}
|
||||
|
||||
export function extensionDisplayName(name: string, version: string) {
|
||||
return `${name}@${version}`;
|
||||
}
|
||||
|
||||
@ -11,6 +11,7 @@ describe("getPageUrl", () => {
|
||||
name: "foo-bar",
|
||||
version: "0.1.1"
|
||||
},
|
||||
absolutePath: "/absolute/fake/",
|
||||
manifestPath: "/this/is/fake/package.json",
|
||||
isBundled: false,
|
||||
isEnabled: true
|
||||
@ -41,6 +42,7 @@ describe("globalPageRegistry", () => {
|
||||
name: "@acme/foo-bar",
|
||||
version: "0.1.1"
|
||||
},
|
||||
absolutePath: "/absolute/fake/",
|
||||
manifestPath: "/this/is/fake/package.json",
|
||||
isBundled: false,
|
||||
isEnabled: true
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
// Base class for extensions-api registries
|
||||
import { action, observable } from "mobx";
|
||||
import { LensExtension } from "../lens-extension";
|
||||
import { recitfy } from "../../common/utils";
|
||||
import { rectify } from "../../common/utils";
|
||||
|
||||
export class BaseRegistry<T> {
|
||||
private items = observable<T>([], { deep: false });
|
||||
@ -13,7 +13,7 @@ export class BaseRegistry<T> {
|
||||
add(items: T | T[], ext?: LensExtension): () => void; // allow method overloading with required "ext"
|
||||
@action
|
||||
add(items: T | T[]) {
|
||||
const itemArray = recitfy(items);
|
||||
const itemArray = rectify(items);
|
||||
this.items.push(...itemArray);
|
||||
return () => this.remove(...itemArray);
|
||||
}
|
||||
|
||||
@ -7,7 +7,7 @@ import { compile } from "path-to-regexp";
|
||||
import { BaseRegistry } from "./base-registry";
|
||||
import { LensExtension, sanitizeExtensionName } from "../lens-extension";
|
||||
import logger from "../../main/logger";
|
||||
import { recitfy } from "../../common/utils";
|
||||
import { rectify } from "../../common/utils";
|
||||
|
||||
export interface PageRegistration {
|
||||
/**
|
||||
@ -54,7 +54,7 @@ export function getExtensionPageUrl<P extends object>({ extensionId, pageId = ""
|
||||
export class PageRegistry extends BaseRegistry<RegisteredPage> {
|
||||
@action
|
||||
add(items: PageRegistration | PageRegistration[], ext: LensExtension) {
|
||||
const itemArray = recitfy(items);
|
||||
const itemArray = rectify(items);
|
||||
let registeredPages: RegisteredPage[] = [];
|
||||
try {
|
||||
registeredPages = itemArray.map(page => ({
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import { ipcMain } from "electron";
|
||||
import type { ClusterId, ClusterMetadata, ClusterModel, ClusterPreferences } from "../common/cluster-store";
|
||||
import type { ClusterId, ClusterMetadata, ClusterModel, ClusterPreferences, ClusterPrometheusPreferences } from "../common/cluster-store";
|
||||
import type { IMetricsReqParams } from "../renderer/api/endpoints/metrics.api";
|
||||
import type { WorkspaceId } from "../common/workspace-store";
|
||||
import { action, computed, observable, reaction, toJS, when } from "mobx";
|
||||
import { action, comparer, computed, observable, reaction, toJS, when } from "mobx";
|
||||
import { apiKubePrefix } from "../common/vars";
|
||||
import { broadcastMessage } from "../common/ipc";
|
||||
import { ContextHandler } from "./context-handler";
|
||||
@ -27,7 +27,8 @@ export enum ClusterMetadataKey {
|
||||
CLUSTER_ID = "id",
|
||||
DISTRIBUTION = "distribution",
|
||||
NODES_COUNT = "nodes",
|
||||
LAST_SEEN = "lastSeen"
|
||||
LAST_SEEN = "lastSeen",
|
||||
PROMETHEUS = "prometheus"
|
||||
}
|
||||
|
||||
export type ClusterRefreshOptions = {
|
||||
@ -42,7 +43,6 @@ export interface ClusterState {
|
||||
accessible: boolean;
|
||||
ready: boolean;
|
||||
failureReason: string;
|
||||
eventCount: number;
|
||||
isAdmin: boolean;
|
||||
allowedNamespaces: string[]
|
||||
allowedResources: string[]
|
||||
@ -74,7 +74,6 @@ export class Cluster implements ClusterModel, ClusterState {
|
||||
@observable disconnected = true; // false if user has selected to connect
|
||||
@observable failureReason: string;
|
||||
@observable isAdmin = false;
|
||||
@observable eventCount = 0;
|
||||
@observable preferences: ClusterPreferences = {};
|
||||
@observable metadata: ClusterMetadata = {};
|
||||
@observable allowedNamespaces: string[] = [];
|
||||
@ -89,6 +88,13 @@ export class Cluster implements ClusterModel, ClusterState {
|
||||
return this.preferences.clusterName || this.contextName;
|
||||
}
|
||||
|
||||
@computed get prometheusPreferences(): ClusterPrometheusPreferences {
|
||||
const { prometheus, prometheusProvider } = this.preferences;
|
||||
return toJS({ prometheus, prometheusProvider }, {
|
||||
recurseEverything: true,
|
||||
});
|
||||
}
|
||||
|
||||
get version(): string {
|
||||
return String(this.metadata?.version) || "";
|
||||
}
|
||||
@ -138,6 +144,7 @@ export class Cluster implements ClusterModel, ClusterState {
|
||||
if (ipcMain) {
|
||||
this.eventDisposers.push(
|
||||
reaction(() => this.getState(), () => this.pushState()),
|
||||
reaction(() => this.prometheusPreferences, (prefs) => this.contextHandler.setupPrometheus(prefs), { equals: comparer.structural, }),
|
||||
() => {
|
||||
clearInterval(refreshTimer);
|
||||
clearInterval(refreshMetadataTimer);
|
||||
@ -209,10 +216,7 @@ export class Cluster implements ClusterModel, ClusterState {
|
||||
await this.refreshConnectionStatus();
|
||||
if (this.accessible) {
|
||||
this.isAdmin = await this.isClusterAdmin();
|
||||
await Promise.all([
|
||||
this.refreshEvents(),
|
||||
this.refreshAllowedResources(),
|
||||
]);
|
||||
await this.refreshAllowedResources();
|
||||
if (opts.refreshMetadata) {
|
||||
this.refreshMetadata();
|
||||
}
|
||||
@ -242,11 +246,6 @@ export class Cluster implements ClusterModel, ClusterState {
|
||||
this.allowedResources = await this.getAllowedResources();
|
||||
}
|
||||
|
||||
@action
|
||||
async refreshEvents() {
|
||||
this.eventCount = await this.getEventCount();
|
||||
}
|
||||
|
||||
protected getKubeconfig(): KubeConfig {
|
||||
return loadConfig(this.kubeConfigPath);
|
||||
}
|
||||
@ -332,40 +331,6 @@ export class Cluster implements ClusterModel, ClusterState {
|
||||
});
|
||||
}
|
||||
|
||||
protected async getEventCount(): Promise<number> {
|
||||
if (!this.isAdmin) {
|
||||
return 0;
|
||||
}
|
||||
const client = this.getProxyKubeconfig().makeApiClient(CoreV1Api);
|
||||
try {
|
||||
const response = await client.listEventForAllNamespaces(false, null, null, null, 1000);
|
||||
const uniqEventSources = new Set();
|
||||
const warnings = response.body.items.filter(e => e.type !== 'Normal');
|
||||
for (const w of warnings) {
|
||||
if (w.involvedObject.kind === 'Pod') {
|
||||
try {
|
||||
const { body: pod } = await client.readNamespacedPod(w.involvedObject.name, w.involvedObject.namespace);
|
||||
logger.debug(`checking pod ${w.involvedObject.namespace}/${w.involvedObject.name}`);
|
||||
if (podHasIssues(pod)) {
|
||||
uniqEventSources.add(w.involvedObject.uid);
|
||||
}
|
||||
} catch (err) {
|
||||
}
|
||||
} else {
|
||||
uniqEventSources.add(w.involvedObject.uid);
|
||||
}
|
||||
}
|
||||
const nodes = (await client.listNode()).body.items;
|
||||
const nodeNotificationCount = nodes
|
||||
.map(getNodeWarningConditions)
|
||||
.reduce((sum, conditions) => sum + conditions.length, 0);
|
||||
return uniqEventSources.size + nodeNotificationCount;
|
||||
} catch (error) {
|
||||
logger.error("Failed to fetch event count: " + JSON.stringify(error));
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
toJSON(): ClusterModel {
|
||||
const model: ClusterModel = {
|
||||
id: this.id,
|
||||
@ -393,7 +358,6 @@ export class Cluster implements ClusterModel, ClusterState {
|
||||
accessible: this.accessible,
|
||||
failureReason: this.failureReason,
|
||||
isAdmin: this.isAdmin,
|
||||
eventCount: this.eventCount,
|
||||
allowedNamespaces: this.allowedNamespaces,
|
||||
allowedResources: this.allowedResources,
|
||||
};
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { PrometheusProvider, PrometheusService } from "./prometheus/provider-registry";
|
||||
import type { ClusterPreferences } from "../common/cluster-store";
|
||||
import type { ClusterPrometheusPreferences } from "../common/cluster-store";
|
||||
import type { Cluster } from "./cluster";
|
||||
import type httpProxy from "http-proxy";
|
||||
import url, { UrlWithStringQuery } from "url";
|
||||
@ -22,7 +22,7 @@ export class ContextHandler {
|
||||
this.setupPrometheus(cluster.preferences);
|
||||
}
|
||||
|
||||
protected setupPrometheus(preferences: ClusterPreferences = {}) {
|
||||
public setupPrometheus(preferences: ClusterPrometheusPreferences = {}) {
|
||||
this.prometheusProvider = preferences.prometheusProvider?.type;
|
||||
this.prometheusPath = null;
|
||||
if (preferences.prometheus) {
|
||||
@ -32,13 +32,18 @@ export class ContextHandler {
|
||||
}
|
||||
|
||||
protected async resolvePrometheusPath(): Promise<string> {
|
||||
const { service, namespace, port } = await this.getPrometheusService();
|
||||
const prometheusService = await this.getPrometheusService();
|
||||
if (!prometheusService) return null;
|
||||
const { service, namespace, port } = prometheusService;
|
||||
return `${namespace}/services/${service}:${port}`;
|
||||
}
|
||||
|
||||
async getPrometheusProvider() {
|
||||
if (!this.prometheusProvider) {
|
||||
const service = await this.getPrometheusService();
|
||||
if (!service) {
|
||||
return null;
|
||||
}
|
||||
logger.info(`using ${service.id} as prometheus provider`);
|
||||
this.prometheusProvider = service.id;
|
||||
}
|
||||
@ -52,13 +57,7 @@ export class ContextHandler {
|
||||
return await provider.getPrometheusService(apiClient);
|
||||
});
|
||||
const resolvedPrometheusServices = await Promise.all(prometheusPromises);
|
||||
const service = resolvedPrometheusServices.filter(n => n)[0];
|
||||
return service || {
|
||||
id: "lens",
|
||||
namespace: "lens-metrics",
|
||||
service: "prometheus",
|
||||
port: 80
|
||||
};
|
||||
return resolvedPrometheusServices.filter(n => n)[0];
|
||||
}
|
||||
|
||||
async getPrometheusPath(): Promise<string> {
|
||||
|
||||
@ -38,7 +38,7 @@ export class PrometheusLens implements PrometheusProvider {
|
||||
cpuRequests:`sum(kube_pod_container_resource_requests{node=~"${opts.nodes}", resource="cpu"}) by (component)`,
|
||||
cpuLimits: `sum(kube_pod_container_resource_limits{node=~"${opts.nodes}", resource="cpu"}) by (component)`,
|
||||
cpuCapacity: `sum(kube_node_status_capacity{node=~"${opts.nodes}", resource="cpu"}) by (component)`,
|
||||
podUsage: `sum(kubelet_running_pod_count{instance=~"${opts.nodes}"})`,
|
||||
podUsage: `sum({__name__=~"kubelet_running_pod_count|kubelet_running_pods", instance=~"${opts.nodes}"})`,
|
||||
podCapacity: `sum(kube_node_status_capacity{node=~"${opts.nodes}", resource="pods"}) by (component)`,
|
||||
fsSize: `sum(node_filesystem_size_bytes{kubernetes_node=~"${opts.nodes}", mountpoint="/"}) by (kubernetes_node)`,
|
||||
fsUsage: `sum(node_filesystem_size_bytes{kubernetes_node=~"${opts.nodes}", mountpoint="/"} - node_filesystem_avail_bytes{kubernetes_node=~"${opts.nodes}", mountpoint="/"}) by (kubernetes_node)`
|
||||
|
||||
@ -46,7 +46,7 @@ export class PrometheusOperator implements PrometheusProvider {
|
||||
cpuRequests:`sum(kube_pod_container_resource_requests{node=~"${opts.nodes}", resource="cpu"})`,
|
||||
cpuLimits: `sum(kube_pod_container_resource_limits{node=~"${opts.nodes}", resource="cpu"})`,
|
||||
cpuCapacity: `sum(kube_node_status_capacity{node=~"${opts.nodes}", resource="cpu"})`,
|
||||
podUsage: `sum(kubelet_running_pod_count{node=~"${opts.nodes}"})`,
|
||||
podUsage: `sum({__name__=~"kubelet_running_pod_count|kubelet_running_pods", node=~"${opts.nodes}"})`,
|
||||
podCapacity: `sum(kube_node_status_capacity{node=~"${opts.nodes}", resource="pods"})`,
|
||||
fsSize: `sum(node_filesystem_size_bytes{mountpoint="/"} * on (pod,namespace) group_left(node) kube_pod_info{node=~"${opts.nodes}"})`,
|
||||
fsUsage: `sum(node_filesystem_size_bytes{mountpoint="/"} * on (pod,namespace) group_left(node) kube_pod_info{node=~"${opts.nodes}"} - node_filesystem_avail_bytes{mountpoint="/"} * on (pod,namespace) group_left(node) kube_pod_info{node=~"${opts.nodes}"})`
|
||||
|
||||
@ -38,7 +38,7 @@ export class PrometheusStacklight implements PrometheusProvider {
|
||||
cpuRequests:`sum(kube_pod_container_resource_requests{node=~"${opts.nodes}", resource="cpu"}) by (component)`,
|
||||
cpuLimits: `sum(kube_pod_container_resource_limits{node=~"${opts.nodes}", resource="cpu"}) by (component)`,
|
||||
cpuCapacity: `sum(kube_node_status_capacity{node=~"${opts.nodes}", resource="cpu"}) by (component)`,
|
||||
podUsage: `sum(kubelet_running_pod_count{instance=~"${opts.nodes}"})`,
|
||||
podUsage: `sum({__name__=~"kubelet_running_pod_count|kubelet_running_pods", instance=~"${opts.nodes}"})`,
|
||||
podCapacity: `sum(kube_node_status_capacity{node=~"${opts.nodes}", resource="pods"}) by (component)`,
|
||||
fsSize: `sum(node_filesystem_size_bytes{node=~"${opts.nodes}", mountpoint="/"}) by (node)`,
|
||||
fsUsage: `sum(node_filesystem_size_bytes{node=~"${opts.nodes}", mountpoint="/"} - node_filesystem_avail_bytes{node=~"${opts.nodes}", mountpoint="/"}) by (node)`
|
||||
|
||||
@ -1,7 +1,9 @@
|
||||
import _ from "lodash";
|
||||
import { LensApiRequest } from "../router";
|
||||
import { LensApi } from "../lens-api";
|
||||
import { Cluster } from "../cluster";
|
||||
import _ from "lodash";
|
||||
import { Cluster, ClusterMetadataKey } from "../cluster";
|
||||
import { ClusterPrometheusMetadata } from "../../common/cluster-store";
|
||||
import logger from "../logger";
|
||||
|
||||
export type IMetricsQuery = string | string[] | {
|
||||
[metricName: string]: string;
|
||||
@ -22,11 +24,9 @@ async function loadMetrics(promQueries: string[], cluster: Cluster, prometheusPa
|
||||
try {
|
||||
return await cluster.getMetrics(prometheusPath, { query, ...queryParams });
|
||||
} catch (error) {
|
||||
if (lastAttempt || error?.statusCode === 404) {
|
||||
return {
|
||||
status: error.toString(),
|
||||
data: { result: [] },
|
||||
};
|
||||
if (lastAttempt || (error?.statusCode >= 400 && error?.statusCode < 500)) {
|
||||
logger.error("[Metrics]: metrics not available", { error });
|
||||
throw new Error("Metrics not available");
|
||||
}
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, (attempt + 1) * 1000)); // add delay before repeating request
|
||||
@ -43,13 +43,19 @@ async function loadMetrics(promQueries: string[], cluster: Cluster, prometheusPa
|
||||
class MetricsRoute extends LensApi {
|
||||
async routeMetrics({ response, cluster, payload, query }: LensApiRequest) {
|
||||
const queryParams: IMetricsQuery = Object.fromEntries(query.entries());
|
||||
|
||||
const prometheusMetadata: ClusterPrometheusMetadata = {};
|
||||
try {
|
||||
const [prometheusPath, prometheusProvider] = await Promise.all([
|
||||
cluster.contextHandler.getPrometheusPath(),
|
||||
cluster.contextHandler.getPrometheusProvider()
|
||||
]);
|
||||
|
||||
prometheusMetadata.provider = prometheusProvider?.id;
|
||||
prometheusMetadata.autoDetected = !cluster.preferences.prometheusProvider?.type;
|
||||
if (!prometheusPath) {
|
||||
prometheusMetadata.success = false;
|
||||
this.respondJson(response, {});
|
||||
return;
|
||||
}
|
||||
// return data in same structure as query
|
||||
if (typeof payload === "string") {
|
||||
const [data] = await loadMetrics([payload], cluster, prometheusPath, queryParams);
|
||||
@ -65,8 +71,12 @@ class MetricsRoute extends LensApi {
|
||||
const data = Object.fromEntries(Object.keys(payload).map((metricName, i) => [metricName, result[i]]));
|
||||
this.respondJson(response, data);
|
||||
}
|
||||
prometheusMetadata.success = true;
|
||||
} catch {
|
||||
prometheusMetadata.success = false;
|
||||
this.respondJson(response, {});
|
||||
} finally {
|
||||
cluster.metadata[ClusterMetadataKey.PROMETHEUS] = prometheusMetadata;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,6 +4,29 @@ import { IAffinity, WorkloadKubeObject } from "../workload-kube-object";
|
||||
import { autobind } from "../../utils";
|
||||
import { KubeApi } from "../kube-api";
|
||||
|
||||
export class StatefulSetApi extends KubeApi<StatefulSet> {
|
||||
protected getScaleApiUrl(params: { namespace: string; name: string }) {
|
||||
return this.getUrl(params) + "/scale";
|
||||
}
|
||||
|
||||
getReplicas(params: { namespace: string; name: string }): Promise<number> {
|
||||
return this.request
|
||||
.get(this.getScaleApiUrl(params))
|
||||
.then(({ status }: any) => status?.replicas);
|
||||
}
|
||||
|
||||
scale(params: { namespace: string; name: string }, replicas: number) {
|
||||
return this.request.put(this.getScaleApiUrl(params), {
|
||||
data: {
|
||||
metadata: params,
|
||||
spec: {
|
||||
replicas
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@autobind()
|
||||
export class StatefulSet extends WorkloadKubeObject {
|
||||
static kind = "StatefulSet";
|
||||
@ -67,17 +90,22 @@ export class StatefulSet extends WorkloadKubeObject {
|
||||
observedGeneration: number;
|
||||
replicas: number;
|
||||
currentReplicas: number;
|
||||
readyReplicas: number;
|
||||
currentRevision: string;
|
||||
updateRevision: string;
|
||||
collisionCount: number;
|
||||
};
|
||||
|
||||
getReplicas() {
|
||||
return this.spec.replicas || 0;
|
||||
}
|
||||
|
||||
getImages() {
|
||||
const containers: IPodContainer[] = get(this, "spec.template.spec.containers", []);
|
||||
return [...containers].map(container => container.image);
|
||||
}
|
||||
}
|
||||
|
||||
export const statefulSetApi = new KubeApi({
|
||||
export const statefulSetApi = new StatefulSetApi({
|
||||
objectConstructor: StatefulSet,
|
||||
});
|
||||
|
||||
@ -1,16 +1,20 @@
|
||||
.AddCluster {
|
||||
.hint {
|
||||
margin-top: -$padding;
|
||||
color: $textColorSecondary;
|
||||
|
||||
> * {
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
.AddClusters {
|
||||
--flex-gap: #{$unit * 2};
|
||||
$spacing: $padding * 2;
|
||||
|
||||
.AceEditor {
|
||||
min-height: 200px;
|
||||
max-height: 400px;
|
||||
border: 1px solid var(--colorVague);
|
||||
border-radius: $radius;
|
||||
|
||||
.theme-light & {
|
||||
border-color: var(--borderFaintColor);
|
||||
}
|
||||
|
||||
.editor {
|
||||
border-radius: $radius;
|
||||
}
|
||||
}
|
||||
|
||||
.Select {
|
||||
@ -34,4 +38,13 @@
|
||||
code {
|
||||
color: $pink-400;
|
||||
}
|
||||
|
||||
.text-primary {
|
||||
color: var(--textColorAccent);
|
||||
}
|
||||
|
||||
.hint {
|
||||
display: block;
|
||||
padding-top: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
@ -25,6 +25,8 @@ import { Notifications } from "../notifications";
|
||||
import { Tab, Tabs } from "../tabs";
|
||||
import { ExecValidationNotFoundError } from "../../../common/custom-errors";
|
||||
import { appEventBus } from "../../../common/event-bus";
|
||||
import { PageLayout } from "../layout/page-layout";
|
||||
import { docsUrl } from "../../../common/vars";
|
||||
|
||||
enum KubeConfigSourceTab {
|
||||
FILE = "file",
|
||||
@ -193,44 +195,19 @@ export class AddCluster extends React.Component {
|
||||
|
||||
renderInfo() {
|
||||
return (
|
||||
<Fragment>
|
||||
<h2>Clusters associated with Lens</h2>
|
||||
<p>
|
||||
Add clusters by clicking the <span className="text-primary">Add Cluster</span> button.
|
||||
You'll need to obtain a working kubeconfig for the cluster you want to add. You can either browse it from the file system or paste it as a text from the clipboard.
|
||||
</p>
|
||||
<p>
|
||||
Selected <a href="https://kubernetes.io/docs/concepts/configuration/organize-cluster-access-kubeconfig/#context" target="_blank">cluster contexts</a> are added as a separate item in the
|
||||
left-side cluster menu to allow you to operate easily on multiple clusters and/or contexts.
|
||||
</p>
|
||||
<p>
|
||||
For more information on kubeconfig see <a href="https://kubernetes.io/docs/concepts/configuration/organize-cluster-access-kubeconfig/" target="_blank">Kubernetes docs</a>.
|
||||
</p>
|
||||
<p>
|
||||
NOTE: Any manually added cluster is not merged into your kubeconfig file.
|
||||
</p>
|
||||
<p>
|
||||
To see your currently enabled config with <code>kubectl</code>, use <code>kubectl config view --minify --raw</code> command in your terminal.
|
||||
</p>
|
||||
<p>
|
||||
When connecting to a cluster, make sure you have a valid and working kubeconfig for the cluster. Following lists known "gotchas" in some authentication types used in kubeconfig with Lens
|
||||
app.
|
||||
</p>
|
||||
<h3>Exec auth plugins</h3>
|
||||
<p>
|
||||
When using <a href="https://kubernetes.io/docs/reference/access-authn-authz/authentication/#configuration" target="_blank">exec auth</a> plugins make sure the paths that are used to call
|
||||
any binaries
|
||||
are full paths as Lens app might not be able to call binaries with relative paths. Make also sure that you pass all needed information either as arguments or env variables in the config,
|
||||
Lens app might not have all login shell env variables set automatically.
|
||||
</p>
|
||||
</Fragment>
|
||||
<p>
|
||||
Add clusters by clicking the <span className="text-primary">Add Cluster</span> button.
|
||||
You'll need to obtain a working kubeconfig for the cluster you want to add.
|
||||
You can either browse it from the file system or paste it as a text from the clipboard.
|
||||
Read more about adding clusters <a href={`${docsUrl}/latest/clusters/adding-clusters/`} target="_blank">here</a>.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
renderKubeConfigSource() {
|
||||
return (
|
||||
<>
|
||||
<Tabs withBorder onChange={this.onKubeConfigTabChange}>
|
||||
<Tabs onChange={this.onKubeConfigTabChange}>
|
||||
<Tab
|
||||
value={KubeConfigSourceTab.FILE}
|
||||
label={<Trans>Select kubeconfig file</Trans>}
|
||||
@ -242,7 +219,7 @@ export class AddCluster extends React.Component {
|
||||
/>
|
||||
</Tabs>
|
||||
{this.sourceTab === KubeConfigSourceTab.FILE && (
|
||||
<>
|
||||
<div>
|
||||
<div className="kube-config-select flex gaps align-center">
|
||||
<Input
|
||||
theme="round-black"
|
||||
@ -267,10 +244,10 @@ export class AddCluster extends React.Component {
|
||||
<small className="hint">
|
||||
<Trans>Pro-Tip: you can also drag-n-drop kubeconfig file to this area</Trans>
|
||||
</small>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
{this.sourceTab === KubeConfigSourceTab.TEXT && (
|
||||
<>
|
||||
<div className="flex column">
|
||||
<AceEditor
|
||||
autoFocus
|
||||
showGutter={false}
|
||||
@ -284,7 +261,7 @@ export class AddCluster extends React.Component {
|
||||
<small className="hint">
|
||||
<Trans>Pro-Tip: paste kubeconfig to get available contexts</Trans>
|
||||
</small>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
@ -296,7 +273,7 @@ export class AddCluster extends React.Component {
|
||||
? <Trans>Selected contexts: <b>{this.selectedContexts.length}</b></Trans>
|
||||
: <Trans>Select contexts</Trans>;
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<Select
|
||||
id="kubecontext-select" // todo: provide better mapping for integration tests (e.g. data-test-id="..")
|
||||
placeholder={placeholder}
|
||||
@ -320,7 +297,7 @@ export class AddCluster extends React.Component {
|
||||
<code>{this.selectedContexts.join(", ")}</code>
|
||||
</small>
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -357,9 +334,12 @@ export class AddCluster extends React.Component {
|
||||
render() {
|
||||
const addDisabled = this.selectedContexts.length === 0;
|
||||
return (
|
||||
<DropFileInput onDropFiles={this.onDropKubeConfig}>
|
||||
<WizardLayout className="AddCluster" infoPanel={this.renderInfo()}>
|
||||
<h2><Trans>Add Cluster</Trans></h2>
|
||||
<PageLayout className="AddClusters" header={<h2>Add Clusters</h2>}>
|
||||
<h2>Add Clusters from Kubeconfig</h2>
|
||||
|
||||
{this.renderInfo()}
|
||||
|
||||
<DropFileInput onDropFiles={this.onDropKubeConfig}>
|
||||
{this.renderKubeConfigSource()}
|
||||
{this.renderContextSelector()}
|
||||
<div className="cluster-settings">
|
||||
@ -384,19 +364,20 @@ export class AddCluster extends React.Component {
|
||||
{this.error && (
|
||||
<div className="error">{this.error}</div>
|
||||
)}
|
||||
|
||||
<div className="actions-panel">
|
||||
<Button
|
||||
primary
|
||||
disabled={addDisabled}
|
||||
label={<Trans>Add cluster(s)</Trans>}
|
||||
label={this.selectedContexts.length < 2 ? <Trans>Add cluster</Trans> : <Trans>Add clusters</Trans>}
|
||||
onClick={this.addClusters}
|
||||
waiting={this.isWaiting}
|
||||
tooltip={addDisabled ? _i18n._("Select at least one cluster to add.") : undefined}
|
||||
tooltipOverrideDisabled
|
||||
/>
|
||||
</div>
|
||||
</WizardLayout>
|
||||
</DropFileInput>
|
||||
</DropFileInput>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,8 +2,7 @@ import React from "react";
|
||||
import { observable, autorun } from "mobx";
|
||||
import { observer, disposeOnUnmount } from "mobx-react";
|
||||
import { Cluster } from "../../../../main/cluster";
|
||||
import { Input } from "../../input";
|
||||
import { isUrl } from "../../input/input_validators";
|
||||
import { Input, InputValidators } from "../../input";
|
||||
import { SubTitle } from "../../layout/sub-title";
|
||||
|
||||
interface Props {
|
||||
@ -41,7 +40,7 @@ export class ClusterProxySetting extends React.Component<Props> {
|
||||
onChange={this.onChange}
|
||||
onBlur={this.save}
|
||||
placeholder="http://<address>:<port>"
|
||||
validators={isUrl}
|
||||
validators={this.proxy ? InputValidators.isUrl : undefined}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@ -1,61 +1,40 @@
|
||||
.Extensions {
|
||||
.PageLayout.Extensions {
|
||||
$spacing: $padding * 2;
|
||||
--width: 100%;
|
||||
--max-width: auto;
|
||||
--width: 50%;
|
||||
|
||||
h2 {
|
||||
margin-bottom: $padding;
|
||||
}
|
||||
|
||||
.no-extensions {
|
||||
--flex-gap: #{$padding};
|
||||
padding: $padding;
|
||||
|
||||
code {
|
||||
font-size: $font-size-small;
|
||||
}
|
||||
}
|
||||
|
||||
.install-extension {
|
||||
margin: $spacing * 2 0;
|
||||
}
|
||||
|
||||
.installed-extensions {
|
||||
--flex-gap: #{$spacing};
|
||||
|
||||
.extensions-list {
|
||||
.extension {
|
||||
--flex-gap: $padding / 3;
|
||||
padding: $padding $spacing;
|
||||
background: $colorVague;
|
||||
background: $layoutBackground;
|
||||
border-radius: $radius;
|
||||
|
||||
&:not(:first-of-type) {
|
||||
margin-top: $spacing;
|
||||
.actions > button:not(:last-child) {
|
||||
margin-right: $spacing / 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.extensions-info {
|
||||
--flex-gap: #{$spacing};
|
||||
|
||||
> .flex.gaps {
|
||||
--flex-gap: #{$padding};
|
||||
}
|
||||
}
|
||||
|
||||
.extensions-path {
|
||||
word-break: break-all;
|
||||
|
||||
&:hover code {
|
||||
color: $textColorSecondary;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.Clipboard {
|
||||
display: inline;
|
||||
vertical-align: baseline;
|
||||
font-size: $font-size-small;
|
||||
|
||||
&:hover {
|
||||
color: $textColorSecondary;
|
||||
}
|
||||
}
|
||||
|
||||
.SearchInput {
|
||||
--spacing: #{$padding};
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.WizardLayout {
|
||||
padding: 0;
|
||||
|
||||
.info-col {
|
||||
flex: 0.6;
|
||||
align-self: flex-start;
|
||||
}
|
||||
--spacing: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,26 +1,27 @@
|
||||
import "./extensions.scss";
|
||||
import { t, Trans } from "@lingui/macro";
|
||||
import { remote, shell } from "electron";
|
||||
import os from "os";
|
||||
import path from "path";
|
||||
import fse from "fs-extra";
|
||||
import React from "react";
|
||||
import { computed, observable } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import { t, Trans } from "@lingui/macro";
|
||||
import { _i18n } from "../../i18n";
|
||||
import { Button } from "../button";
|
||||
import { WizardLayout } from "../layout/wizard-layout";
|
||||
import { DropFileInput, Input, InputValidators, SearchInput } from "../input";
|
||||
import { Icon } from "../icon";
|
||||
import { SubTitle } from "../layout/sub-title";
|
||||
import { PageLayout } from "../layout/page-layout";
|
||||
import logger from "../../../main/logger";
|
||||
import { extensionLoader } from "../../../extensions/extension-loader";
|
||||
import { extensionDiscovery, manifestFilename } from "../../../extensions/extension-discovery";
|
||||
import { LensExtensionManifest, sanitizeExtensionName } from "../../../extensions/lens-extension";
|
||||
import { Notifications } from "../notifications";
|
||||
import os from "os";
|
||||
import path from "path";
|
||||
import React from "react";
|
||||
import { downloadFile, extractTar, listTarEntries, readFileFromTar } from "../../../common/utils";
|
||||
import { docsUrl } from "../../../common/vars";
|
||||
import { extensionDiscovery, InstalledExtension, manifestFilename } from "../../../extensions/extension-discovery";
|
||||
import { extensionLoader } from "../../../extensions/extension-loader";
|
||||
import { extensionDisplayName, LensExtensionManifest, sanitizeExtensionName } from "../../../extensions/lens-extension";
|
||||
import logger from "../../../main/logger";
|
||||
import { _i18n } from "../../i18n";
|
||||
import { prevDefault } from "../../utils";
|
||||
import { Button } from "../button";
|
||||
import { Icon } from "../icon";
|
||||
import { DropFileInput, Input, InputValidator, InputValidators, SearchInput } from "../input";
|
||||
import { PageLayout } from "../layout/page-layout";
|
||||
import { SubTitle } from "../layout/sub-title";
|
||||
import { Notifications } from "../notifications";
|
||||
import { TooltipPosition } from "../tooltip";
|
||||
import "./extensions.scss";
|
||||
|
||||
interface InstallRequest {
|
||||
fileName: string;
|
||||
@ -40,8 +41,16 @@ interface InstallRequestValidated extends InstallRequestPreloaded {
|
||||
@observer
|
||||
export class Extensions extends React.Component {
|
||||
private supportedFormats = [".tar", ".tgz"];
|
||||
|
||||
private installPathValidator: InputValidator = {
|
||||
message: <Trans>Invalid URL or absolute path</Trans>,
|
||||
validate(value: string) {
|
||||
return InputValidators.isUrl.validate(value) || InputValidators.isPath.validate(value);
|
||||
}
|
||||
};
|
||||
|
||||
@observable search = "";
|
||||
@observable downloadUrl = "";
|
||||
@observable installPath = "";
|
||||
|
||||
@computed get extensions() {
|
||||
const searchText = this.search.toLowerCase();
|
||||
@ -87,25 +96,25 @@ export class Extensions extends React.Component {
|
||||
}
|
||||
};
|
||||
|
||||
addExtensions = () => {
|
||||
const { downloadUrl } = this;
|
||||
if (downloadUrl && InputValidators.isUrl.validate(downloadUrl)) {
|
||||
this.installFromUrl(downloadUrl);
|
||||
} else {
|
||||
this.installFromSelectFileDialog();
|
||||
}
|
||||
};
|
||||
|
||||
installFromUrl = async (url: string) => {
|
||||
installFromUrlOrPath = async () => {
|
||||
const { installPath } = this;
|
||||
if (!installPath) return;
|
||||
const fileName = path.basename(installPath);
|
||||
try {
|
||||
const { promise: filePromise } = downloadFile({ url });
|
||||
this.requestInstall([{
|
||||
fileName: path.basename(url),
|
||||
data: await filePromise,
|
||||
}]);
|
||||
} catch (err) {
|
||||
// install via url
|
||||
// fixme: improve error messages for non-tar-file URLs
|
||||
if (InputValidators.isUrl.validate(installPath)) {
|
||||
const { promise: filePromise } = downloadFile({ url: installPath, timeout: 60000 /*1m*/ });
|
||||
const data = await filePromise;
|
||||
this.requestInstall({ fileName, data });
|
||||
}
|
||||
// otherwise installing from system path
|
||||
else if (InputValidators.isPath.validate(installPath)) {
|
||||
this.requestInstall({ fileName, filePath: installPath });
|
||||
}
|
||||
} catch (error) {
|
||||
Notifications.error(
|
||||
<p>Installation via URL has failed: <b>{String(err)}</b></p>
|
||||
<p>Installation has failed: <b>{String(error)}</b></p>
|
||||
);
|
||||
}
|
||||
};
|
||||
@ -122,6 +131,7 @@ export class Extensions extends React.Component {
|
||||
|
||||
async preloadExtensions(requests: InstallRequest[], { showError = true } = {}) {
|
||||
const preloadedRequests = requests.filter(req => req.data);
|
||||
|
||||
await Promise.all(
|
||||
requests
|
||||
.filter(req => !req.data && req.filePath)
|
||||
@ -129,13 +139,14 @@ export class Extensions extends React.Component {
|
||||
return fse.readFile(req.filePath).then(data => {
|
||||
req.data = data;
|
||||
preloadedRequests.push(req);
|
||||
}).catch(err => {
|
||||
}).catch(error => {
|
||||
if (showError) {
|
||||
Notifications.error(`Error while reading "${req.filePath}": ${String(err)}`);
|
||||
Notifications.error(`Error while reading "${req.filePath}": ${String(error)}`);
|
||||
}
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
return preloadedRequests as InstallRequestPreloaded[];
|
||||
}
|
||||
|
||||
@ -182,13 +193,13 @@ export class Extensions extends React.Component {
|
||||
manifest,
|
||||
tempFile,
|
||||
});
|
||||
} catch (err) {
|
||||
} catch (error) {
|
||||
fse.unlink(tempFile).catch(() => null); // remove invalid temp package
|
||||
if (showErrors) {
|
||||
Notifications.error(
|
||||
<div className="flex column gaps">
|
||||
<p>Installing <em>{req.fileName}</em> has failed, skipping.</p>
|
||||
<p>Reason: <em>{String(err)}</em></p>
|
||||
<p>Reason: <em>{String(error)}</em></p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -198,7 +209,8 @@ export class Extensions extends React.Component {
|
||||
return validatedRequests;
|
||||
}
|
||||
|
||||
async requestInstall(requests: InstallRequest[]) {
|
||||
async requestInstall(init: InstallRequest | InstallRequest[]) {
|
||||
const requests = Array.isArray(init) ? init : [init];
|
||||
const preloadedRequests = await this.preloadExtensions(requests);
|
||||
const validatedRequests = await this.createTempFilesAndValidate(preloadedRequests);
|
||||
|
||||
@ -231,7 +243,7 @@ export class Extensions extends React.Component {
|
||||
}
|
||||
|
||||
async unpackExtension({ fileName, tempFile, manifest: { name, version } }: InstallRequestValidated) {
|
||||
const extName = `${name}@${version}`;
|
||||
const extName = extensionDisplayName(name, version);
|
||||
logger.info(`Unpacking extension ${extName}`, { fileName, tempFile });
|
||||
const unpackingTempFolder = path.join(path.dirname(tempFile), path.basename(tempFile) + "-unpacked");
|
||||
const extensionFolder = this.getExtensionDestFolder(name);
|
||||
@ -254,9 +266,9 @@ export class Extensions extends React.Component {
|
||||
Notifications.ok(
|
||||
<p>Extension <b>{extName}</b> successfully installed!</p>
|
||||
);
|
||||
} catch (err) {
|
||||
} catch (error) {
|
||||
Notifications.error(
|
||||
<p>Installing extension <b>{extName}</b> has failed: <em>{err}</em></p>
|
||||
<p>Installing extension <b>{extName}</b> has failed: <em>{error}</em></p>
|
||||
);
|
||||
} finally {
|
||||
// clean up
|
||||
@ -265,92 +277,122 @@ export class Extensions extends React.Component {
|
||||
}
|
||||
}
|
||||
|
||||
renderInfo() {
|
||||
return (
|
||||
<div className="extensions-info flex column gaps">
|
||||
<h2>Lens Extensions</h2>
|
||||
<div>
|
||||
The features that Lens includes out-of-the-box are just the start.
|
||||
Lens extensions let you add new features to your installation to support your workflow.
|
||||
Rich extensibility model lets extension authors plug directly into the Lens UI and contribute functionality through the same APIs used by Lens itself.
|
||||
Check out documentation to <a href={`${docsUrl}/latest/extensions/usage/`} target="_blank">learn more</a>.
|
||||
</div>
|
||||
<div className="install-extension flex column gaps">
|
||||
<SubTitle title="Install extension:"/>
|
||||
<Input
|
||||
showErrorsAsTooltip={true}
|
||||
className="box grow"
|
||||
theme="round-black"
|
||||
iconLeft="link"
|
||||
placeholder={`URL to an extension package (${this.supportedFormats.join(", ")})`}
|
||||
validators={InputValidators.isUrl}
|
||||
value={this.downloadUrl}
|
||||
onChange={v => this.downloadUrl = v}
|
||||
onSubmit={this.addExtensions}
|
||||
/>
|
||||
<Button
|
||||
primary
|
||||
label="Install"
|
||||
onClick={this.addExtensions}
|
||||
/>
|
||||
<p className="hint">
|
||||
<Trans><b>Pro-Tip</b>: you can drag & drop extension's tarball here to request installation</Trans>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
async uninstallExtension(extension: InstalledExtension) {
|
||||
const extensionName = extensionDisplayName(extension.manifest.name, extension.manifest.version);
|
||||
|
||||
try {
|
||||
await extensionDiscovery.uninstallExtension(extension.absolutePath);
|
||||
} catch (error) {
|
||||
Notifications.error(
|
||||
<p>Uninstalling extension <b>{extensionName}</b> has failed: <em>{error?.message ?? ""}</em></p>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
renderExtensions() {
|
||||
const { extensions, extensionsPath, search } = this;
|
||||
|
||||
if (!extensions.length) {
|
||||
return (
|
||||
<div className="flex align-center box grow justify-center gaps">
|
||||
{search && <Trans>No search results found</Trans>}
|
||||
{!search && <p><Trans>There are no extensions in</Trans> <code>{extensionsPath}</code></p>}
|
||||
<div className="no-extensions flex box gaps justify-center">
|
||||
<Icon material="info"/>
|
||||
<div>
|
||||
{search && <p>No search results found</p>}
|
||||
{!search && <p>There are no extensions in <code>{extensionsPath}</code></p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return extensions.map(ext => {
|
||||
const { manifestPath: extId, isEnabled, manifest } = ext;
|
||||
const { name, description } = manifest;
|
||||
|
||||
return (
|
||||
<div key={extId} className="extension flex gaps align-center">
|
||||
<div className="box grow flex column gaps">
|
||||
<div className="package">
|
||||
<div className="box grow">
|
||||
<div className="name">
|
||||
Name: <code className="name">{name}</code>
|
||||
</div>
|
||||
<div>
|
||||
<div className="description">
|
||||
Description: <span className="text-secondary">{description}</span>
|
||||
</div>
|
||||
</div>
|
||||
{!isEnabled && (
|
||||
<Button plain active onClick={() => ext.isEnabled = true}>Enable</Button>
|
||||
)}
|
||||
{isEnabled && (
|
||||
<Button accent onClick={() => ext.isEnabled = false}>Disable</Button>
|
||||
)}
|
||||
<div className="actions">
|
||||
{!isEnabled && (
|
||||
<Button plain active onClick={() => ext.isEnabled = true}>Enable</Button>
|
||||
)}
|
||||
{isEnabled && (
|
||||
<Button accent onClick={() => ext.isEnabled = false}>Disable</Button>
|
||||
)}
|
||||
<Button plain active onClick={() => {
|
||||
this.uninstallExtension(ext);
|
||||
}}>Uninstall</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
const topHeader = <h2>Manage Lens Extensions</h2>;
|
||||
const { installPath } = this;
|
||||
return (
|
||||
<PageLayout showOnTop className="Extensions" header={<h2>Extensions</h2>}>
|
||||
<DropFileInput onDropFiles={this.installOnDrop}>
|
||||
<WizardLayout infoPanel={this.renderInfo()}>
|
||||
<DropFileInput onDropFiles={this.installOnDrop}>
|
||||
<PageLayout showOnTop className="Extensions flex column gaps" header={topHeader} contentGaps={false}>
|
||||
<h2>Lens Extensions</h2>
|
||||
<div>
|
||||
The features that Lens includes out-of-the-box are just the start.
|
||||
Lens extensions let you add new features to your installation to support your workflow.
|
||||
Rich extensibility model lets extension authors plug directly into the Lens UI and contribute functionality through the same APIs used by Lens itself.
|
||||
Check out documentation to <a href={`${docsUrl}/latest/extensions/usage/`} target="_blank">learn more</a>.
|
||||
</div>
|
||||
|
||||
<div className="install-extension flex column gaps">
|
||||
<SubTitle title={<Trans>Install Extension:</Trans>}/>
|
||||
<div className="extension-input flex box gaps align-center">
|
||||
<Input
|
||||
className="box grow"
|
||||
theme="round-black"
|
||||
placeholder={`Path or URL to an extension package (${this.supportedFormats.join(", ")})`}
|
||||
showErrorsAsTooltip={{ preferredPositions: TooltipPosition.BOTTOM }}
|
||||
validators={installPath ? this.installPathValidator : undefined}
|
||||
value={installPath}
|
||||
onChange={v => this.installPath = v}
|
||||
onSubmit={this.installFromUrlOrPath}
|
||||
iconLeft="link"
|
||||
iconRight={
|
||||
<Icon
|
||||
interactive
|
||||
material="folder"
|
||||
onMouseDown={prevDefault(this.installFromSelectFileDialog)}
|
||||
tooltip={<Trans>Browse</Trans>}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
primary
|
||||
label="Install"
|
||||
disabled={!this.installPathValidator.validate(installPath)}
|
||||
onClick={this.installFromUrlOrPath}
|
||||
/>
|
||||
<small className="hint">
|
||||
<Trans><b>Pro-Tip</b>: you can drag & drop extension's tarball-file to install</Trans>
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<h2>Installed Extensions</h2>
|
||||
<div className="installed-extensions flex column gaps">
|
||||
<SearchInput
|
||||
placeholder={_i18n._(t`Search installed extensions`)}
|
||||
placeholder="Search extensions by name or description"
|
||||
value={this.search}
|
||||
onChange={(value) => this.search = value}
|
||||
/>
|
||||
<div className="extensions-list">
|
||||
{this.renderExtensions()}
|
||||
</div>
|
||||
</WizardLayout>
|
||||
</DropFileInput>
|
||||
</PageLayout>
|
||||
{this.renderExtensions()}
|
||||
</div>
|
||||
</PageLayout>
|
||||
</DropFileInput>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,8 +1,7 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Trans } from '@lingui/macro';
|
||||
import { isPath } from '../input/input_validators';
|
||||
import { Checkbox } from '../checkbox';
|
||||
import { Input } from '../input';
|
||||
import { Input, InputValidators } from '../input';
|
||||
import { SubTitle } from '../layout/sub-title';
|
||||
import { UserPreferences, userStore } from '../../../common/user-store';
|
||||
import { observer } from 'mobx-react';
|
||||
@ -12,6 +11,7 @@ import { SelectOption, Select } from '../select';
|
||||
export const KubectlBinaries = observer(({ preferences }: { preferences: UserPreferences }) => {
|
||||
const [downloadPath, setDownloadPath] = useState(preferences.downloadBinariesPath || "");
|
||||
const [binariesPath, setBinariesPath] = useState(preferences.kubectlBinariesPath || "");
|
||||
const pathValidator = downloadPath ? InputValidators.isPath : undefined;
|
||||
|
||||
const downloadMirrorOptions: SelectOption<string>[] = [
|
||||
{ value: "default", label: "Default (Google)" },
|
||||
@ -47,7 +47,7 @@ export const KubectlBinaries = observer(({ preferences }: { preferences: UserPre
|
||||
theme="round-black"
|
||||
value={downloadPath}
|
||||
placeholder={userStore.getDefaultKubectlPath()}
|
||||
validators={isPath}
|
||||
validators={pathValidator}
|
||||
onChange={setDownloadPath}
|
||||
onBlur={save}
|
||||
disabled={!preferences.downloadKubectlBinaries}
|
||||
@ -60,7 +60,7 @@ export const KubectlBinaries = observer(({ preferences }: { preferences: UserPre
|
||||
theme="round-black"
|
||||
placeholder={bundledKubectlPath()}
|
||||
value={binariesPath}
|
||||
validators={isPath}
|
||||
validators={pathValidator}
|
||||
onChange={setBinariesPath}
|
||||
onBlur={save}
|
||||
disabled={preferences.downloadKubectlBinaries}
|
||||
|
||||
@ -0,0 +1,49 @@
|
||||
.StatefulSetScaleDialog {
|
||||
.Wizard {
|
||||
.header {
|
||||
span {
|
||||
color: #a0a0a0;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
|
||||
.WizardStep {
|
||||
.step-content {
|
||||
min-height: 90px;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.current-scale {
|
||||
font-weight: bold
|
||||
}
|
||||
|
||||
.desired-scale {
|
||||
flex: 1.1 0;
|
||||
}
|
||||
|
||||
.slider-container {
|
||||
flex: 1 0;
|
||||
}
|
||||
|
||||
.plus-minus-container {
|
||||
margin-left: $margin * 2;
|
||||
.Icon {
|
||||
--color-active: black;
|
||||
}
|
||||
}
|
||||
|
||||
.warning {
|
||||
color: $colorSoftError;
|
||||
font-size: small;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.Icon {
|
||||
margin: 0;
|
||||
margin-right: $margin;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,167 @@
|
||||
import '@testing-library/jest-dom/extend-expect';
|
||||
|
||||
jest.mock("../../api/endpoints");
|
||||
import { statefulSetApi } from "../../api/endpoints";
|
||||
import { StatefulSetScaleDialog } from "./statefulset-scale-dialog";
|
||||
import { render, waitFor, fireEvent } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
|
||||
const dummyStatefulSet = {
|
||||
apiVersion: 'v1',
|
||||
kind: 'dummy',
|
||||
metadata: {
|
||||
uid: 'dummy',
|
||||
name: 'dummy',
|
||||
creationTimestamp: 'dummy',
|
||||
resourceVersion: 'dummy',
|
||||
selfLink: 'link',
|
||||
},
|
||||
selfLink: 'link',
|
||||
|
||||
spec: {
|
||||
serviceName: 'dummy',
|
||||
replicas: 1,
|
||||
selector: {
|
||||
matchLabels: { 'label': 'label' }
|
||||
},
|
||||
template: {
|
||||
metadata: {
|
||||
labels: {
|
||||
app: 'app',
|
||||
},
|
||||
},
|
||||
spec: {
|
||||
containers: [{
|
||||
name: 'dummy',
|
||||
image: 'dummy',
|
||||
ports: [{
|
||||
containerPort: 1234,
|
||||
name: 'dummy',
|
||||
}],
|
||||
volumeMounts: [{
|
||||
name: 'dummy',
|
||||
mountPath: 'dummy',
|
||||
}],
|
||||
}],
|
||||
tolerations: [{
|
||||
key: 'dummy',
|
||||
operator: 'dummy',
|
||||
effect: 'dummy',
|
||||
tolerationSeconds: 1,
|
||||
}],
|
||||
},
|
||||
},
|
||||
volumeClaimTemplates: [{
|
||||
metadata: {
|
||||
name: 'dummy',
|
||||
},
|
||||
spec: {
|
||||
accessModes: ['dummy'],
|
||||
resources: {
|
||||
requests: {
|
||||
storage: 'dummy',
|
||||
},
|
||||
},
|
||||
},
|
||||
}],
|
||||
},
|
||||
status: {
|
||||
observedGeneration: 1,
|
||||
replicas: 1,
|
||||
currentReplicas: 1,
|
||||
readyReplicas: 1,
|
||||
currentRevision: 'dummy',
|
||||
updateRevision: 'dummy',
|
||||
collisionCount: 1,
|
||||
},
|
||||
|
||||
getImages: jest.fn(),
|
||||
getReplicas: jest.fn(),
|
||||
getSelectors: jest.fn(),
|
||||
getTemplateLabels: jest.fn(),
|
||||
getAffinity: jest.fn(),
|
||||
getTolerations: jest.fn(),
|
||||
getNodeSelectors: jest.fn(),
|
||||
getAffinityNumber: jest.fn(),
|
||||
getId: jest.fn(),
|
||||
getResourceVersion: jest.fn(),
|
||||
getName: jest.fn(),
|
||||
getNs: jest.fn(),
|
||||
getAge: jest.fn(),
|
||||
getFinalizers: jest.fn(),
|
||||
getLabels: jest.fn(),
|
||||
getAnnotations: jest.fn(),
|
||||
getOwnerRefs: jest.fn(),
|
||||
getSearchFields: jest.fn(),
|
||||
toPlainObject: jest.fn(),
|
||||
update: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
};
|
||||
|
||||
describe('<StatefulSetScaleDialog />', () => {
|
||||
it('renders w/o errors', () => {
|
||||
const { container } = render(<StatefulSetScaleDialog/>);
|
||||
expect(container).toBeInstanceOf(HTMLElement);
|
||||
});
|
||||
|
||||
it('init with a dummy stateful set and mocked current/desired scale', async () => {
|
||||
// mock statefulSetApi.getReplicas() which will be called
|
||||
// when <StatefulSetScaleDialog /> rendered.
|
||||
const initReplicas = 1;
|
||||
statefulSetApi.getReplicas = jest.fn().mockImplementationOnce(async () => initReplicas);
|
||||
const { getByTestId } = render(<StatefulSetScaleDialog/>);
|
||||
StatefulSetScaleDialog.open(dummyStatefulSet);
|
||||
// we need to wait for the StatefulSetScaleDialog to show up
|
||||
// because there is an <Animate /> in <Dialog /> which renders null at start.
|
||||
await waitFor(async () => {
|
||||
const [currentScale, desiredScale] = await Promise.all([
|
||||
getByTestId('current-scale'),
|
||||
getByTestId('desired-scale'),
|
||||
]);
|
||||
expect(currentScale).toHaveTextContent(`${initReplicas}`);
|
||||
expect(desiredScale).toHaveTextContent(`${initReplicas}`);
|
||||
});
|
||||
});
|
||||
|
||||
it('changes the desired scale when clicking the icon buttons +/-', async () => {
|
||||
const initReplicas = 1;
|
||||
statefulSetApi.getReplicas = jest.fn().mockImplementationOnce(async () => initReplicas);
|
||||
const component = render(<StatefulSetScaleDialog/>);
|
||||
StatefulSetScaleDialog.open(dummyStatefulSet);
|
||||
await waitFor(async () => {
|
||||
expect(await component.findByTestId('desired-scale')).toHaveTextContent(`${initReplicas}`);
|
||||
expect(await component.findByTestId('current-scale')).toHaveTextContent(`${initReplicas}`);
|
||||
expect((await component.baseElement.querySelector('input').value)).toBe(`${initReplicas}`);
|
||||
});
|
||||
|
||||
const up = await component.findByTestId('desired-replicas-up');
|
||||
const down = await component.findByTestId('desired-replicas-down');
|
||||
fireEvent.click(up);
|
||||
expect(await component.findByTestId('desired-scale')).toHaveTextContent(`${initReplicas + 1}`);
|
||||
expect(await component.findByTestId('current-scale')).toHaveTextContent(`${initReplicas}`);
|
||||
expect((await component.baseElement.querySelector('input').value)).toBe(`${initReplicas + 1}`);
|
||||
|
||||
fireEvent.click(down);
|
||||
expect(await component.findByTestId('desired-scale')).toHaveTextContent(`${initReplicas}`);
|
||||
expect(await component.findByTestId('current-scale')).toHaveTextContent(`${initReplicas}`);
|
||||
expect((await component.baseElement.querySelector('input').value)).toBe(`${initReplicas}`);
|
||||
|
||||
// edge case, desiredScale must >= 0
|
||||
let times = 10;
|
||||
for (let i = 0; i < times; i++) {
|
||||
fireEvent.click(down);
|
||||
}
|
||||
expect(await component.findByTestId('desired-scale')).toHaveTextContent('0');
|
||||
expect((await component.baseElement.querySelector('input').value)).toBe('0');
|
||||
|
||||
// edge case, desiredScale must <= scaleMax (100)
|
||||
times = 120;
|
||||
for (let i = 0; i < times; i++) {
|
||||
fireEvent.click(up);
|
||||
}
|
||||
expect(await component.findByTestId('desired-scale')).toHaveTextContent('100');
|
||||
expect((component.baseElement.querySelector("input").value)).toBe('100');
|
||||
expect(await component.findByTestId('warning'))
|
||||
.toHaveTextContent('High number of replicas may cause cluster performance issues');
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,164 @@
|
||||
import "./statefulset-scale-dialog.scss";
|
||||
|
||||
import { StatefulSet, statefulSetApi } from "../../api/endpoints";
|
||||
import React, { Component } from "react";
|
||||
import { computed, observable } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import { Trans } from "@lingui/macro";
|
||||
import { Dialog, DialogProps } from "../dialog";
|
||||
import { Wizard, WizardStep } from "../wizard";
|
||||
import { Icon } from "../icon";
|
||||
import { Slider } from "../slider";
|
||||
import { Notifications } from "../notifications";
|
||||
import { cssNames } from "../../utils";
|
||||
|
||||
interface Props extends Partial<DialogProps> {
|
||||
}
|
||||
|
||||
@observer
|
||||
export class StatefulSetScaleDialog extends Component<Props> {
|
||||
@observable static isOpen = false;
|
||||
@observable static data: StatefulSet = null;
|
||||
|
||||
@observable ready = false;
|
||||
@observable currentReplicas = 0;
|
||||
@observable desiredReplicas = 0;
|
||||
|
||||
static open(statefulSet: StatefulSet) {
|
||||
StatefulSetScaleDialog.isOpen = true;
|
||||
StatefulSetScaleDialog.data = statefulSet;
|
||||
}
|
||||
|
||||
static close() {
|
||||
StatefulSetScaleDialog.isOpen = false;
|
||||
}
|
||||
|
||||
get statefulSet() {
|
||||
return StatefulSetScaleDialog.data;
|
||||
}
|
||||
|
||||
close = () => {
|
||||
StatefulSetScaleDialog.close();
|
||||
};
|
||||
|
||||
onOpen = async () => {
|
||||
const { statefulSet } = this;
|
||||
this.currentReplicas = await statefulSetApi.getReplicas({
|
||||
namespace: statefulSet.getNs(),
|
||||
name: statefulSet.getName(),
|
||||
});
|
||||
this.desiredReplicas = this.currentReplicas;
|
||||
this.ready = true;
|
||||
};
|
||||
|
||||
onClose = () => {
|
||||
this.ready = false;
|
||||
};
|
||||
|
||||
onChange = (evt: React.ChangeEvent, value: number) => {
|
||||
this.desiredReplicas = value;
|
||||
};
|
||||
|
||||
@computed get scaleMax() {
|
||||
const { currentReplicas } = this;
|
||||
const defaultMax = 50;
|
||||
return currentReplicas <= defaultMax
|
||||
? defaultMax * 2
|
||||
: currentReplicas * 2;
|
||||
}
|
||||
|
||||
scale = async () => {
|
||||
const { statefulSet } = this;
|
||||
const { currentReplicas, desiredReplicas, close } = this;
|
||||
try {
|
||||
if (currentReplicas !== desiredReplicas) {
|
||||
await statefulSetApi.scale({
|
||||
name: statefulSet.getName(),
|
||||
namespace: statefulSet.getNs(),
|
||||
}, desiredReplicas);
|
||||
}
|
||||
close();
|
||||
} catch (err) {
|
||||
Notifications.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
desiredReplicasUp = () => {
|
||||
this.desiredReplicas < this.scaleMax && this.desiredReplicas++;
|
||||
};
|
||||
|
||||
desiredReplicasDown = () => {
|
||||
this.desiredReplicas > 0 && this.desiredReplicas--;
|
||||
};
|
||||
|
||||
renderContents() {
|
||||
const { currentReplicas, desiredReplicas, onChange, scaleMax } = this;
|
||||
const warning = currentReplicas < 10 && desiredReplicas > 90;
|
||||
return (
|
||||
<>
|
||||
<div className="current-scale" data-testid="current-scale">
|
||||
<Trans>Current replica scale: {currentReplicas}</Trans>
|
||||
</div>
|
||||
<div className="flex gaps align-center">
|
||||
<div className="desired-scale" data-testid="desired-scale">
|
||||
<Trans>Desired number of replicas</Trans>: {desiredReplicas}
|
||||
</div>
|
||||
<div className="slider-container flex align-center" data-testid="slider">
|
||||
<Slider value={desiredReplicas} max={scaleMax}
|
||||
onChange={onChange as any /** see: https://github.com/mui-org/material-ui/issues/20191 */}
|
||||
/>
|
||||
</div>
|
||||
<div className="plus-minus-container flex gaps">
|
||||
<Icon
|
||||
material="add_circle_outline"
|
||||
onClick={this.desiredReplicasUp}
|
||||
data-testid="desired-replicas-up"
|
||||
/>
|
||||
<Icon
|
||||
material="remove_circle_outline"
|
||||
onClick={this.desiredReplicasDown}
|
||||
data-testid="desired-replicas-down"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{warning &&
|
||||
<div className="warning" data-testid="warning">
|
||||
<Icon material="warning"/>
|
||||
<Trans>High number of replicas may cause cluster performance issues</Trans>
|
||||
</div>
|
||||
}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { className, ...dialogProps } = this.props;
|
||||
const statefulSetName = this.statefulSet ? this.statefulSet.getName() : "";
|
||||
const header = (
|
||||
<h5>
|
||||
<Trans>Scale Stateful Set <span>{statefulSetName}</span></Trans>
|
||||
</h5>
|
||||
);
|
||||
return (
|
||||
<Dialog
|
||||
{...dialogProps}
|
||||
isOpen={StatefulSetScaleDialog.isOpen}
|
||||
className={cssNames("StatefulSetScaleDialog", className)}
|
||||
onOpen={this.onOpen}
|
||||
onClose={this.onClose}
|
||||
close={this.close}
|
||||
>
|
||||
<Wizard header={header} done={this.close}>
|
||||
<WizardStep
|
||||
contentClass="flex gaps column"
|
||||
next={this.scale}
|
||||
nextLabel={<Trans>Scale</Trans>}
|
||||
disabledNext={!this.ready}
|
||||
>
|
||||
{this.renderContents()}
|
||||
</WizardStep>
|
||||
</Wizard>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -5,7 +5,7 @@
|
||||
}
|
||||
|
||||
&.pods {
|
||||
flex-grow: 0.3;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
&.warning {
|
||||
|
||||
@ -3,21 +3,27 @@ import "./statefulsets.scss";
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { RouteComponentProps } from "react-router";
|
||||
import { Trans } from "@lingui/macro";
|
||||
import { StatefulSet } from "../../api/endpoints";
|
||||
import { t, Trans } from "@lingui/macro";
|
||||
import { StatefulSet, statefulSetApi } from "../../api/endpoints";
|
||||
import { podsStore } from "../+workloads-pods/pods.store";
|
||||
import { statefulSetStore } from "./statefulset.store";
|
||||
import { nodesStore } from "../+nodes/nodes.store";
|
||||
import { eventStore } from "../+events/event.store";
|
||||
import { KubeObjectMenuProps } from "../kube-object/kube-object-menu";
|
||||
import { KubeObjectListLayout } from "../kube-object";
|
||||
import { IStatefulSetsRouteParams } from "../+workloads";
|
||||
import { KubeObjectStatusIcon } from "../kube-object-status-icon";
|
||||
import { StatefulSetScaleDialog } from "./statefulset-scale-dialog";
|
||||
import { MenuItem } from "../menu/menu";
|
||||
import { _i18n } from "../../i18n";
|
||||
import { Icon } from "../icon/icon";
|
||||
import { kubeObjectMenuRegistry } from "../../../extensions/registries/kube-object-menu-registry";
|
||||
|
||||
enum sortBy {
|
||||
name = "name",
|
||||
namespace = "namespace",
|
||||
pods = "pods",
|
||||
age = "age",
|
||||
replicas = "replicas",
|
||||
}
|
||||
|
||||
interface Props extends RouteComponentProps<IStatefulSetsRouteParams> {
|
||||
@ -25,8 +31,9 @@ interface Props extends RouteComponentProps<IStatefulSetsRouteParams> {
|
||||
|
||||
@observer
|
||||
export class StatefulSets extends React.Component<Props> {
|
||||
getPodsLength(statefulSet: StatefulSet) {
|
||||
return statefulSetStore.getChildPods(statefulSet).length;
|
||||
renderPods(statefulSet: StatefulSet) {
|
||||
const { readyReplicas, currentReplicas } = statefulSet.status;
|
||||
return `${readyReplicas || 0}/${currentReplicas || 0}`;
|
||||
}
|
||||
|
||||
render() {
|
||||
@ -38,7 +45,7 @@ export class StatefulSets extends React.Component<Props> {
|
||||
[sortBy.name]: (statefulSet: StatefulSet) => statefulSet.getName(),
|
||||
[sortBy.namespace]: (statefulSet: StatefulSet) => statefulSet.getNs(),
|
||||
[sortBy.age]: (statefulSet: StatefulSet) => statefulSet.metadata.creationTimestamp,
|
||||
[sortBy.pods]: (statefulSet: StatefulSet) => this.getPodsLength(statefulSet),
|
||||
[sortBy.replicas]: (statefulSet: StatefulSet) => statefulSet.getReplicas(),
|
||||
}}
|
||||
searchFilters={[
|
||||
(statefulSet: StatefulSet) => statefulSet.getSearchFields(),
|
||||
@ -47,18 +54,43 @@ export class StatefulSets extends React.Component<Props> {
|
||||
renderTableHeader={[
|
||||
{ title: <Trans>Name</Trans>, className: "name", sortBy: sortBy.name },
|
||||
{ title: <Trans>Namespace</Trans>, className: "namespace", sortBy: sortBy.namespace },
|
||||
{ title: <Trans>Pods</Trans>, className: "pods", sortBy: sortBy.pods },
|
||||
{ title: <Trans>Pods</Trans>, className: "pods" },
|
||||
{ title: <Trans>Replicas</Trans>, className: "replicas", sortBy: sortBy.replicas },
|
||||
{ className: "warning" },
|
||||
{ title: <Trans>Age</Trans>, className: "age", sortBy: sortBy.age },
|
||||
]}
|
||||
renderTableContents={(statefulSet: StatefulSet) => [
|
||||
statefulSet.getName(),
|
||||
statefulSet.getNs(),
|
||||
this.getPodsLength(statefulSet),
|
||||
this.renderPods(statefulSet),
|
||||
statefulSet.getReplicas(),
|
||||
<KubeObjectStatusIcon object={statefulSet}/>,
|
||||
statefulSet.getAge(),
|
||||
]}
|
||||
renderItemMenu={(item: StatefulSet) => {
|
||||
return <StatefulSetMenu object={item}/>;
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function StatefulSetMenu(props: KubeObjectMenuProps<StatefulSet>) {
|
||||
const { object, toolbar } = props;
|
||||
return (
|
||||
<>
|
||||
<MenuItem onClick={() => StatefulSetScaleDialog.open(object)}>
|
||||
<Icon material="open_with" title={_i18n._(t`Scale`)} interactive={toolbar}/>
|
||||
<span className="title"><Trans>Scale</Trans></span>
|
||||
</MenuItem>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
kubeObjectMenuRegistry.add({
|
||||
kind: "StatefulSet",
|
||||
apiVersions: ["apps/v1"],
|
||||
components: {
|
||||
MenuItem: StatefulSetMenu
|
||||
}
|
||||
});
|
||||
|
||||
@ -37,12 +37,17 @@ import { webFrame } from "electron";
|
||||
import { clusterPageRegistry, getExtensionPageUrl, PageRegistration, RegisteredPage } from "../../extensions/registries/page-registry";
|
||||
import { extensionLoader } from "../../extensions/extension-loader";
|
||||
import { appEventBus } from "../../common/event-bus";
|
||||
import { requestMain } from "../../common/ipc";
|
||||
import { broadcastMessage, requestMain } from "../../common/ipc";
|
||||
import whatInput from 'what-input';
|
||||
import { clusterSetFrameIdHandler } from "../../common/cluster-ipc";
|
||||
import { ClusterPageMenuRegistration, clusterPageMenuRegistry } from "../../extensions/registries";
|
||||
import { TabLayoutRoute, TabLayout } from "./layout/tab-layout";
|
||||
import { Trans } from "@lingui/macro";
|
||||
import { StatefulSetScaleDialog } from "./+workloads-statefulsets/statefulset-scale-dialog";
|
||||
import { eventStore } from "./+events/event.store";
|
||||
import { reaction, computed } from "mobx";
|
||||
import { nodesStore } from "./+nodes/nodes.store";
|
||||
import { podsStore } from "./+workloads-pods/pods.store";
|
||||
import { sum } from "lodash";
|
||||
|
||||
@observer
|
||||
export class App extends React.Component {
|
||||
@ -68,6 +73,39 @@ export class App extends React.Component {
|
||||
whatInput.ask(); // Start to monitor user input device
|
||||
}
|
||||
|
||||
async componentDidMount() {
|
||||
const cluster = getHostedCluster();
|
||||
const promises: Promise<void>[] = [];
|
||||
if (isAllowedResource("events") && isAllowedResource("pods")) {
|
||||
promises.push(eventStore.loadAll());
|
||||
promises.push(podsStore.loadAll());
|
||||
}
|
||||
if (isAllowedResource("nodes")) {
|
||||
promises.push(nodesStore.loadAll());
|
||||
}
|
||||
await Promise.all(promises);
|
||||
if (eventStore.isLoaded && podsStore.isLoaded) {
|
||||
eventStore.subscribe();
|
||||
podsStore.subscribe();
|
||||
}
|
||||
if (nodesStore.isLoaded) {
|
||||
nodesStore.subscribe();
|
||||
}
|
||||
|
||||
reaction(() => this.warningsCount, (count) => {
|
||||
broadcastMessage(`cluster-warning-event-count:${cluster.id}`, count);
|
||||
});
|
||||
}
|
||||
|
||||
@computed
|
||||
get warningsCount() {
|
||||
let warnings = sum(nodesStore.items
|
||||
.map(node => node.getWarningConditions().length));
|
||||
warnings = warnings + eventStore.getWarnings().length;
|
||||
|
||||
return warnings;
|
||||
}
|
||||
|
||||
get startURL() {
|
||||
if (isAllowedResource(["events", "nodes", "pods"])) {
|
||||
return clusterURL();
|
||||
@ -150,6 +188,7 @@ export class App extends React.Component {
|
||||
<KubeConfigDialog/>
|
||||
<AddRoleBindingDialog/>
|
||||
<DeploymentScaleDialog/>
|
||||
<StatefulSetScaleDialog/>
|
||||
<CronJobTriggerDialog/>
|
||||
</ErrorBoundary>
|
||||
</Router>
|
||||
|
||||
@ -1,13 +1,17 @@
|
||||
import "./cluster-icon.scss";
|
||||
|
||||
import React, { DOMAttributes } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { disposeOnUnmount, observer } from "mobx-react";
|
||||
import { Params as HashiconParams } from "@emeraldpay/hashicon";
|
||||
import { Hashicon } from "@emeraldpay/hashicon-react";
|
||||
import { Cluster } from "../../../main/cluster";
|
||||
import { cssNames, IClassName } from "../../utils";
|
||||
import { Badge } from "../badge";
|
||||
import { Tooltip } from "../tooltip";
|
||||
import { eventStore } from "../+events/event.store";
|
||||
import { forCluster } from "../../api/kube-api";
|
||||
import { subscribeToBroadcast, unsubscribeAllFromBroadcast } from "../../../common/ipc";
|
||||
import { observable, when } from "mobx";
|
||||
|
||||
interface Props extends DOMAttributes<HTMLElement> {
|
||||
cluster: Cluster;
|
||||
@ -29,12 +33,29 @@ const defaultProps: Partial<Props> = {
|
||||
export class ClusterIcon extends React.Component<Props> {
|
||||
static defaultProps = defaultProps as object;
|
||||
|
||||
@observable eventCount = 0;
|
||||
|
||||
get eventCountBroadcast() {
|
||||
return `cluster-warning-event-count:${this.props.cluster.id}`;
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const subscriber = subscribeToBroadcast(this.eventCountBroadcast, (ev, eventCount) => {
|
||||
this.eventCount = eventCount;
|
||||
});
|
||||
|
||||
disposeOnUnmount(this, [
|
||||
subscriber
|
||||
]);
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
cluster, showErrors, showTooltip, errorClass, options, interactive, isActive,
|
||||
children, ...elemProps
|
||||
} = this.props;
|
||||
const { isAdmin, name, eventCount, preferences, id: clusterId } = cluster;
|
||||
const { name, preferences, id: clusterId } = cluster;
|
||||
const eventCount = this.eventCount;
|
||||
const { icon } = preferences;
|
||||
const clusterIconId = `cluster-icon-${clusterId}`;
|
||||
const className = cssNames("ClusterIcon flex inline", this.props.className, {
|
||||
@ -48,7 +69,7 @@ export class ClusterIcon extends React.Component<Props> {
|
||||
)}
|
||||
{icon && <img src={icon} alt={name}/>}
|
||||
{!icon && <Hashicon value={clusterId} options={options}/>}
|
||||
{showErrors && isAdmin && eventCount > 0 && (
|
||||
{showErrors && eventCount > 0 && !isActive && (
|
||||
<Badge
|
||||
className={cssNames("events-count", errorClass)}
|
||||
label={eventCount >= 1000 ? Math.ceil(eventCount / 1000) + "k+" : eventCount}
|
||||
|
||||
@ -60,7 +60,7 @@ export const PodLogSearch = observer((props: PodLogSearchProps) => {
|
||||
<SearchInput
|
||||
value={searchQuery}
|
||||
onChange={setSearch}
|
||||
closeIcon={false}
|
||||
showClearIcon={false}
|
||||
contentRight={totalFinds > 0 && findCounts}
|
||||
onClear={onClear}
|
||||
onKeyDown={onKeyDown}
|
||||
|
||||
@ -61,7 +61,7 @@ export class DropFileInput<T extends HTMLElement = any> extends React.Component<
|
||||
const isValidContentElem = React.isValidElement(contentElem);
|
||||
if (isValidContentElem) {
|
||||
const contentElemProps: React.HTMLProps<HTMLElement> = {
|
||||
className: cssNames("DropFileInput", className, {
|
||||
className: cssNames("DropFileInput", contentElem.props.className, className, {
|
||||
droppable: this.dropAreaActive,
|
||||
}),
|
||||
onDragEnter,
|
||||
|
||||
@ -89,8 +89,10 @@
|
||||
|
||||
&.theme {
|
||||
&.round-black {
|
||||
&.invalid label {
|
||||
border-color: $colorSoftError !important;
|
||||
&.invalid.dirty {
|
||||
label {
|
||||
border-color: $colorSoftError;
|
||||
}
|
||||
}
|
||||
|
||||
label {
|
||||
|
||||
@ -3,13 +3,13 @@ import "./input.scss";
|
||||
import React, { DOMAttributes, InputHTMLAttributes, TextareaHTMLAttributes } from "react";
|
||||
import { autobind, cssNames, debouncePromise, getRandId } from "../../utils";
|
||||
import { Icon } from "../icon";
|
||||
import { Tooltip, TooltipProps } from "../tooltip";
|
||||
import * as Validators from "./input_validators";
|
||||
import { InputValidator } from "./input_validators";
|
||||
import isString from "lodash/isString";
|
||||
import isFunction from "lodash/isFunction";
|
||||
import isBoolean from "lodash/isBoolean";
|
||||
import uniqueId from "lodash/uniqueId";
|
||||
import { Tooltip } from "../tooltip";
|
||||
|
||||
const { conditionalValidators, ...InputValidators } = Validators;
|
||||
export { InputValidators, InputValidator };
|
||||
@ -26,7 +26,7 @@ export type InputProps<T = string> = Omit<InputElementProps, "onChange" | "onSub
|
||||
maxRows?: number; // when multiLine={true} define max rows size
|
||||
dirty?: boolean; // show validation errors even if the field wasn't touched yet
|
||||
showValidationLine?: boolean; // show animated validation line for async validators
|
||||
showErrorsAsTooltip?: boolean; // show validation errors as a tooltip :hover (instead of block below)
|
||||
showErrorsAsTooltip?: boolean | Omit<TooltipProps, "targetId">; // show validation errors as a tooltip :hover (instead of block below)
|
||||
iconLeft?: string | React.ReactNode; // material-icon name in case of string-type
|
||||
iconRight?: string | React.ReactNode;
|
||||
contentRight?: string | React.ReactNode; // Any component of string goes after iconRight
|
||||
@ -63,6 +63,10 @@ export class Input extends React.Component<InputProps, State> {
|
||||
errors: [],
|
||||
};
|
||||
|
||||
isValid() {
|
||||
return this.state.valid;
|
||||
}
|
||||
|
||||
setValue(value: string) {
|
||||
if (value !== this.getValue()) {
|
||||
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(this.input.constructor.prototype, "value").set;
|
||||
@ -268,7 +272,8 @@ export class Input extends React.Component<InputProps, State> {
|
||||
render() {
|
||||
const {
|
||||
multiLine, showValidationLine, validators, theme, maxRows, children, showErrorsAsTooltip,
|
||||
maxLength, rows, disabled, autoSelectOnFocus, iconLeft, iconRight, contentRight,
|
||||
maxLength, rows, disabled, autoSelectOnFocus, iconLeft, iconRight, contentRight, id,
|
||||
dirty: _dirty, // excluded from passing to input-element
|
||||
...inputProps
|
||||
} = this.props;
|
||||
const { focused, dirty, valid, validating, errors } = this.state;
|
||||
@ -294,29 +299,35 @@ export class Input extends React.Component<InputProps, State> {
|
||||
ref: this.bindRef,
|
||||
spellCheck: "false",
|
||||
});
|
||||
const tooltipId = showErrorsAsTooltip ? getRandId({ prefix: "input_tooltip_id" }) : undefined;
|
||||
const showErrors = errors.length > 0 && !valid && dirty;
|
||||
const errorsInfo = (
|
||||
<div className="errors box grow">
|
||||
{errors.map((error, i) => <p key={i}>{error}</p>)}
|
||||
</div>
|
||||
);
|
||||
const componentId = id || showErrorsAsTooltip ? getRandId({ prefix: "input_tooltip_id" }) : undefined;
|
||||
let tooltipError: React.ReactNode;
|
||||
if (showErrorsAsTooltip && showErrors) {
|
||||
const tooltipProps = typeof showErrorsAsTooltip === "object" ? showErrorsAsTooltip : {};
|
||||
tooltipProps.className = cssNames("InputTooltipError", tooltipProps.className);
|
||||
tooltipError = (
|
||||
<Tooltip targetId={componentId} {...tooltipProps}>
|
||||
<div className="flex gaps align-center">
|
||||
<Icon material="error_outline"/>
|
||||
{errorsInfo}
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div id={tooltipId} className={className}>
|
||||
<div id={componentId} className={className}>
|
||||
{tooltipError}
|
||||
<label className="input-area flex gaps align-center" id="">
|
||||
{isString(iconLeft) ? <Icon material={iconLeft}/> : iconLeft}
|
||||
{multiLine ? <textarea {...inputProps as any} /> : <input {...inputProps as any} />}
|
||||
{isString(iconRight) ? <Icon material={iconRight}/> : iconRight}
|
||||
{contentRight}
|
||||
</label>
|
||||
{showErrorsAsTooltip && showErrors && (
|
||||
<Tooltip targetId={tooltipId} className="InputTooltipError">
|
||||
<div className="flex gaps align-center">
|
||||
<Icon material="error_outline"/>
|
||||
{errorsInfo}
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
<div className="input-info flex gaps">
|
||||
{!showErrorsAsTooltip && showErrors && errorsInfo}
|
||||
{this.showMaxLenIndicator && (
|
||||
|
||||
@ -39,13 +39,13 @@ export const isNumber: InputValidator = {
|
||||
export const isUrl: InputValidator = {
|
||||
condition: ({ type }) => type === "url",
|
||||
message: () => _i18n._(t`Wrong url format`),
|
||||
validate: value => !!value.match(/^$|^http(s)?:\/\/\w+(\.\w+)*(:[0-9]+)?\/?(\/[\w\-\._~:/?#[\]@!\$&'\(\)\*\+,;=.]*)*$/),
|
||||
validate: value => !!value.match(/^http(s)?:\/\/\w+(\.\w+)*(:[0-9]+)?\/?(\/[\w\-\._~:/?#[\]@!\$&'\(\)\*\+,;=.]*)*$/),
|
||||
};
|
||||
|
||||
export const isPath: InputValidator = {
|
||||
condition: ({ type }) => type === "text",
|
||||
message: () => _i18n._(t`This field must be a valid path`),
|
||||
validate: value => !value || fse.pathExistsSync(value),
|
||||
validate: value => value && fse.pathExistsSync(value),
|
||||
};
|
||||
|
||||
export const minLength: InputValidator = {
|
||||
|
||||
@ -10,13 +10,15 @@ import { Input, InputProps } from "./input";
|
||||
|
||||
interface Props extends InputProps {
|
||||
compact?: boolean; // show only search-icon when not focused
|
||||
closeIcon?: boolean;
|
||||
onClear?: () => void;
|
||||
bindGlobalFocusHotkey?: boolean;
|
||||
showClearIcon?: boolean;
|
||||
onClear?(): void;
|
||||
}
|
||||
|
||||
const defaultProps: Partial<Props> = {
|
||||
autoFocus: true,
|
||||
closeIcon: true,
|
||||
bindGlobalFocusHotkey: true,
|
||||
showClearIcon: true,
|
||||
get placeholder() {
|
||||
return _i18n._(t`Search...`);
|
||||
},
|
||||
@ -26,27 +28,27 @@ const defaultProps: Partial<Props> = {
|
||||
export class SearchInput extends React.Component<Props> {
|
||||
static defaultProps = defaultProps as object;
|
||||
|
||||
private input = createRef<Input>();
|
||||
private inputRef = createRef<Input>();
|
||||
|
||||
componentDidMount() {
|
||||
addEventListener("keydown", this.focus);
|
||||
if (!this.props.bindGlobalFocusHotkey) return;
|
||||
window.addEventListener("keydown", this.onGlobalKey);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
removeEventListener("keydown", this.focus);
|
||||
window.removeEventListener("keydown", this.onGlobalKey);
|
||||
}
|
||||
|
||||
clear = () => {
|
||||
if (this.props.onClear) {
|
||||
this.props.onClear();
|
||||
@autobind()
|
||||
onGlobalKey(evt: KeyboardEvent) {
|
||||
const meta = evt.metaKey || evt.ctrlKey;
|
||||
if (meta && evt.key === "f") {
|
||||
this.inputRef.current.focus();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
onChange = (val: string, evt: React.ChangeEvent<any>) => {
|
||||
this.props.onChange(val, evt);
|
||||
};
|
||||
|
||||
onKeyDown = (evt: React.KeyboardEvent<any>) => {
|
||||
@autobind()
|
||||
onKeyDown(evt: React.KeyboardEvent<any>) {
|
||||
if (this.props.onKeyDown) {
|
||||
this.props.onKeyDown(evt);
|
||||
}
|
||||
@ -56,29 +58,31 @@ export class SearchInput extends React.Component<Props> {
|
||||
this.clear();
|
||||
evt.stopPropagation();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@autobind()
|
||||
focus(evt: KeyboardEvent) {
|
||||
const meta = evt.metaKey || evt.ctrlKey;
|
||||
if (meta && evt.key == "f") {
|
||||
this.input.current.focus();
|
||||
clear() {
|
||||
if (this.props.onClear) {
|
||||
this.props.onClear();
|
||||
} else {
|
||||
this.inputRef.current.setValue("");
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { className, compact, closeIcon, onClear, ...inputProps } = this.props;
|
||||
const icon = this.props.value
|
||||
? closeIcon ? <Icon small material="close" onClick={this.clear}/> : null
|
||||
: <Icon small material="search"/>;
|
||||
const { className, compact, onClear, showClearIcon, bindGlobalFocusHotkey, value, ...inputProps } = this.props;
|
||||
let rightIcon = <Icon small material="search"/>;
|
||||
if (showClearIcon && value) {
|
||||
rightIcon = <Icon small material="close" onClick={this.clear}/>;
|
||||
}
|
||||
return (
|
||||
<Input
|
||||
{...inputProps}
|
||||
className={cssNames("SearchInput", className, { compact })}
|
||||
onChange={this.onChange}
|
||||
value={value}
|
||||
onKeyDown={this.onKeyDown}
|
||||
iconRight={icon}
|
||||
ref={this.input}
|
||||
iconRight={rightIcon}
|
||||
ref={this.inputRef}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,11 +0,0 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"files": [
|
||||
"src/extensions/extension-api.ts",
|
||||
],
|
||||
"compilerOptions": {
|
||||
"module": "CommonJS",
|
||||
"sourceMap": false,
|
||||
"declaration": true
|
||||
}
|
||||
}
|
||||
87
webpack.extensions.ts
Normal file
87
webpack.extensions.ts
Normal file
@ -0,0 +1,87 @@
|
||||
|
||||
import path from 'path';
|
||||
import webpack from "webpack";
|
||||
import { sassCommonVars } from "./src/common/vars";
|
||||
|
||||
export default function (): webpack.Configuration {
|
||||
const entry = "./src/extensions/extension-api.ts"
|
||||
const outDir = "./src/extensions/npm/extensions/dist";
|
||||
return {
|
||||
// Compile for Electron for renderer process
|
||||
// see <https://webpack.js.org/configuration/target/>
|
||||
target: "electron-renderer",
|
||||
entry,
|
||||
output: {
|
||||
filename: 'extension-api.js',
|
||||
// need to be an absolute path
|
||||
path: path.resolve(__dirname, `${outDir}/src/extensions`),
|
||||
// can be use in commonjs environments
|
||||
// e.g. require('@k8slens/extensions')
|
||||
libraryTarget: "commonjs"
|
||||
},
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.tsx?$/,
|
||||
loader: 'ts-loader',
|
||||
options: {
|
||||
// !! ts-loader will use tsconfig.json at folder root
|
||||
// !! changes in tsconfig.json may have side effects
|
||||
// !! on '@k8slens/extensions' module
|
||||
compilerOptions: {
|
||||
declaration: true, // output .d.ts
|
||||
sourceMap: false, // to override sourceMap: true in tsconfig.json
|
||||
outDir // where the .d.ts should be located
|
||||
}
|
||||
}
|
||||
},
|
||||
// for src/renderer/components/fonts/roboto-mono-nerd.ttf
|
||||
// in src/renderer/components/dock/terminal.ts 95:25-65
|
||||
{
|
||||
test: /\.(ttf|eot|woff2?)$/,
|
||||
use: {
|
||||
loader: "url-loader",
|
||||
options: {
|
||||
name: "fonts/[name].[ext]"
|
||||
}
|
||||
}
|
||||
},
|
||||
// for import scss files
|
||||
{
|
||||
test: /\.s?css$/,
|
||||
use: [
|
||||
// creates `style` nodes from JS strings
|
||||
"style-loader",
|
||||
// translates CSS into CommonJS
|
||||
"css-loader",
|
||||
{
|
||||
loader: "sass-loader",
|
||||
options: {
|
||||
prependData: `@import "${path.basename(sassCommonVars)}";`,
|
||||
sassOptions: {
|
||||
includePaths: [
|
||||
path.dirname(sassCommonVars)
|
||||
]
|
||||
},
|
||||
}
|
||||
},
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
resolve: {
|
||||
extensions: ['.ts', '.tsx', '.js']
|
||||
},
|
||||
plugins: [
|
||||
// In ts-loader's README they said to output a built .d.ts file,
|
||||
// you can set "declaration": true in tsconfig.extensions.json,
|
||||
// and use the DeclarationBundlerPlugin in your webpack config... but
|
||||
// !! the DeclarationBundlerPlugin doesn't work anymore, author archived it.
|
||||
// https://www.npmjs.com/package/declaration-bundler-webpack-plugin
|
||||
// new DeclarationBundlerPlugin({
|
||||
// moduleName: '@k8slens/extensions',
|
||||
// out: 'extension-api.d.ts',
|
||||
// })
|
||||
]
|
||||
};
|
||||
}
|
||||
49
yarn.lock
49
yarn.lock
@ -1724,11 +1724,6 @@
|
||||
"@babel/runtime" "^7.11.2"
|
||||
"@testing-library/dom" "^7.26.0"
|
||||
|
||||
"@tokenizer/token@^0.1.0", "@tokenizer/token@^0.1.1":
|
||||
version "0.1.1"
|
||||
resolved "https://registry.yarnpkg.com/@tokenizer/token/-/token-0.1.1.tgz#f0d92c12f87079ddfd1b29f614758b9696bc29e3"
|
||||
integrity sha512-XO6INPbZCxdprl+9qa/AAbFFOMzzwqYxpjPgLICrMD6C2FCw6qfJOPcBk6JqqPLSaZ/Qx87qn4rpPmPMwaAK6w==
|
||||
|
||||
"@types/anymatch@*":
|
||||
version "1.3.1"
|
||||
resolved "https://registry.yarnpkg.com/@types/anymatch/-/anymatch-1.3.1.tgz#336badc1beecb9dacc38bea2cf32adf627a8421a"
|
||||
@ -6281,16 +6276,6 @@ file-loader@^6.0.0:
|
||||
loader-utils "^2.0.0"
|
||||
schema-utils "^2.6.5"
|
||||
|
||||
file-type@^14.7.1:
|
||||
version "14.7.1"
|
||||
resolved "https://registry.yarnpkg.com/file-type/-/file-type-14.7.1.tgz#f748732b3e70478bff530e1cf0ec2fe33608b1bb"
|
||||
integrity sha512-sXAMgFk67fQLcetXustxfKX+PZgHIUFn96Xld9uH8aXPdX3xOp0/jg9OdouVTvQrf7mrn+wAa4jN/y9fUOOiRA==
|
||||
dependencies:
|
||||
readable-web-to-node-stream "^2.0.0"
|
||||
strtok3 "^6.0.3"
|
||||
token-types "^2.0.0"
|
||||
typedarray-to-buffer "^3.1.5"
|
||||
|
||||
file-uri-to-path@1.0.0:
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz#553a7b8446ff6f684359c445f1e37a05dacc33dd"
|
||||
@ -7409,7 +7394,7 @@ identity-obj-proxy@^3.0.0:
|
||||
dependencies:
|
||||
harmony-reflect "^1.4.6"
|
||||
|
||||
ieee754@^1.1.13, ieee754@^1.1.4:
|
||||
ieee754@^1.1.4:
|
||||
version "1.1.13"
|
||||
resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.1.13.tgz#ec168558e95aa181fd87d37f55c32bbcb6708b84"
|
||||
integrity sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg==
|
||||
@ -11272,11 +11257,6 @@ pbkdf2@^3.0.3:
|
||||
safe-buffer "^5.0.1"
|
||||
sha.js "^2.4.8"
|
||||
|
||||
peek-readable@^3.1.0:
|
||||
version "3.1.0"
|
||||
resolved "https://registry.yarnpkg.com/peek-readable/-/peek-readable-3.1.0.tgz#250b08b7de09db8573d7fd8ea475215bbff14348"
|
||||
integrity sha512-KGuODSTV6hcgdZvDrIDBUkN0utcAVj1LL7FfGbM0viKTtCHmtZcuEJ+lGqsp0fTFkGqesdtemV2yUSMeyy3ddA==
|
||||
|
||||
pend@~1.2.0:
|
||||
version "1.2.0"
|
||||
resolved "https://registry.yarnpkg.com/pend/-/pend-1.2.0.tgz#7a57eb550a6783f9115331fcf4663d5c8e007a50"
|
||||
@ -11480,6 +11460,11 @@ prepend-http@^2.0.0:
|
||||
resolved "https://registry.yarnpkg.com/prepend-http/-/prepend-http-2.0.0.tgz#e92434bfa5ea8c19f41cdfd401d741a3c819d897"
|
||||
integrity sha1-6SQ0v6XqjBn0HN/UAddBo8gZ2Jc=
|
||||
|
||||
prettier@^2.2.0:
|
||||
version "2.2.0"
|
||||
resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.2.0.tgz#8a03c7777883b29b37fb2c4348c66a78e980418b"
|
||||
integrity sha512-yYerpkvseM4iKD/BXLYUkQV5aKt4tQPqaGW6EsZjzyu0r7sVZZNPJW4Y8MyKmicp6t42XUPcBVA+H6sB3gqndw==
|
||||
|
||||
pretty-error@^2.1.1:
|
||||
version "2.1.1"
|
||||
resolved "https://registry.yarnpkg.com/pretty-error/-/pretty-error-2.1.1.tgz#5f4f87c8f91e5ae3f3ba87ab4cf5e03b1a17f1a3"
|
||||
@ -12119,11 +12104,6 @@ readable-stream@~1.1.10:
|
||||
isarray "0.0.1"
|
||||
string_decoder "~0.10.x"
|
||||
|
||||
readable-web-to-node-stream@^2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/readable-web-to-node-stream/-/readable-web-to-node-stream-2.0.0.tgz#751e632f466552ac0d5c440cc01470352f93c4b7"
|
||||
integrity sha512-+oZJurc4hXpaaqsN68GoZGQAQIA3qr09Or4fqEsargABnbe5Aau8hFn6ISVleT3cpY/0n/8drn7huyyEvTbghA==
|
||||
|
||||
readdir-scoped-modules@^1.0.0, readdir-scoped-modules@^1.1.0:
|
||||
version "1.1.0"
|
||||
resolved "https://registry.yarnpkg.com/readdir-scoped-modules/-/readdir-scoped-modules-1.1.0.tgz#8d45407b4f870a0dcaebc0e28670d18e74514309"
|
||||
@ -13581,15 +13561,6 @@ strip-outer@^1.0.1:
|
||||
dependencies:
|
||||
escape-string-regexp "^1.0.2"
|
||||
|
||||
strtok3@^6.0.3:
|
||||
version "6.0.4"
|
||||
resolved "https://registry.yarnpkg.com/strtok3/-/strtok3-6.0.4.tgz#ede0d20fde5aa9fda56417c3558eaafccc724694"
|
||||
integrity sha512-rqWMKwsbN9APU47bQTMEYTPcwdpKDtmf1jVhHzNW2cL1WqAxaM9iBb9t5P2fj+RV2YsErUWgQzHD5JwV0uCTEQ==
|
||||
dependencies:
|
||||
"@tokenizer/token" "^0.1.1"
|
||||
"@types/debug" "^4.1.5"
|
||||
peek-readable "^3.1.0"
|
||||
|
||||
style-loader@^1.2.1:
|
||||
version "1.2.1"
|
||||
resolved "https://registry.yarnpkg.com/style-loader/-/style-loader-1.2.1.tgz#c5cbbfbf1170d076cfdd86e0109c5bba114baa1a"
|
||||
@ -13979,14 +13950,6 @@ toidentifier@1.0.0:
|
||||
resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.0.tgz#7e1be3470f1e77948bc43d94a3c8f4d7752ba553"
|
||||
integrity sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw==
|
||||
|
||||
token-types@^2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/token-types/-/token-types-2.0.0.tgz#b23618af744818299c6fbf125e0fdad98bab7e85"
|
||||
integrity sha512-WWvu8sGK8/ZmGusekZJJ5NM6rRVTTDO7/bahz4NGiSDb/XsmdYBn6a1N/bymUHuWYTWeuLUg98wUzvE4jPdCZw==
|
||||
dependencies:
|
||||
"@tokenizer/token" "^0.1.0"
|
||||
ieee754 "^1.1.13"
|
||||
|
||||
touch@^3.1.0:
|
||||
version "3.1.0"
|
||||
resolved "https://registry.yarnpkg.com/touch/-/touch-3.1.0.tgz#fe365f5f75ec9ed4e56825e0bb76d24ab74af83b"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user