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
@ -14,3 +14,9 @@ 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/).
|
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,6 +19,7 @@ Each guide or sample will include:
|
|||||||
| [Stores](stores.md) | |
|
| [Stores](stores.md) | |
|
||||||
| [Components](components.md) | |
|
| [Components](components.md) | |
|
||||||
| [KubeObjectListLayout](kube-object-list-layout.md) | |
|
| [KubeObjectListLayout](kube-object-list-layout.md) | |
|
||||||
|
| [Working with mobx](working-with-mobx.md) | |
|
||||||
|
|
||||||
## Samples
|
## Samples
|
||||||
|
|
||||||
|
|||||||
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";
|
name = "metrics";
|
||||||
latestVersion = "v2.17.2-lens1";
|
latestVersion = "v2.17.2-lens1";
|
||||||
|
|
||||||
config: MetricsConfiguration = {
|
templateContext: MetricsConfiguration = {
|
||||||
persistence: {
|
persistence: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
storageClass: null,
|
storageClass: null,
|
||||||
@ -53,12 +53,12 @@ export class MetricsFeature extends ClusterFeature.Feature {
|
|||||||
// Check if there are storageclasses
|
// Check if there are storageclasses
|
||||||
const storageClassApi = K8sApi.forCluster(cluster, K8sApi.StorageClass);
|
const storageClassApi = K8sApi.forCluster(cluster, K8sApi.StorageClass);
|
||||||
const scs = await storageClassApi.list();
|
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.kubernetes.io/is-default-class'] === 'true' ||
|
||||||
sc.metadata?.annotations?.['storageclass.beta.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> {
|
async upgrade(cluster: Store.Cluster): Promise<void> {
|
||||||
|
|||||||
@ -118,7 +118,8 @@ export class Tracker extends Util.Singleton {
|
|||||||
kubernetesVersion: cluster.metadata.version,
|
kubernetesVersion: cluster.metadata.version,
|
||||||
distribution: cluster.metadata.distribution,
|
distribution: cluster.metadata.distribution,
|
||||||
nodesCount: cluster.metadata.nodes,
|
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 () => {
|
it('adds cluster in test-workspace', async () => {
|
||||||
await app.client.click('#current-workspace .Icon');
|
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 app.client.click('.WorkspaceMenu li[title="test description"]');
|
||||||
await addMinikubeCluster(app);
|
await addMinikubeCluster(app);
|
||||||
await app.client.waitUntilTextExists("pre.kube-auth-out", "Authentication proxy started");
|
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 () => {
|
it('checks if default workspace has active cluster', async () => {
|
||||||
await app.client.click('#current-workspace .Icon');
|
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.click('.WorkspaceMenu > li:first-of-type');
|
||||||
await app.client.waitForVisible(".ClustersMenu .ClusterIcon.active");
|
await app.client.waitForVisible(".ClustersMenu .ClusterIcon.active");
|
||||||
});
|
});
|
||||||
|
|||||||
@ -738,6 +738,7 @@ msgstr "Current / Target"
|
|||||||
msgid "Current Healthy"
|
msgid "Current Healthy"
|
||||||
msgstr "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
|
#: src/renderer/components/+workloads-deployments/deployment-scale-dialog.tsx:103
|
||||||
msgid "Current replica scale: {currentReplicas}"
|
msgid "Current replica scale: {currentReplicas}"
|
||||||
msgstr "Current replica scale: {currentReplicas}"
|
msgstr "Current replica scale: {currentReplicas}"
|
||||||
@ -828,6 +829,7 @@ msgstr "Description"
|
|||||||
msgid "Desired Healthy"
|
msgid "Desired Healthy"
|
||||||
msgstr "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
|
#: src/renderer/components/+workloads-deployments/deployment-scale-dialog.tsx:107
|
||||||
msgid "Desired number of replicas"
|
msgid "Desired number of replicas"
|
||||||
msgstr "Desired number of replicas"
|
msgstr "Desired number of replicas"
|
||||||
@ -1091,6 +1093,7 @@ msgstr "Helm branch <0>{0}</0> already in use"
|
|||||||
msgid "Hide"
|
msgid "Hide"
|
||||||
msgstr "Hide"
|
msgstr "Hide"
|
||||||
|
|
||||||
|
#: src/renderer/components/+workloads-statefulsets/statefulset-scale-dialog.tsx:127
|
||||||
#: src/renderer/components/+workloads-deployments/deployment-scale-dialog.tsx:116
|
#: src/renderer/components/+workloads-deployments/deployment-scale-dialog.tsx:116
|
||||||
msgid "High number of replicas may cause cluster performance issues"
|
msgid "High number of replicas may cause cluster performance issues"
|
||||||
msgstr "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"
|
msgid "Save"
|
||||||
msgstr "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/deployment-scale-dialog.tsx:128
|
||||||
#: src/renderer/components/+workloads-deployments/deployments.tsx:83
|
#: src/renderer/components/+workloads-deployments/deployments.tsx:83
|
||||||
#: src/renderer/components/+workloads-deployments/deployments.tsx:84
|
#: src/renderer/components/+workloads-deployments/deployments.tsx:84
|
||||||
@ -2308,6 +2312,10 @@ msgstr "Scale"
|
|||||||
msgid "Scale Deployment <0>{deploymentName}</0>"
|
msgid "Scale Deployment <0>{deploymentName}</0>"
|
||||||
msgstr "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/cronjob-details.tsx:45
|
||||||
#: src/renderer/components/+workloads-cronjobs/cronjobs.tsx:46
|
#: src/renderer/components/+workloads-cronjobs/cronjobs.tsx:46
|
||||||
msgid "Schedule"
|
msgid "Schedule"
|
||||||
|
|||||||
@ -734,6 +734,7 @@ msgstr ""
|
|||||||
msgid "Current Healthy"
|
msgid "Current Healthy"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/renderer/components/+workloads-statefulsets/statefulset-scale-dialog.tsx:101
|
||||||
#: src/renderer/components/+workloads-deployments/deployment-scale-dialog.tsx:103
|
#: src/renderer/components/+workloads-deployments/deployment-scale-dialog.tsx:103
|
||||||
msgid "Current replica scale: {currentReplicas}"
|
msgid "Current replica scale: {currentReplicas}"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
@ -824,6 +825,7 @@ msgstr ""
|
|||||||
msgid "Desired Healthy"
|
msgid "Desired Healthy"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/renderer/components/+workloads-statefulsets/statefulset-scale-dialog.tsx:105
|
||||||
#: src/renderer/components/+workloads-deployments/deployment-scale-dialog.tsx:107
|
#: src/renderer/components/+workloads-deployments/deployment-scale-dialog.tsx:107
|
||||||
msgid "Desired number of replicas"
|
msgid "Desired number of replicas"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
@ -1082,6 +1084,7 @@ msgstr ""
|
|||||||
msgid "Hide"
|
msgid "Hide"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/renderer/components/+workloads-statefulsets/statefulset-scale-dialog.tsx:127
|
||||||
#: src/renderer/components/+workloads-deployments/deployment-scale-dialog.tsx:116
|
#: src/renderer/components/+workloads-deployments/deployment-scale-dialog.tsx:116
|
||||||
msgid "High number of replicas may cause cluster performance issues"
|
msgid "High number of replicas may cause cluster performance issues"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
@ -2281,6 +2284,7 @@ msgstr ""
|
|||||||
msgid "Save"
|
msgid "Save"
|
||||||
msgstr ""
|
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/deployment-scale-dialog.tsx:128
|
||||||
#: src/renderer/components/+workloads-deployments/deployments.tsx:83
|
#: src/renderer/components/+workloads-deployments/deployments.tsx:83
|
||||||
#: src/renderer/components/+workloads-deployments/deployments.tsx:84
|
#: src/renderer/components/+workloads-deployments/deployments.tsx:84
|
||||||
@ -2291,6 +2295,10 @@ msgstr ""
|
|||||||
msgid "Scale Deployment <0>{deploymentName}</0>"
|
msgid "Scale Deployment <0>{deploymentName}</0>"
|
||||||
msgstr ""
|
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/cronjob-details.tsx:45
|
||||||
#: src/renderer/components/+workloads-cronjobs/cronjobs.tsx:46
|
#: src/renderer/components/+workloads-cronjobs/cronjobs.tsx:46
|
||||||
msgid "Schedule"
|
msgid "Schedule"
|
||||||
|
|||||||
@ -739,6 +739,7 @@ msgstr "Текущее / Цель"
|
|||||||
msgid "Current Healthy"
|
msgid "Current Healthy"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/renderer/components/+workloads-statefulsets/statefulset-scale-dialog.tsx:101
|
||||||
#: src/renderer/components/+workloads-deployments/deployment-scale-dialog.tsx:103
|
#: src/renderer/components/+workloads-deployments/deployment-scale-dialog.tsx:103
|
||||||
msgid "Current replica scale: {currentReplicas}"
|
msgid "Current replica scale: {currentReplicas}"
|
||||||
msgstr "Текущий размер реплики: {currentReplicas}"
|
msgstr "Текущий размер реплики: {currentReplicas}"
|
||||||
@ -829,6 +830,7 @@ msgstr "Описание"
|
|||||||
msgid "Desired Healthy"
|
msgid "Desired Healthy"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/renderer/components/+workloads-statefulsets/statefulset-scale-dialog.tsx:105
|
||||||
#: src/renderer/components/+workloads-deployments/deployment-scale-dialog.tsx:107
|
#: src/renderer/components/+workloads-deployments/deployment-scale-dialog.tsx:107
|
||||||
msgid "Desired number of replicas"
|
msgid "Desired number of replicas"
|
||||||
msgstr "Нужный уровень реплик"
|
msgstr "Нужный уровень реплик"
|
||||||
@ -1092,6 +1094,7 @@ msgstr ""
|
|||||||
msgid "Hide"
|
msgid "Hide"
|
||||||
msgstr "Скрыть"
|
msgstr "Скрыть"
|
||||||
|
|
||||||
|
#: src/renderer/components/+workloads-statefulsets/statefulset-scale-dialog.tsx:127
|
||||||
#: src/renderer/components/+workloads-deployments/deployment-scale-dialog.tsx:116
|
#: src/renderer/components/+workloads-deployments/deployment-scale-dialog.tsx:116
|
||||||
msgid "High number of replicas may cause cluster performance issues"
|
msgid "High number of replicas may cause cluster performance issues"
|
||||||
msgstr "Большое количество реплик может вызвать проблемы с производительностью кластера"
|
msgstr "Большое количество реплик может вызвать проблемы с производительностью кластера"
|
||||||
@ -2299,6 +2302,7 @@ msgstr ""
|
|||||||
msgid "Save"
|
msgid "Save"
|
||||||
msgstr "Сохранить"
|
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/deployment-scale-dialog.tsx:128
|
||||||
#: src/renderer/components/+workloads-deployments/deployments.tsx:83
|
#: src/renderer/components/+workloads-deployments/deployments.tsx:83
|
||||||
#: src/renderer/components/+workloads-deployments/deployments.tsx:84
|
#: src/renderer/components/+workloads-deployments/deployments.tsx:84
|
||||||
@ -2309,6 +2313,10 @@ msgstr "Масштабировать"
|
|||||||
msgid "Scale Deployment <0>{deploymentName}</0>"
|
msgid "Scale Deployment <0>{deploymentName}</0>"
|
||||||
msgstr "Масштабировать 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/cronjob-details.tsx:45
|
||||||
#: src/renderer/components/+workloads-cronjobs/cronjobs.tsx:46
|
#: src/renderer/components/+workloads-cronjobs/cronjobs.tsx:46
|
||||||
msgid "Schedule"
|
msgid "Schedule"
|
||||||
|
|||||||
@ -33,6 +33,7 @@ nav:
|
|||||||
- Main Extension: extensions/guides/main-extension.md
|
- Main Extension: extensions/guides/main-extension.md
|
||||||
- Renderer Extension: extensions/guides/renderer-extension.md
|
- Renderer Extension: extensions/guides/renderer-extension.md
|
||||||
- Generator: extensions/guides/generator.md
|
- Generator: extensions/guides/generator.md
|
||||||
|
- Working with mobx: extensions/guides/working-with-mobx.md
|
||||||
- Testing and Publishing:
|
- Testing and Publishing:
|
||||||
- Testing Extensions: extensions/testing-and-publishing/testing.md
|
- Testing Extensions: extensions/testing-and-publishing/testing.md
|
||||||
- Publishing Extensions: extensions/testing-and-publishing/publishing.md
|
- Publishing Extensions: extensions/testing-and-publishing/publishing.md
|
||||||
@ -62,7 +63,6 @@ theme:
|
|||||||
icon: material/toggle-switch-off-outline
|
icon: material/toggle-switch-off-outline
|
||||||
name: Switch to dark mode
|
name: Switch to dark mode
|
||||||
features:
|
features:
|
||||||
- navigation.instant
|
|
||||||
- toc.autohide
|
- toc.autohide
|
||||||
- search.suggest
|
- search.suggest
|
||||||
- search.highlight
|
- search.highlight
|
||||||
|
|||||||
@ -21,7 +21,7 @@
|
|||||||
"compile:main": "yarn run webpack --config webpack.main.ts",
|
"compile:main": "yarn run webpack --config webpack.main.ts",
|
||||||
"compile:renderer": "yarn run webpack --config webpack.renderer.ts",
|
"compile:renderer": "yarn run webpack --config webpack.renderer.ts",
|
||||||
"compile:i18n": "yarn run lingui compile",
|
"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",
|
"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:linux": "yarn run compile && electron-builder --linux --dir -c.productName=Lens",
|
||||||
"build:mac": "yarn run compile && electron-builder --mac --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-devtools-installer": "^3.1.1",
|
||||||
"electron-updater": "^4.3.1",
|
"electron-updater": "^4.3.1",
|
||||||
"electron-window-state": "^5.0.3",
|
"electron-window-state": "^5.0.3",
|
||||||
"file-type": "^14.7.1",
|
|
||||||
"filenamify": "^4.1.0",
|
"filenamify": "^4.1.0",
|
||||||
"fs-extra": "^9.0.1",
|
"fs-extra": "^9.0.1",
|
||||||
"handlebars": "^4.7.6",
|
"handlebars": "^4.7.6",
|
||||||
@ -362,6 +361,7 @@
|
|||||||
"nodemon": "^2.0.4",
|
"nodemon": "^2.0.4",
|
||||||
"patch-package": "^6.2.2",
|
"patch-package": "^6.2.2",
|
||||||
"postinstall-postinstall": "^2.1.0",
|
"postinstall-postinstall": "^2.1.0",
|
||||||
|
"prettier": "^2.2.0",
|
||||||
"progress-bar-webpack-plugin": "^2.1.0",
|
"progress-bar-webpack-plugin": "^2.1.0",
|
||||||
"raw-loader": "^4.0.1",
|
"raw-loader": "^4.0.1",
|
||||||
"react": "^16.14.0",
|
"react": "^16.14.0",
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { workspaceStore } from "./workspace-store";
|
|||||||
import path from "path";
|
import path from "path";
|
||||||
import { app, ipcRenderer, remote, webFrame } from "electron";
|
import { app, ipcRenderer, remote, webFrame } from "electron";
|
||||||
import { unlink } from "fs-extra";
|
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 { BaseStore } from "./base-store";
|
||||||
import { Cluster, ClusterState } from "../main/cluster";
|
import { Cluster, ClusterState } from "../main/cluster";
|
||||||
import migrations from "../migrations/cluster-store";
|
import migrations from "../migrations/cluster-store";
|
||||||
@ -23,9 +23,15 @@ export interface ClusterIconUpload {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ClusterMetadata {
|
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 {
|
export interface ClusterStoreModel {
|
||||||
activeCluster?: ClusterId; // last opened cluster
|
activeCluster?: ClusterId; // last opened cluster
|
||||||
clusters?: ClusterModel[]
|
clusters?: ClusterModel[]
|
||||||
@ -47,9 +53,15 @@ export interface ClusterModel {
|
|||||||
kubeConfig?: string; // yaml
|
kubeConfig?: string; // yaml
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ClusterPreferences {
|
export interface ClusterPreferences extends ClusterPrometheusPreferences{
|
||||||
terminalCWD?: string;
|
terminalCWD?: string;
|
||||||
clusterName?: string;
|
clusterName?: string;
|
||||||
|
iconOrder?: number;
|
||||||
|
icon?: string;
|
||||||
|
httpsProxy?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClusterPrometheusPreferences {
|
||||||
prometheus?: {
|
prometheus?: {
|
||||||
namespace: string;
|
namespace: string;
|
||||||
service: string;
|
service: string;
|
||||||
@ -59,9 +71,6 @@ export interface ClusterPreferences {
|
|||||||
prometheusProvider?: {
|
prometheusProvider?: {
|
||||||
type: string;
|
type: string;
|
||||||
};
|
};
|
||||||
iconOrder?: number;
|
|
||||||
icon?: string;
|
|
||||||
httpsProxy?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ClusterStore extends BaseStore<ClusterStoreModel> {
|
export class ClusterStore extends BaseStore<ClusterStoreModel> {
|
||||||
@ -84,6 +93,9 @@ export class ClusterStore extends BaseStore<ClusterStoreModel> {
|
|||||||
super({
|
super({
|
||||||
configName: "lens-cluster-store",
|
configName: "lens-cluster-store",
|
||||||
accessPropertiesByDotNotation: false, // To make dots safe in cluster context names
|
accessPropertiesByDotNotation: false, // To make dots safe in cluster context names
|
||||||
|
syncOptions: {
|
||||||
|
equals: comparer.structural,
|
||||||
|
},
|
||||||
migrations,
|
migrations,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import request from "request";
|
|||||||
export interface DownloadFileOptions {
|
export interface DownloadFileOptions {
|
||||||
url: string;
|
url: string;
|
||||||
gzip?: boolean;
|
gzip?: boolean;
|
||||||
|
timeout?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DownloadFileTicket {
|
export interface DownloadFileTicket {
|
||||||
@ -11,9 +12,9 @@ export interface DownloadFileTicket {
|
|||||||
cancel(): void;
|
cancel(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function downloadFile({ url, gzip = true }: DownloadFileOptions): DownloadFileTicket {
|
export function downloadFile({ url, timeout, gzip = true }: DownloadFileOptions): DownloadFileTicket {
|
||||||
const fileChunks: Buffer[] = [];
|
const fileChunks: Buffer[] = [];
|
||||||
const req = request(url, { gzip });
|
const req = request(url, { gzip, timeout });
|
||||||
const promise: Promise<Buffer> = new Promise((resolve, reject) => {
|
const promise: Promise<Buffer> = new Promise((resolve, reject) => {
|
||||||
req.on("data", (chunk: Buffer) => {
|
req.on("data", (chunk: Buffer) => {
|
||||||
fileChunks.push(chunk);
|
fileChunks.push(chunk);
|
||||||
|
|||||||
@ -3,6 +3,6 @@
|
|||||||
* @param items either one item or an array of items
|
* @param items either one item or an array of items
|
||||||
* @returns a list 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];
|
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",
|
name: "foo-bar",
|
||||||
version: "0.1.1"
|
version: "0.1.1"
|
||||||
},
|
},
|
||||||
|
absolutePath: "/absolute/fake/",
|
||||||
manifestPath: "/this/is/fake/package.json",
|
manifestPath: "/this/is/fake/package.json",
|
||||||
isBundled: false,
|
isBundled: false,
|
||||||
isEnabled: true
|
isEnabled: true
|
||||||
|
|||||||
@ -10,17 +10,27 @@ import { requestMain } from "../common/ipc";
|
|||||||
import { clusterKubectlApplyAllHandler } from "../common/cluster-ipc";
|
import { clusterKubectlApplyAllHandler } from "../common/cluster-ipc";
|
||||||
|
|
||||||
export interface ClusterFeatureStatus {
|
export interface ClusterFeatureStatus {
|
||||||
|
/** feature's current version, as set by the implementation */
|
||||||
currentVersion: string;
|
currentVersion: string;
|
||||||
installed: boolean;
|
/** feature's latest version, as set by the implementation */
|
||||||
latestVersion: string;
|
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;
|
canUpgrade: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export abstract class ClusterFeature {
|
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 = {
|
@observable status: ClusterFeatureStatus = {
|
||||||
currentVersion: null,
|
currentVersion: null,
|
||||||
installed: false,
|
installed: false,
|
||||||
@ -28,15 +38,59 @@ export abstract class ClusterFeature {
|
|||||||
canUpgrade: false
|
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>;
|
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>;
|
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>;
|
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>;
|
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) {
|
if (app) {
|
||||||
await new ResourceApplier(cluster).kubectlApplyAll(resources);
|
await new ResourceApplier(cluster).kubectlApplyAll(resources);
|
||||||
} else {
|
} 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[] {
|
protected renderTemplates(folderPath: string): string[] {
|
||||||
const resources: string[] = [];
|
const resources: string[] = [];
|
||||||
logger.info(`[FEATURE]: render templates from ${folderPath}`);
|
logger.info(`[FEATURE]: render templates from ${folderPath}`);
|
||||||
@ -52,7 +114,7 @@ export abstract class ClusterFeature {
|
|||||||
const raw = fs.readFileSync(file);
|
const raw = fs.readFileSync(file);
|
||||||
if (filename.endsWith('.hb')) {
|
if (filename.endsWith('.hb')) {
|
||||||
const template = hb.compile(raw.toString());
|
const template = hb.compile(raw.toString());
|
||||||
resources.push(template(this.config));
|
resources.push(template(this.templateContext));
|
||||||
} else {
|
} else {
|
||||||
resources.push(raw.toString());
|
resources.push(raw.toString());
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,6 +11,12 @@ import type { LensExtensionId, LensExtensionManifest } from "./lens-extension";
|
|||||||
|
|
||||||
export interface InstalledExtension {
|
export interface InstalledExtension {
|
||||||
readonly manifest: LensExtensionManifest;
|
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 manifestPath: string;
|
||||||
readonly isBundled: boolean; // defined in project root's package.json
|
readonly isBundled: boolean; // defined in project root's package.json
|
||||||
isEnabled: boolean;
|
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>> {
|
async load(): Promise<Map<LensExtensionId, InstalledExtension>> {
|
||||||
if (this.loadStarted) {
|
if (this.loadStarted) {
|
||||||
// The class is simplified by only supporting .load() to be called once
|
// 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);
|
const isEnabled = isBundled || extensionsStore.isEnabled(installedManifestPath);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
absolutePath: path.dirname(manifestPath),
|
||||||
manifestPath: installedManifestPath,
|
manifestPath: installedManifestPath,
|
||||||
manifest: manifestJson,
|
manifest: manifestJson,
|
||||||
isBundled,
|
isBundled,
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { app, ipcRenderer, remote } from "electron";
|
import { app, ipcRenderer, remote } from "electron";
|
||||||
|
import { EventEmitter } from "events";
|
||||||
import { action, computed, observable, reaction, toJS, when } from "mobx";
|
import { action, computed, observable, reaction, toJS, when } from "mobx";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import { getHostedCluster } from "../common/cluster-store";
|
import { getHostedCluster } from "../common/cluster-store";
|
||||||
@ -26,26 +27,32 @@ export class ExtensionLoader {
|
|||||||
protected instances = observable.map<LensExtensionId, LensExtension>();
|
protected instances = observable.map<LensExtensionId, LensExtension>();
|
||||||
protected readonly requestExtensionsChannel = "extensions:loaded";
|
protected readonly requestExtensionsChannel = "extensions:loaded";
|
||||||
|
|
||||||
|
// emits event "remove" of type LensExtension when the extension is removed
|
||||||
|
private events = new EventEmitter();
|
||||||
|
|
||||||
@observable isLoaded = false;
|
@observable isLoaded = false;
|
||||||
whenLoaded = when(() => this.isLoaded);
|
whenLoaded = when(() => this.isLoaded);
|
||||||
|
|
||||||
@computed get userExtensions(): Map<LensExtensionId, InstalledExtension> {
|
@computed get userExtensions(): Map<LensExtensionId, InstalledExtension> {
|
||||||
const extensions = this.extensions.toJS();
|
const extensions = this.extensions.toJS();
|
||||||
|
|
||||||
extensions.forEach((ext, extId) => {
|
extensions.forEach((ext, extId) => {
|
||||||
if (ext.isBundled) {
|
if (ext.isBundled) {
|
||||||
extensions.delete(extId);
|
extensions.delete(extId);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return extensions;
|
return extensions;
|
||||||
}
|
}
|
||||||
|
|
||||||
@action
|
@action
|
||||||
async init() {
|
async init() {
|
||||||
if (ipcRenderer) {
|
if (ipcRenderer) {
|
||||||
this.initRenderer();
|
await this.initRenderer();
|
||||||
} else {
|
} else {
|
||||||
this.initMain();
|
await this.initMain();
|
||||||
}
|
}
|
||||||
|
|
||||||
extensionsStore.manageState(this);
|
extensionsStore.manageState(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -57,8 +64,27 @@ export class ExtensionLoader {
|
|||||||
this.extensions.set(extension.manifestPath as LensExtensionId, extension);
|
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) {
|
removeExtension(lensExtensionId: LensExtensionId) {
|
||||||
// TODO: Remove the extension properly (from menus etc.)
|
this.removeInstance(lensExtensionId);
|
||||||
|
|
||||||
if (!this.extensions.delete(lensExtensionId)) {
|
if (!this.extensions.delete(lensExtensionId)) {
|
||||||
throw new Error(`Can't remove extension ${lensExtensionId}, doesn't exist.`);
|
throw new Error(`Can't remove extension ${lensExtensionId}, doesn't exist.`);
|
||||||
}
|
}
|
||||||
@ -79,14 +105,25 @@ export class ExtensionLoader {
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected async initRenderer() {
|
protected async initRenderer() {
|
||||||
const extensionListHandler = ( extensions: [LensExtensionId, InstalledExtension][]) => {
|
const extensionListHandler = (extensions: [LensExtensionId, InstalledExtension][]) => {
|
||||||
this.isLoaded = true;
|
this.isLoaded = true;
|
||||||
|
const receivedExtensionIds = extensions.map(([lensExtensionId]) => lensExtensionId);
|
||||||
|
|
||||||
|
// Add new extensions
|
||||||
extensions.forEach(([extId, ext]) => {
|
extensions.forEach(([extId, ext]) => {
|
||||||
if (!this.extensions.has(extId)) {
|
if (!this.extensions.has(extId)) {
|
||||||
this.extensions.set(extId, ext);
|
this.extensions.set(extId, ext);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Remove deleted extensions
|
||||||
|
this.extensions.forEach((_, lensExtensionId) => {
|
||||||
|
if (!receivedExtensionIds.includes(lensExtensionId)) {
|
||||||
|
this.removeExtension(lensExtensionId);
|
||||||
|
}
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
requestMain(this.requestExtensionsChannel).then(extensionListHandler);
|
requestMain(this.requestExtensionsChannel).then(extensionListHandler);
|
||||||
subscribeToBroadcast(this.requestExtensionsChannel, (event, extensions: [LensExtensionId, InstalledExtension][]) => {
|
subscribeToBroadcast(this.requestExtensionsChannel, (event, extensions: [LensExtensionId, InstalledExtension][]) => {
|
||||||
extensionListHandler(extensions);
|
extensionListHandler(extensions);
|
||||||
@ -95,36 +132,73 @@ export class ExtensionLoader {
|
|||||||
|
|
||||||
loadOnMain() {
|
loadOnMain() {
|
||||||
logger.info(`${logModule}: load on main`);
|
logger.info(`${logModule}: load on main`);
|
||||||
this.autoInitExtensions(async (ext: LensMainExtension) => [
|
this.autoInitExtensions(async (extension: LensMainExtension) => {
|
||||||
registries.menuRegistry.add(ext.appMenus)
|
// 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() {
|
loadOnClusterManagerRenderer() {
|
||||||
logger.info(`${logModule}: load on main renderer (cluster manager)`);
|
logger.info(`${logModule}: load on main renderer (cluster manager)`);
|
||||||
this.autoInitExtensions(async (ext: LensRendererExtension) => [
|
this.autoInitExtensions(async (extension: LensRendererExtension) => {
|
||||||
registries.globalPageRegistry.add(ext.globalPages, ext),
|
const removeItems = [
|
||||||
registries.globalPageMenuRegistry.add(ext.globalPageMenus, ext),
|
registries.globalPageRegistry.add(extension.globalPages, extension),
|
||||||
registries.appPreferenceRegistry.add(ext.appPreferences),
|
registries.globalPageMenuRegistry.add(extension.globalPageMenus, extension),
|
||||||
registries.clusterFeatureRegistry.add(ext.clusterFeatures),
|
registries.appPreferenceRegistry.add(extension.appPreferences),
|
||||||
registries.statusBarRegistry.add(ext.statusBarItems),
|
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() {
|
loadOnClusterRenderer() {
|
||||||
logger.info(`${logModule}: load on cluster renderer (dashboard)`);
|
logger.info(`${logModule}: load on cluster renderer (dashboard)`);
|
||||||
const cluster = getHostedCluster();
|
const cluster = getHostedCluster();
|
||||||
this.autoInitExtensions(async (ext: LensRendererExtension) => {
|
this.autoInitExtensions(async (extension: LensRendererExtension) => {
|
||||||
if (await ext.isEnabledForCluster(cluster) === false) {
|
if (await extension.isEnabledForCluster(cluster) === false) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
return [
|
|
||||||
registries.clusterPageRegistry.add(ext.clusterPages, ext),
|
const removeItems = [
|
||||||
registries.clusterPageMenuRegistry.add(ext.clusterPageMenus, ext),
|
registries.clusterPageRegistry.add(extension.clusterPages, extension),
|
||||||
registries.kubeObjectMenuRegistry.add(ext.kubeObjectMenuItems),
|
registries.clusterPageMenuRegistry.add(extension.clusterPageMenus, extension),
|
||||||
registries.kubeObjectDetailRegistry.add(ext.kubeObjectDetailItems),
|
registries.kubeObjectMenuRegistry.add(extension.kubeObjectMenuItems),
|
||||||
registries.kubeObjectStatusRegistry.add(ext.kubeObjectStatusTexts)
|
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 });
|
logger.error(`${logModule}: activation extension error`, { ext, err });
|
||||||
}
|
}
|
||||||
} else if (!ext.isEnabled && alreadyInit) {
|
} else if (!ext.isEnabled && alreadyInit) {
|
||||||
try {
|
this.removeInstance(extId);
|
||||||
const instance = this.instances.get(extId);
|
|
||||||
instance.disable();
|
|
||||||
this.instances.delete(extId);
|
|
||||||
} catch (err) {
|
|
||||||
logger.error(`${logModule}: deactivation extension error`, { ext, err });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, {
|
}, {
|
||||||
|
|||||||
@ -114,3 +114,7 @@ export class LensExtension {
|
|||||||
export function sanitizeExtensionName(name: string) {
|
export function sanitizeExtensionName(name: string) {
|
||||||
return name.replace("@", "").replace("/", "--");
|
return name.replace("@", "").replace("/", "--");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function extensionDisplayName(name: string, version: string) {
|
||||||
|
return `${name}@${version}`;
|
||||||
|
}
|
||||||
|
|||||||
@ -11,6 +11,7 @@ describe("getPageUrl", () => {
|
|||||||
name: "foo-bar",
|
name: "foo-bar",
|
||||||
version: "0.1.1"
|
version: "0.1.1"
|
||||||
},
|
},
|
||||||
|
absolutePath: "/absolute/fake/",
|
||||||
manifestPath: "/this/is/fake/package.json",
|
manifestPath: "/this/is/fake/package.json",
|
||||||
isBundled: false,
|
isBundled: false,
|
||||||
isEnabled: true
|
isEnabled: true
|
||||||
@ -41,6 +42,7 @@ describe("globalPageRegistry", () => {
|
|||||||
name: "@acme/foo-bar",
|
name: "@acme/foo-bar",
|
||||||
version: "0.1.1"
|
version: "0.1.1"
|
||||||
},
|
},
|
||||||
|
absolutePath: "/absolute/fake/",
|
||||||
manifestPath: "/this/is/fake/package.json",
|
manifestPath: "/this/is/fake/package.json",
|
||||||
isBundled: false,
|
isBundled: false,
|
||||||
isEnabled: true
|
isEnabled: true
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
// Base class for extensions-api registries
|
// Base class for extensions-api registries
|
||||||
import { action, observable } from "mobx";
|
import { action, observable } from "mobx";
|
||||||
import { LensExtension } from "../lens-extension";
|
import { LensExtension } from "../lens-extension";
|
||||||
import { recitfy } from "../../common/utils";
|
import { rectify } from "../../common/utils";
|
||||||
|
|
||||||
export class BaseRegistry<T> {
|
export class BaseRegistry<T> {
|
||||||
private items = observable<T>([], { deep: false });
|
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"
|
add(items: T | T[], ext?: LensExtension): () => void; // allow method overloading with required "ext"
|
||||||
@action
|
@action
|
||||||
add(items: T | T[]) {
|
add(items: T | T[]) {
|
||||||
const itemArray = recitfy(items);
|
const itemArray = rectify(items);
|
||||||
this.items.push(...itemArray);
|
this.items.push(...itemArray);
|
||||||
return () => this.remove(...itemArray);
|
return () => this.remove(...itemArray);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,7 +7,7 @@ import { compile } from "path-to-regexp";
|
|||||||
import { BaseRegistry } from "./base-registry";
|
import { BaseRegistry } from "./base-registry";
|
||||||
import { LensExtension, sanitizeExtensionName } from "../lens-extension";
|
import { LensExtension, sanitizeExtensionName } from "../lens-extension";
|
||||||
import logger from "../../main/logger";
|
import logger from "../../main/logger";
|
||||||
import { recitfy } from "../../common/utils";
|
import { rectify } from "../../common/utils";
|
||||||
|
|
||||||
export interface PageRegistration {
|
export interface PageRegistration {
|
||||||
/**
|
/**
|
||||||
@ -54,7 +54,7 @@ export function getExtensionPageUrl<P extends object>({ extensionId, pageId = ""
|
|||||||
export class PageRegistry extends BaseRegistry<RegisteredPage> {
|
export class PageRegistry extends BaseRegistry<RegisteredPage> {
|
||||||
@action
|
@action
|
||||||
add(items: PageRegistration | PageRegistration[], ext: LensExtension) {
|
add(items: PageRegistration | PageRegistration[], ext: LensExtension) {
|
||||||
const itemArray = recitfy(items);
|
const itemArray = rectify(items);
|
||||||
let registeredPages: RegisteredPage[] = [];
|
let registeredPages: RegisteredPage[] = [];
|
||||||
try {
|
try {
|
||||||
registeredPages = itemArray.map(page => ({
|
registeredPages = itemArray.map(page => ({
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
import { ipcMain } from "electron";
|
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 { IMetricsReqParams } from "../renderer/api/endpoints/metrics.api";
|
||||||
import type { WorkspaceId } from "../common/workspace-store";
|
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 { apiKubePrefix } from "../common/vars";
|
||||||
import { broadcastMessage } from "../common/ipc";
|
import { broadcastMessage } from "../common/ipc";
|
||||||
import { ContextHandler } from "./context-handler";
|
import { ContextHandler } from "./context-handler";
|
||||||
@ -27,7 +27,8 @@ export enum ClusterMetadataKey {
|
|||||||
CLUSTER_ID = "id",
|
CLUSTER_ID = "id",
|
||||||
DISTRIBUTION = "distribution",
|
DISTRIBUTION = "distribution",
|
||||||
NODES_COUNT = "nodes",
|
NODES_COUNT = "nodes",
|
||||||
LAST_SEEN = "lastSeen"
|
LAST_SEEN = "lastSeen",
|
||||||
|
PROMETHEUS = "prometheus"
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ClusterRefreshOptions = {
|
export type ClusterRefreshOptions = {
|
||||||
@ -42,7 +43,6 @@ export interface ClusterState {
|
|||||||
accessible: boolean;
|
accessible: boolean;
|
||||||
ready: boolean;
|
ready: boolean;
|
||||||
failureReason: string;
|
failureReason: string;
|
||||||
eventCount: number;
|
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
allowedNamespaces: string[]
|
allowedNamespaces: string[]
|
||||||
allowedResources: string[]
|
allowedResources: string[]
|
||||||
@ -74,7 +74,6 @@ export class Cluster implements ClusterModel, ClusterState {
|
|||||||
@observable disconnected = true; // false if user has selected to connect
|
@observable disconnected = true; // false if user has selected to connect
|
||||||
@observable failureReason: string;
|
@observable failureReason: string;
|
||||||
@observable isAdmin = false;
|
@observable isAdmin = false;
|
||||||
@observable eventCount = 0;
|
|
||||||
@observable preferences: ClusterPreferences = {};
|
@observable preferences: ClusterPreferences = {};
|
||||||
@observable metadata: ClusterMetadata = {};
|
@observable metadata: ClusterMetadata = {};
|
||||||
@observable allowedNamespaces: string[] = [];
|
@observable allowedNamespaces: string[] = [];
|
||||||
@ -89,6 +88,13 @@ export class Cluster implements ClusterModel, ClusterState {
|
|||||||
return this.preferences.clusterName || this.contextName;
|
return this.preferences.clusterName || this.contextName;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@computed get prometheusPreferences(): ClusterPrometheusPreferences {
|
||||||
|
const { prometheus, prometheusProvider } = this.preferences;
|
||||||
|
return toJS({ prometheus, prometheusProvider }, {
|
||||||
|
recurseEverything: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
get version(): string {
|
get version(): string {
|
||||||
return String(this.metadata?.version) || "";
|
return String(this.metadata?.version) || "";
|
||||||
}
|
}
|
||||||
@ -138,6 +144,7 @@ export class Cluster implements ClusterModel, ClusterState {
|
|||||||
if (ipcMain) {
|
if (ipcMain) {
|
||||||
this.eventDisposers.push(
|
this.eventDisposers.push(
|
||||||
reaction(() => this.getState(), () => this.pushState()),
|
reaction(() => this.getState(), () => this.pushState()),
|
||||||
|
reaction(() => this.prometheusPreferences, (prefs) => this.contextHandler.setupPrometheus(prefs), { equals: comparer.structural, }),
|
||||||
() => {
|
() => {
|
||||||
clearInterval(refreshTimer);
|
clearInterval(refreshTimer);
|
||||||
clearInterval(refreshMetadataTimer);
|
clearInterval(refreshMetadataTimer);
|
||||||
@ -209,10 +216,7 @@ export class Cluster implements ClusterModel, ClusterState {
|
|||||||
await this.refreshConnectionStatus();
|
await this.refreshConnectionStatus();
|
||||||
if (this.accessible) {
|
if (this.accessible) {
|
||||||
this.isAdmin = await this.isClusterAdmin();
|
this.isAdmin = await this.isClusterAdmin();
|
||||||
await Promise.all([
|
await this.refreshAllowedResources();
|
||||||
this.refreshEvents(),
|
|
||||||
this.refreshAllowedResources(),
|
|
||||||
]);
|
|
||||||
if (opts.refreshMetadata) {
|
if (opts.refreshMetadata) {
|
||||||
this.refreshMetadata();
|
this.refreshMetadata();
|
||||||
}
|
}
|
||||||
@ -242,11 +246,6 @@ export class Cluster implements ClusterModel, ClusterState {
|
|||||||
this.allowedResources = await this.getAllowedResources();
|
this.allowedResources = await this.getAllowedResources();
|
||||||
}
|
}
|
||||||
|
|
||||||
@action
|
|
||||||
async refreshEvents() {
|
|
||||||
this.eventCount = await this.getEventCount();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected getKubeconfig(): KubeConfig {
|
protected getKubeconfig(): KubeConfig {
|
||||||
return loadConfig(this.kubeConfigPath);
|
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 {
|
toJSON(): ClusterModel {
|
||||||
const model: ClusterModel = {
|
const model: ClusterModel = {
|
||||||
id: this.id,
|
id: this.id,
|
||||||
@ -393,7 +358,6 @@ export class Cluster implements ClusterModel, ClusterState {
|
|||||||
accessible: this.accessible,
|
accessible: this.accessible,
|
||||||
failureReason: this.failureReason,
|
failureReason: this.failureReason,
|
||||||
isAdmin: this.isAdmin,
|
isAdmin: this.isAdmin,
|
||||||
eventCount: this.eventCount,
|
|
||||||
allowedNamespaces: this.allowedNamespaces,
|
allowedNamespaces: this.allowedNamespaces,
|
||||||
allowedResources: this.allowedResources,
|
allowedResources: this.allowedResources,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import type { PrometheusProvider, PrometheusService } from "./prometheus/provider-registry";
|
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 { Cluster } from "./cluster";
|
||||||
import type httpProxy from "http-proxy";
|
import type httpProxy from "http-proxy";
|
||||||
import url, { UrlWithStringQuery } from "url";
|
import url, { UrlWithStringQuery } from "url";
|
||||||
@ -22,7 +22,7 @@ export class ContextHandler {
|
|||||||
this.setupPrometheus(cluster.preferences);
|
this.setupPrometheus(cluster.preferences);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected setupPrometheus(preferences: ClusterPreferences = {}) {
|
public setupPrometheus(preferences: ClusterPrometheusPreferences = {}) {
|
||||||
this.prometheusProvider = preferences.prometheusProvider?.type;
|
this.prometheusProvider = preferences.prometheusProvider?.type;
|
||||||
this.prometheusPath = null;
|
this.prometheusPath = null;
|
||||||
if (preferences.prometheus) {
|
if (preferences.prometheus) {
|
||||||
@ -32,13 +32,18 @@ export class ContextHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected async resolvePrometheusPath(): Promise<string> {
|
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}`;
|
return `${namespace}/services/${service}:${port}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getPrometheusProvider() {
|
async getPrometheusProvider() {
|
||||||
if (!this.prometheusProvider) {
|
if (!this.prometheusProvider) {
|
||||||
const service = await this.getPrometheusService();
|
const service = await this.getPrometheusService();
|
||||||
|
if (!service) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
logger.info(`using ${service.id} as prometheus provider`);
|
logger.info(`using ${service.id} as prometheus provider`);
|
||||||
this.prometheusProvider = service.id;
|
this.prometheusProvider = service.id;
|
||||||
}
|
}
|
||||||
@ -52,13 +57,7 @@ export class ContextHandler {
|
|||||||
return await provider.getPrometheusService(apiClient);
|
return await provider.getPrometheusService(apiClient);
|
||||||
});
|
});
|
||||||
const resolvedPrometheusServices = await Promise.all(prometheusPromises);
|
const resolvedPrometheusServices = await Promise.all(prometheusPromises);
|
||||||
const service = resolvedPrometheusServices.filter(n => n)[0];
|
return resolvedPrometheusServices.filter(n => n)[0];
|
||||||
return service || {
|
|
||||||
id: "lens",
|
|
||||||
namespace: "lens-metrics",
|
|
||||||
service: "prometheus",
|
|
||||||
port: 80
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getPrometheusPath(): Promise<string> {
|
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)`,
|
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)`,
|
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)`,
|
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)`,
|
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)`,
|
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)`
|
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"})`,
|
cpuRequests:`sum(kube_pod_container_resource_requests{node=~"${opts.nodes}", resource="cpu"})`,
|
||||||
cpuLimits: `sum(kube_pod_container_resource_limits{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"})`,
|
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"})`,
|
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}"})`,
|
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}"})`
|
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)`,
|
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)`,
|
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)`,
|
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)`,
|
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)`,
|
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)`
|
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 { LensApiRequest } from "../router";
|
||||||
import { LensApi } from "../lens-api";
|
import { LensApi } from "../lens-api";
|
||||||
import { Cluster } from "../cluster";
|
import { Cluster, ClusterMetadataKey } from "../cluster";
|
||||||
import _ from "lodash";
|
import { ClusterPrometheusMetadata } from "../../common/cluster-store";
|
||||||
|
import logger from "../logger";
|
||||||
|
|
||||||
export type IMetricsQuery = string | string[] | {
|
export type IMetricsQuery = string | string[] | {
|
||||||
[metricName: string]: string;
|
[metricName: string]: string;
|
||||||
@ -22,11 +24,9 @@ async function loadMetrics(promQueries: string[], cluster: Cluster, prometheusPa
|
|||||||
try {
|
try {
|
||||||
return await cluster.getMetrics(prometheusPath, { query, ...queryParams });
|
return await cluster.getMetrics(prometheusPath, { query, ...queryParams });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (lastAttempt || error?.statusCode === 404) {
|
if (lastAttempt || (error?.statusCode >= 400 && error?.statusCode < 500)) {
|
||||||
return {
|
logger.error("[Metrics]: metrics not available", { error });
|
||||||
status: error.toString(),
|
throw new Error("Metrics not available");
|
||||||
data: { result: [] },
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await new Promise(resolve => setTimeout(resolve, (attempt + 1) * 1000)); // add delay before repeating request
|
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 {
|
class MetricsRoute extends LensApi {
|
||||||
async routeMetrics({ response, cluster, payload, query }: LensApiRequest) {
|
async routeMetrics({ response, cluster, payload, query }: LensApiRequest) {
|
||||||
const queryParams: IMetricsQuery = Object.fromEntries(query.entries());
|
const queryParams: IMetricsQuery = Object.fromEntries(query.entries());
|
||||||
|
const prometheusMetadata: ClusterPrometheusMetadata = {};
|
||||||
try {
|
try {
|
||||||
const [prometheusPath, prometheusProvider] = await Promise.all([
|
const [prometheusPath, prometheusProvider] = await Promise.all([
|
||||||
cluster.contextHandler.getPrometheusPath(),
|
cluster.contextHandler.getPrometheusPath(),
|
||||||
cluster.contextHandler.getPrometheusProvider()
|
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
|
// return data in same structure as query
|
||||||
if (typeof payload === "string") {
|
if (typeof payload === "string") {
|
||||||
const [data] = await loadMetrics([payload], cluster, prometheusPath, queryParams);
|
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]]));
|
const data = Object.fromEntries(Object.keys(payload).map((metricName, i) => [metricName, result[i]]));
|
||||||
this.respondJson(response, data);
|
this.respondJson(response, data);
|
||||||
}
|
}
|
||||||
|
prometheusMetadata.success = true;
|
||||||
} catch {
|
} catch {
|
||||||
|
prometheusMetadata.success = false;
|
||||||
this.respondJson(response, {});
|
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 { autobind } from "../../utils";
|
||||||
import { KubeApi } from "../kube-api";
|
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()
|
@autobind()
|
||||||
export class StatefulSet extends WorkloadKubeObject {
|
export class StatefulSet extends WorkloadKubeObject {
|
||||||
static kind = "StatefulSet";
|
static kind = "StatefulSet";
|
||||||
@ -67,17 +90,22 @@ export class StatefulSet extends WorkloadKubeObject {
|
|||||||
observedGeneration: number;
|
observedGeneration: number;
|
||||||
replicas: number;
|
replicas: number;
|
||||||
currentReplicas: number;
|
currentReplicas: number;
|
||||||
|
readyReplicas: number;
|
||||||
currentRevision: string;
|
currentRevision: string;
|
||||||
updateRevision: string;
|
updateRevision: string;
|
||||||
collisionCount: number;
|
collisionCount: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
getReplicas() {
|
||||||
|
return this.spec.replicas || 0;
|
||||||
|
}
|
||||||
|
|
||||||
getImages() {
|
getImages() {
|
||||||
const containers: IPodContainer[] = get(this, "spec.template.spec.containers", []);
|
const containers: IPodContainer[] = get(this, "spec.template.spec.containers", []);
|
||||||
return [...containers].map(container => container.image);
|
return [...containers].map(container => container.image);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const statefulSetApi = new KubeApi({
|
export const statefulSetApi = new StatefulSetApi({
|
||||||
objectConstructor: StatefulSet,
|
objectConstructor: StatefulSet,
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,16 +1,20 @@
|
|||||||
.AddCluster {
|
.AddClusters {
|
||||||
.hint {
|
--flex-gap: #{$unit * 2};
|
||||||
margin-top: -$padding;
|
$spacing: $padding * 2;
|
||||||
color: $textColorSecondary;
|
|
||||||
|
|
||||||
> * {
|
|
||||||
vertical-align: middle;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.AceEditor {
|
.AceEditor {
|
||||||
min-height: 200px;
|
min-height: 200px;
|
||||||
max-height: 400px;
|
max-height: 400px;
|
||||||
|
border: 1px solid var(--colorVague);
|
||||||
|
border-radius: $radius;
|
||||||
|
|
||||||
|
.theme-light & {
|
||||||
|
border-color: var(--borderFaintColor);
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor {
|
||||||
|
border-radius: $radius;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.Select {
|
.Select {
|
||||||
@ -34,4 +38,13 @@
|
|||||||
code {
|
code {
|
||||||
color: $pink-400;
|
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 { Tab, Tabs } from "../tabs";
|
||||||
import { ExecValidationNotFoundError } from "../../../common/custom-errors";
|
import { ExecValidationNotFoundError } from "../../../common/custom-errors";
|
||||||
import { appEventBus } from "../../../common/event-bus";
|
import { appEventBus } from "../../../common/event-bus";
|
||||||
|
import { PageLayout } from "../layout/page-layout";
|
||||||
|
import { docsUrl } from "../../../common/vars";
|
||||||
|
|
||||||
enum KubeConfigSourceTab {
|
enum KubeConfigSourceTab {
|
||||||
FILE = "file",
|
FILE = "file",
|
||||||
@ -193,44 +195,19 @@ export class AddCluster extends React.Component {
|
|||||||
|
|
||||||
renderInfo() {
|
renderInfo() {
|
||||||
return (
|
return (
|
||||||
<Fragment>
|
<p>
|
||||||
<h2>Clusters associated with Lens</h2>
|
Add clusters by clicking the <span className="text-primary">Add Cluster</span> button.
|
||||||
<p>
|
You'll need to obtain a working kubeconfig for the cluster you want to add.
|
||||||
Add clusters by clicking the <span className="text-primary">Add Cluster</span> button.
|
You can either browse it from the file system or paste it as a text from the clipboard.
|
||||||
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>
|
</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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
renderKubeConfigSource() {
|
renderKubeConfigSource() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Tabs withBorder onChange={this.onKubeConfigTabChange}>
|
<Tabs onChange={this.onKubeConfigTabChange}>
|
||||||
<Tab
|
<Tab
|
||||||
value={KubeConfigSourceTab.FILE}
|
value={KubeConfigSourceTab.FILE}
|
||||||
label={<Trans>Select kubeconfig file</Trans>}
|
label={<Trans>Select kubeconfig file</Trans>}
|
||||||
@ -242,7 +219,7 @@ export class AddCluster extends React.Component {
|
|||||||
/>
|
/>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
{this.sourceTab === KubeConfigSourceTab.FILE && (
|
{this.sourceTab === KubeConfigSourceTab.FILE && (
|
||||||
<>
|
<div>
|
||||||
<div className="kube-config-select flex gaps align-center">
|
<div className="kube-config-select flex gaps align-center">
|
||||||
<Input
|
<Input
|
||||||
theme="round-black"
|
theme="round-black"
|
||||||
@ -267,10 +244,10 @@ export class AddCluster extends React.Component {
|
|||||||
<small className="hint">
|
<small className="hint">
|
||||||
<Trans>Pro-Tip: you can also drag-n-drop kubeconfig file to this area</Trans>
|
<Trans>Pro-Tip: you can also drag-n-drop kubeconfig file to this area</Trans>
|
||||||
</small>
|
</small>
|
||||||
</>
|
</div>
|
||||||
)}
|
)}
|
||||||
{this.sourceTab === KubeConfigSourceTab.TEXT && (
|
{this.sourceTab === KubeConfigSourceTab.TEXT && (
|
||||||
<>
|
<div className="flex column">
|
||||||
<AceEditor
|
<AceEditor
|
||||||
autoFocus
|
autoFocus
|
||||||
showGutter={false}
|
showGutter={false}
|
||||||
@ -284,7 +261,7 @@ export class AddCluster extends React.Component {
|
|||||||
<small className="hint">
|
<small className="hint">
|
||||||
<Trans>Pro-Tip: paste kubeconfig to get available contexts</Trans>
|
<Trans>Pro-Tip: paste kubeconfig to get available contexts</Trans>
|
||||||
</small>
|
</small>
|
||||||
</>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@ -296,7 +273,7 @@ export class AddCluster extends React.Component {
|
|||||||
? <Trans>Selected contexts: <b>{this.selectedContexts.length}</b></Trans>
|
? <Trans>Selected contexts: <b>{this.selectedContexts.length}</b></Trans>
|
||||||
: <Trans>Select contexts</Trans>;
|
: <Trans>Select contexts</Trans>;
|
||||||
return (
|
return (
|
||||||
<>
|
<div>
|
||||||
<Select
|
<Select
|
||||||
id="kubecontext-select" // todo: provide better mapping for integration tests (e.g. data-test-id="..")
|
id="kubecontext-select" // todo: provide better mapping for integration tests (e.g. data-test-id="..")
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
@ -320,7 +297,7 @@ export class AddCluster extends React.Component {
|
|||||||
<code>{this.selectedContexts.join(", ")}</code>
|
<code>{this.selectedContexts.join(", ")}</code>
|
||||||
</small>
|
</small>
|
||||||
)}
|
)}
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -357,9 +334,12 @@ export class AddCluster extends React.Component {
|
|||||||
render() {
|
render() {
|
||||||
const addDisabled = this.selectedContexts.length === 0;
|
const addDisabled = this.selectedContexts.length === 0;
|
||||||
return (
|
return (
|
||||||
<DropFileInput onDropFiles={this.onDropKubeConfig}>
|
<PageLayout className="AddClusters" header={<h2>Add Clusters</h2>}>
|
||||||
<WizardLayout className="AddCluster" infoPanel={this.renderInfo()}>
|
<h2>Add Clusters from Kubeconfig</h2>
|
||||||
<h2><Trans>Add Cluster</Trans></h2>
|
|
||||||
|
{this.renderInfo()}
|
||||||
|
|
||||||
|
<DropFileInput onDropFiles={this.onDropKubeConfig}>
|
||||||
{this.renderKubeConfigSource()}
|
{this.renderKubeConfigSource()}
|
||||||
{this.renderContextSelector()}
|
{this.renderContextSelector()}
|
||||||
<div className="cluster-settings">
|
<div className="cluster-settings">
|
||||||
@ -384,19 +364,20 @@ export class AddCluster extends React.Component {
|
|||||||
{this.error && (
|
{this.error && (
|
||||||
<div className="error">{this.error}</div>
|
<div className="error">{this.error}</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="actions-panel">
|
<div className="actions-panel">
|
||||||
<Button
|
<Button
|
||||||
primary
|
primary
|
||||||
disabled={addDisabled}
|
disabled={addDisabled}
|
||||||
label={<Trans>Add cluster(s)</Trans>}
|
label={this.selectedContexts.length < 2 ? <Trans>Add cluster</Trans> : <Trans>Add clusters</Trans>}
|
||||||
onClick={this.addClusters}
|
onClick={this.addClusters}
|
||||||
waiting={this.isWaiting}
|
waiting={this.isWaiting}
|
||||||
tooltip={addDisabled ? _i18n._("Select at least one cluster to add.") : undefined}
|
tooltip={addDisabled ? _i18n._("Select at least one cluster to add.") : undefined}
|
||||||
tooltipOverrideDisabled
|
tooltipOverrideDisabled
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</WizardLayout>
|
</DropFileInput>
|
||||||
</DropFileInput>
|
</PageLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,8 +2,7 @@ import React from "react";
|
|||||||
import { observable, autorun } from "mobx";
|
import { observable, autorun } from "mobx";
|
||||||
import { observer, disposeOnUnmount } from "mobx-react";
|
import { observer, disposeOnUnmount } from "mobx-react";
|
||||||
import { Cluster } from "../../../../main/cluster";
|
import { Cluster } from "../../../../main/cluster";
|
||||||
import { Input } from "../../input";
|
import { Input, InputValidators } from "../../input";
|
||||||
import { isUrl } from "../../input/input_validators";
|
|
||||||
import { SubTitle } from "../../layout/sub-title";
|
import { SubTitle } from "../../layout/sub-title";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@ -41,7 +40,7 @@ export class ClusterProxySetting extends React.Component<Props> {
|
|||||||
onChange={this.onChange}
|
onChange={this.onChange}
|
||||||
onBlur={this.save}
|
onBlur={this.save}
|
||||||
placeholder="http://<address>:<port>"
|
placeholder="http://<address>:<port>"
|
||||||
validators={isUrl}
|
validators={this.proxy ? InputValidators.isUrl : undefined}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,61 +1,40 @@
|
|||||||
.Extensions {
|
.PageLayout.Extensions {
|
||||||
$spacing: $padding * 2;
|
$spacing: $padding * 2;
|
||||||
--width: 100%;
|
--width: 50%;
|
||||||
--max-width: auto;
|
|
||||||
|
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 {
|
.extension {
|
||||||
--flex-gap: $padding / 3;
|
|
||||||
padding: $padding $spacing;
|
padding: $padding $spacing;
|
||||||
background: $colorVague;
|
background: $layoutBackground;
|
||||||
border-radius: $radius;
|
border-radius: $radius;
|
||||||
|
|
||||||
&:not(:first-of-type) {
|
.actions > button:not(:last-child) {
|
||||||
margin-top: $spacing;
|
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 {
|
.SearchInput {
|
||||||
--spacing: #{$padding};
|
--spacing: 10px;
|
||||||
width: 100%;
|
|
||||||
max-width: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.WizardLayout {
|
|
||||||
padding: 0;
|
|
||||||
|
|
||||||
.info-col {
|
|
||||||
flex: 0.6;
|
|
||||||
align-self: flex-start;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,26 +1,27 @@
|
|||||||
import "./extensions.scss";
|
import { t, Trans } from "@lingui/macro";
|
||||||
import { remote, shell } from "electron";
|
import { remote, shell } from "electron";
|
||||||
import os from "os";
|
|
||||||
import path from "path";
|
|
||||||
import fse from "fs-extra";
|
import fse from "fs-extra";
|
||||||
import React from "react";
|
|
||||||
import { computed, observable } from "mobx";
|
import { computed, observable } from "mobx";
|
||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
import { t, Trans } from "@lingui/macro";
|
import os from "os";
|
||||||
import { _i18n } from "../../i18n";
|
import path from "path";
|
||||||
import { Button } from "../button";
|
import React from "react";
|
||||||
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 { downloadFile, extractTar, listTarEntries, readFileFromTar } from "../../../common/utils";
|
import { downloadFile, extractTar, listTarEntries, readFileFromTar } from "../../../common/utils";
|
||||||
import { docsUrl } from "../../../common/vars";
|
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 {
|
interface InstallRequest {
|
||||||
fileName: string;
|
fileName: string;
|
||||||
@ -40,8 +41,16 @@ interface InstallRequestValidated extends InstallRequestPreloaded {
|
|||||||
@observer
|
@observer
|
||||||
export class Extensions extends React.Component {
|
export class Extensions extends React.Component {
|
||||||
private supportedFormats = [".tar", ".tgz"];
|
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 search = "";
|
||||||
@observable downloadUrl = "";
|
@observable installPath = "";
|
||||||
|
|
||||||
@computed get extensions() {
|
@computed get extensions() {
|
||||||
const searchText = this.search.toLowerCase();
|
const searchText = this.search.toLowerCase();
|
||||||
@ -87,25 +96,25 @@ export class Extensions extends React.Component {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
addExtensions = () => {
|
installFromUrlOrPath = async () => {
|
||||||
const { downloadUrl } = this;
|
const { installPath } = this;
|
||||||
if (downloadUrl && InputValidators.isUrl.validate(downloadUrl)) {
|
if (!installPath) return;
|
||||||
this.installFromUrl(downloadUrl);
|
const fileName = path.basename(installPath);
|
||||||
} else {
|
|
||||||
this.installFromSelectFileDialog();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
installFromUrl = async (url: string) => {
|
|
||||||
try {
|
try {
|
||||||
const { promise: filePromise } = downloadFile({ url });
|
// install via url
|
||||||
this.requestInstall([{
|
// fixme: improve error messages for non-tar-file URLs
|
||||||
fileName: path.basename(url),
|
if (InputValidators.isUrl.validate(installPath)) {
|
||||||
data: await filePromise,
|
const { promise: filePromise } = downloadFile({ url: installPath, timeout: 60000 /*1m*/ });
|
||||||
}]);
|
const data = await filePromise;
|
||||||
} catch (err) {
|
this.requestInstall({ fileName, data });
|
||||||
|
}
|
||||||
|
// otherwise installing from system path
|
||||||
|
else if (InputValidators.isPath.validate(installPath)) {
|
||||||
|
this.requestInstall({ fileName, filePath: installPath });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
Notifications.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 } = {}) {
|
async preloadExtensions(requests: InstallRequest[], { showError = true } = {}) {
|
||||||
const preloadedRequests = requests.filter(req => req.data);
|
const preloadedRequests = requests.filter(req => req.data);
|
||||||
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
requests
|
requests
|
||||||
.filter(req => !req.data && req.filePath)
|
.filter(req => !req.data && req.filePath)
|
||||||
@ -129,13 +139,14 @@ export class Extensions extends React.Component {
|
|||||||
return fse.readFile(req.filePath).then(data => {
|
return fse.readFile(req.filePath).then(data => {
|
||||||
req.data = data;
|
req.data = data;
|
||||||
preloadedRequests.push(req);
|
preloadedRequests.push(req);
|
||||||
}).catch(err => {
|
}).catch(error => {
|
||||||
if (showError) {
|
if (showError) {
|
||||||
Notifications.error(`Error while reading "${req.filePath}": ${String(err)}`);
|
Notifications.error(`Error while reading "${req.filePath}": ${String(error)}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
return preloadedRequests as InstallRequestPreloaded[];
|
return preloadedRequests as InstallRequestPreloaded[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -182,13 +193,13 @@ export class Extensions extends React.Component {
|
|||||||
manifest,
|
manifest,
|
||||||
tempFile,
|
tempFile,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (error) {
|
||||||
fse.unlink(tempFile).catch(() => null); // remove invalid temp package
|
fse.unlink(tempFile).catch(() => null); // remove invalid temp package
|
||||||
if (showErrors) {
|
if (showErrors) {
|
||||||
Notifications.error(
|
Notifications.error(
|
||||||
<div className="flex column gaps">
|
<div className="flex column gaps">
|
||||||
<p>Installing <em>{req.fileName}</em> has failed, skipping.</p>
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -198,7 +209,8 @@ export class Extensions extends React.Component {
|
|||||||
return validatedRequests;
|
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 preloadedRequests = await this.preloadExtensions(requests);
|
||||||
const validatedRequests = await this.createTempFilesAndValidate(preloadedRequests);
|
const validatedRequests = await this.createTempFilesAndValidate(preloadedRequests);
|
||||||
|
|
||||||
@ -231,7 +243,7 @@ export class Extensions extends React.Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async unpackExtension({ fileName, tempFile, manifest: { name, version } }: InstallRequestValidated) {
|
async unpackExtension({ fileName, tempFile, manifest: { name, version } }: InstallRequestValidated) {
|
||||||
const extName = `${name}@${version}`;
|
const extName = extensionDisplayName(name, version);
|
||||||
logger.info(`Unpacking extension ${extName}`, { fileName, tempFile });
|
logger.info(`Unpacking extension ${extName}`, { fileName, tempFile });
|
||||||
const unpackingTempFolder = path.join(path.dirname(tempFile), path.basename(tempFile) + "-unpacked");
|
const unpackingTempFolder = path.join(path.dirname(tempFile), path.basename(tempFile) + "-unpacked");
|
||||||
const extensionFolder = this.getExtensionDestFolder(name);
|
const extensionFolder = this.getExtensionDestFolder(name);
|
||||||
@ -254,9 +266,9 @@ export class Extensions extends React.Component {
|
|||||||
Notifications.ok(
|
Notifications.ok(
|
||||||
<p>Extension <b>{extName}</b> successfully installed!</p>
|
<p>Extension <b>{extName}</b> successfully installed!</p>
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (error) {
|
||||||
Notifications.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 {
|
} finally {
|
||||||
// clean up
|
// clean up
|
||||||
@ -265,92 +277,122 @@ export class Extensions extends React.Component {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
renderInfo() {
|
async uninstallExtension(extension: InstalledExtension) {
|
||||||
return (
|
const extensionName = extensionDisplayName(extension.manifest.name, extension.manifest.version);
|
||||||
<div className="extensions-info flex column gaps">
|
|
||||||
<h2>Lens Extensions</h2>
|
try {
|
||||||
<div>
|
await extensionDiscovery.uninstallExtension(extension.absolutePath);
|
||||||
The features that Lens includes out-of-the-box are just the start.
|
} catch (error) {
|
||||||
Lens extensions let you add new features to your installation to support your workflow.
|
Notifications.error(
|
||||||
Rich extensibility model lets extension authors plug directly into the Lens UI and contribute functionality through the same APIs used by Lens itself.
|
<p>Uninstalling extension <b>{extensionName}</b> has failed: <em>{error?.message ?? ""}</em></p>
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
renderExtensions() {
|
renderExtensions() {
|
||||||
const { extensions, extensionsPath, search } = this;
|
const { extensions, extensionsPath, search } = this;
|
||||||
|
|
||||||
if (!extensions.length) {
|
if (!extensions.length) {
|
||||||
return (
|
return (
|
||||||
<div className="flex align-center box grow justify-center gaps">
|
<div className="no-extensions flex box gaps justify-center">
|
||||||
{search && <Trans>No search results found</Trans>}
|
<Icon material="info"/>
|
||||||
{!search && <p><Trans>There are no extensions in</Trans> <code>{extensionsPath}</code></p>}
|
<div>
|
||||||
|
{search && <p>No search results found</p>}
|
||||||
|
{!search && <p>There are no extensions in <code>{extensionsPath}</code></p>}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return extensions.map(ext => {
|
return extensions.map(ext => {
|
||||||
const { manifestPath: extId, isEnabled, manifest } = ext;
|
const { manifestPath: extId, isEnabled, manifest } = ext;
|
||||||
const { name, description } = manifest;
|
const { name, description } = manifest;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={extId} className="extension flex gaps align-center">
|
<div key={extId} className="extension flex gaps align-center">
|
||||||
<div className="box grow flex column gaps">
|
<div className="box grow">
|
||||||
<div className="package">
|
<div className="name">
|
||||||
Name: <code className="name">{name}</code>
|
Name: <code className="name">{name}</code>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="description">
|
||||||
Description: <span className="text-secondary">{description}</span>
|
Description: <span className="text-secondary">{description}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{!isEnabled && (
|
<div className="actions">
|
||||||
<Button plain active onClick={() => ext.isEnabled = true}>Enable</Button>
|
{!isEnabled && (
|
||||||
)}
|
<Button plain active onClick={() => ext.isEnabled = true}>Enable</Button>
|
||||||
{isEnabled && (
|
)}
|
||||||
<Button accent onClick={() => ext.isEnabled = false}>Disable</Button>
|
{isEnabled && (
|
||||||
)}
|
<Button accent onClick={() => ext.isEnabled = false}>Disable</Button>
|
||||||
|
)}
|
||||||
|
<Button plain active onClick={() => {
|
||||||
|
this.uninstallExtension(ext);
|
||||||
|
}}>Uninstall</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
|
const topHeader = <h2>Manage Lens Extensions</h2>;
|
||||||
|
const { installPath } = this;
|
||||||
return (
|
return (
|
||||||
<PageLayout showOnTop className="Extensions" header={<h2>Extensions</h2>}>
|
<DropFileInput onDropFiles={this.installOnDrop}>
|
||||||
<DropFileInput onDropFiles={this.installOnDrop}>
|
<PageLayout showOnTop className="Extensions flex column gaps" header={topHeader} contentGaps={false}>
|
||||||
<WizardLayout infoPanel={this.renderInfo()}>
|
<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
|
<SearchInput
|
||||||
placeholder={_i18n._(t`Search installed extensions`)}
|
placeholder="Search extensions by name or description"
|
||||||
value={this.search}
|
value={this.search}
|
||||||
onChange={(value) => this.search = value}
|
onChange={(value) => this.search = value}
|
||||||
/>
|
/>
|
||||||
<div className="extensions-list">
|
{this.renderExtensions()}
|
||||||
{this.renderExtensions()}
|
</div>
|
||||||
</div>
|
</PageLayout>
|
||||||
</WizardLayout>
|
</DropFileInput>
|
||||||
</DropFileInput>
|
|
||||||
</PageLayout>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,8 +1,7 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Trans } from '@lingui/macro';
|
import { Trans } from '@lingui/macro';
|
||||||
import { isPath } from '../input/input_validators';
|
|
||||||
import { Checkbox } from '../checkbox';
|
import { Checkbox } from '../checkbox';
|
||||||
import { Input } from '../input';
|
import { Input, InputValidators } from '../input';
|
||||||
import { SubTitle } from '../layout/sub-title';
|
import { SubTitle } from '../layout/sub-title';
|
||||||
import { UserPreferences, userStore } from '../../../common/user-store';
|
import { UserPreferences, userStore } from '../../../common/user-store';
|
||||||
import { observer } from 'mobx-react';
|
import { observer } from 'mobx-react';
|
||||||
@ -12,6 +11,7 @@ import { SelectOption, Select } from '../select';
|
|||||||
export const KubectlBinaries = observer(({ preferences }: { preferences: UserPreferences }) => {
|
export const KubectlBinaries = observer(({ preferences }: { preferences: UserPreferences }) => {
|
||||||
const [downloadPath, setDownloadPath] = useState(preferences.downloadBinariesPath || "");
|
const [downloadPath, setDownloadPath] = useState(preferences.downloadBinariesPath || "");
|
||||||
const [binariesPath, setBinariesPath] = useState(preferences.kubectlBinariesPath || "");
|
const [binariesPath, setBinariesPath] = useState(preferences.kubectlBinariesPath || "");
|
||||||
|
const pathValidator = downloadPath ? InputValidators.isPath : undefined;
|
||||||
|
|
||||||
const downloadMirrorOptions: SelectOption<string>[] = [
|
const downloadMirrorOptions: SelectOption<string>[] = [
|
||||||
{ value: "default", label: "Default (Google)" },
|
{ value: "default", label: "Default (Google)" },
|
||||||
@ -47,7 +47,7 @@ export const KubectlBinaries = observer(({ preferences }: { preferences: UserPre
|
|||||||
theme="round-black"
|
theme="round-black"
|
||||||
value={downloadPath}
|
value={downloadPath}
|
||||||
placeholder={userStore.getDefaultKubectlPath()}
|
placeholder={userStore.getDefaultKubectlPath()}
|
||||||
validators={isPath}
|
validators={pathValidator}
|
||||||
onChange={setDownloadPath}
|
onChange={setDownloadPath}
|
||||||
onBlur={save}
|
onBlur={save}
|
||||||
disabled={!preferences.downloadKubectlBinaries}
|
disabled={!preferences.downloadKubectlBinaries}
|
||||||
@ -60,7 +60,7 @@ export const KubectlBinaries = observer(({ preferences }: { preferences: UserPre
|
|||||||
theme="round-black"
|
theme="round-black"
|
||||||
placeholder={bundledKubectlPath()}
|
placeholder={bundledKubectlPath()}
|
||||||
value={binariesPath}
|
value={binariesPath}
|
||||||
validators={isPath}
|
validators={pathValidator}
|
||||||
onChange={setBinariesPath}
|
onChange={setBinariesPath}
|
||||||
onBlur={save}
|
onBlur={save}
|
||||||
disabled={preferences.downloadKubectlBinaries}
|
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 {
|
&.pods {
|
||||||
flex-grow: 0.3;
|
flex-grow: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.warning {
|
&.warning {
|
||||||
|
|||||||
@ -3,21 +3,27 @@ import "./statefulsets.scss";
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
import { RouteComponentProps } from "react-router";
|
import { RouteComponentProps } from "react-router";
|
||||||
import { Trans } from "@lingui/macro";
|
import { t, Trans } from "@lingui/macro";
|
||||||
import { StatefulSet } from "../../api/endpoints";
|
import { StatefulSet, statefulSetApi } from "../../api/endpoints";
|
||||||
import { podsStore } from "../+workloads-pods/pods.store";
|
import { podsStore } from "../+workloads-pods/pods.store";
|
||||||
import { statefulSetStore } from "./statefulset.store";
|
import { statefulSetStore } from "./statefulset.store";
|
||||||
import { nodesStore } from "../+nodes/nodes.store";
|
import { nodesStore } from "../+nodes/nodes.store";
|
||||||
import { eventStore } from "../+events/event.store";
|
import { eventStore } from "../+events/event.store";
|
||||||
|
import { KubeObjectMenuProps } from "../kube-object/kube-object-menu";
|
||||||
import { KubeObjectListLayout } from "../kube-object";
|
import { KubeObjectListLayout } from "../kube-object";
|
||||||
import { IStatefulSetsRouteParams } from "../+workloads";
|
import { IStatefulSetsRouteParams } from "../+workloads";
|
||||||
import { KubeObjectStatusIcon } from "../kube-object-status-icon";
|
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 {
|
enum sortBy {
|
||||||
name = "name",
|
name = "name",
|
||||||
namespace = "namespace",
|
namespace = "namespace",
|
||||||
pods = "pods",
|
|
||||||
age = "age",
|
age = "age",
|
||||||
|
replicas = "replicas",
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props extends RouteComponentProps<IStatefulSetsRouteParams> {
|
interface Props extends RouteComponentProps<IStatefulSetsRouteParams> {
|
||||||
@ -25,8 +31,9 @@ interface Props extends RouteComponentProps<IStatefulSetsRouteParams> {
|
|||||||
|
|
||||||
@observer
|
@observer
|
||||||
export class StatefulSets extends React.Component<Props> {
|
export class StatefulSets extends React.Component<Props> {
|
||||||
getPodsLength(statefulSet: StatefulSet) {
|
renderPods(statefulSet: StatefulSet) {
|
||||||
return statefulSetStore.getChildPods(statefulSet).length;
|
const { readyReplicas, currentReplicas } = statefulSet.status;
|
||||||
|
return `${readyReplicas || 0}/${currentReplicas || 0}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
@ -38,7 +45,7 @@ export class StatefulSets extends React.Component<Props> {
|
|||||||
[sortBy.name]: (statefulSet: StatefulSet) => statefulSet.getName(),
|
[sortBy.name]: (statefulSet: StatefulSet) => statefulSet.getName(),
|
||||||
[sortBy.namespace]: (statefulSet: StatefulSet) => statefulSet.getNs(),
|
[sortBy.namespace]: (statefulSet: StatefulSet) => statefulSet.getNs(),
|
||||||
[sortBy.age]: (statefulSet: StatefulSet) => statefulSet.metadata.creationTimestamp,
|
[sortBy.age]: (statefulSet: StatefulSet) => statefulSet.metadata.creationTimestamp,
|
||||||
[sortBy.pods]: (statefulSet: StatefulSet) => this.getPodsLength(statefulSet),
|
[sortBy.replicas]: (statefulSet: StatefulSet) => statefulSet.getReplicas(),
|
||||||
}}
|
}}
|
||||||
searchFilters={[
|
searchFilters={[
|
||||||
(statefulSet: StatefulSet) => statefulSet.getSearchFields(),
|
(statefulSet: StatefulSet) => statefulSet.getSearchFields(),
|
||||||
@ -47,18 +54,43 @@ export class StatefulSets extends React.Component<Props> {
|
|||||||
renderTableHeader={[
|
renderTableHeader={[
|
||||||
{ title: <Trans>Name</Trans>, className: "name", sortBy: sortBy.name },
|
{ title: <Trans>Name</Trans>, className: "name", sortBy: sortBy.name },
|
||||||
{ title: <Trans>Namespace</Trans>, className: "namespace", sortBy: sortBy.namespace },
|
{ 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" },
|
{ className: "warning" },
|
||||||
{ title: <Trans>Age</Trans>, className: "age", sortBy: sortBy.age },
|
{ title: <Trans>Age</Trans>, className: "age", sortBy: sortBy.age },
|
||||||
]}
|
]}
|
||||||
renderTableContents={(statefulSet: StatefulSet) => [
|
renderTableContents={(statefulSet: StatefulSet) => [
|
||||||
statefulSet.getName(),
|
statefulSet.getName(),
|
||||||
statefulSet.getNs(),
|
statefulSet.getNs(),
|
||||||
this.getPodsLength(statefulSet),
|
this.renderPods(statefulSet),
|
||||||
|
statefulSet.getReplicas(),
|
||||||
<KubeObjectStatusIcon object={statefulSet}/>,
|
<KubeObjectStatusIcon object={statefulSet}/>,
|
||||||
statefulSet.getAge(),
|
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 { clusterPageRegistry, getExtensionPageUrl, PageRegistration, RegisteredPage } from "../../extensions/registries/page-registry";
|
||||||
import { extensionLoader } from "../../extensions/extension-loader";
|
import { extensionLoader } from "../../extensions/extension-loader";
|
||||||
import { appEventBus } from "../../common/event-bus";
|
import { appEventBus } from "../../common/event-bus";
|
||||||
import { requestMain } from "../../common/ipc";
|
import { broadcastMessage, requestMain } from "../../common/ipc";
|
||||||
import whatInput from 'what-input';
|
import whatInput from 'what-input';
|
||||||
import { clusterSetFrameIdHandler } from "../../common/cluster-ipc";
|
import { clusterSetFrameIdHandler } from "../../common/cluster-ipc";
|
||||||
import { ClusterPageMenuRegistration, clusterPageMenuRegistry } from "../../extensions/registries";
|
import { ClusterPageMenuRegistration, clusterPageMenuRegistry } from "../../extensions/registries";
|
||||||
import { TabLayoutRoute, TabLayout } from "./layout/tab-layout";
|
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
|
@observer
|
||||||
export class App extends React.Component {
|
export class App extends React.Component {
|
||||||
@ -68,6 +73,39 @@ export class App extends React.Component {
|
|||||||
whatInput.ask(); // Start to monitor user input device
|
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() {
|
get startURL() {
|
||||||
if (isAllowedResource(["events", "nodes", "pods"])) {
|
if (isAllowedResource(["events", "nodes", "pods"])) {
|
||||||
return clusterURL();
|
return clusterURL();
|
||||||
@ -150,6 +188,7 @@ export class App extends React.Component {
|
|||||||
<KubeConfigDialog/>
|
<KubeConfigDialog/>
|
||||||
<AddRoleBindingDialog/>
|
<AddRoleBindingDialog/>
|
||||||
<DeploymentScaleDialog/>
|
<DeploymentScaleDialog/>
|
||||||
|
<StatefulSetScaleDialog/>
|
||||||
<CronJobTriggerDialog/>
|
<CronJobTriggerDialog/>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
</Router>
|
</Router>
|
||||||
|
|||||||
@ -1,13 +1,17 @@
|
|||||||
import "./cluster-icon.scss";
|
import "./cluster-icon.scss";
|
||||||
|
|
||||||
import React, { DOMAttributes } from "react";
|
import React, { DOMAttributes } from "react";
|
||||||
import { observer } from "mobx-react";
|
import { disposeOnUnmount, observer } from "mobx-react";
|
||||||
import { Params as HashiconParams } from "@emeraldpay/hashicon";
|
import { Params as HashiconParams } from "@emeraldpay/hashicon";
|
||||||
import { Hashicon } from "@emeraldpay/hashicon-react";
|
import { Hashicon } from "@emeraldpay/hashicon-react";
|
||||||
import { Cluster } from "../../../main/cluster";
|
import { Cluster } from "../../../main/cluster";
|
||||||
import { cssNames, IClassName } from "../../utils";
|
import { cssNames, IClassName } from "../../utils";
|
||||||
import { Badge } from "../badge";
|
import { Badge } from "../badge";
|
||||||
import { Tooltip } from "../tooltip";
|
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> {
|
interface Props extends DOMAttributes<HTMLElement> {
|
||||||
cluster: Cluster;
|
cluster: Cluster;
|
||||||
@ -29,12 +33,29 @@ const defaultProps: Partial<Props> = {
|
|||||||
export class ClusterIcon extends React.Component<Props> {
|
export class ClusterIcon extends React.Component<Props> {
|
||||||
static defaultProps = defaultProps as object;
|
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() {
|
render() {
|
||||||
const {
|
const {
|
||||||
cluster, showErrors, showTooltip, errorClass, options, interactive, isActive,
|
cluster, showErrors, showTooltip, errorClass, options, interactive, isActive,
|
||||||
children, ...elemProps
|
children, ...elemProps
|
||||||
} = this.props;
|
} = this.props;
|
||||||
const { isAdmin, name, eventCount, preferences, id: clusterId } = cluster;
|
const { name, preferences, id: clusterId } = cluster;
|
||||||
|
const eventCount = this.eventCount;
|
||||||
const { icon } = preferences;
|
const { icon } = preferences;
|
||||||
const clusterIconId = `cluster-icon-${clusterId}`;
|
const clusterIconId = `cluster-icon-${clusterId}`;
|
||||||
const className = cssNames("ClusterIcon flex inline", this.props.className, {
|
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 && <img src={icon} alt={name}/>}
|
||||||
{!icon && <Hashicon value={clusterId} options={options}/>}
|
{!icon && <Hashicon value={clusterId} options={options}/>}
|
||||||
{showErrors && isAdmin && eventCount > 0 && (
|
{showErrors && eventCount > 0 && !isActive && (
|
||||||
<Badge
|
<Badge
|
||||||
className={cssNames("events-count", errorClass)}
|
className={cssNames("events-count", errorClass)}
|
||||||
label={eventCount >= 1000 ? Math.ceil(eventCount / 1000) + "k+" : eventCount}
|
label={eventCount >= 1000 ? Math.ceil(eventCount / 1000) + "k+" : eventCount}
|
||||||
|
|||||||
@ -60,7 +60,7 @@ export const PodLogSearch = observer((props: PodLogSearchProps) => {
|
|||||||
<SearchInput
|
<SearchInput
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={setSearch}
|
onChange={setSearch}
|
||||||
closeIcon={false}
|
showClearIcon={false}
|
||||||
contentRight={totalFinds > 0 && findCounts}
|
contentRight={totalFinds > 0 && findCounts}
|
||||||
onClear={onClear}
|
onClear={onClear}
|
||||||
onKeyDown={onKeyDown}
|
onKeyDown={onKeyDown}
|
||||||
|
|||||||
@ -61,7 +61,7 @@ export class DropFileInput<T extends HTMLElement = any> extends React.Component<
|
|||||||
const isValidContentElem = React.isValidElement(contentElem);
|
const isValidContentElem = React.isValidElement(contentElem);
|
||||||
if (isValidContentElem) {
|
if (isValidContentElem) {
|
||||||
const contentElemProps: React.HTMLProps<HTMLElement> = {
|
const contentElemProps: React.HTMLProps<HTMLElement> = {
|
||||||
className: cssNames("DropFileInput", className, {
|
className: cssNames("DropFileInput", contentElem.props.className, className, {
|
||||||
droppable: this.dropAreaActive,
|
droppable: this.dropAreaActive,
|
||||||
}),
|
}),
|
||||||
onDragEnter,
|
onDragEnter,
|
||||||
|
|||||||
@ -89,8 +89,10 @@
|
|||||||
|
|
||||||
&.theme {
|
&.theme {
|
||||||
&.round-black {
|
&.round-black {
|
||||||
&.invalid label {
|
&.invalid.dirty {
|
||||||
border-color: $colorSoftError !important;
|
label {
|
||||||
|
border-color: $colorSoftError;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
label {
|
label {
|
||||||
|
|||||||
@ -3,13 +3,13 @@ import "./input.scss";
|
|||||||
import React, { DOMAttributes, InputHTMLAttributes, TextareaHTMLAttributes } from "react";
|
import React, { DOMAttributes, InputHTMLAttributes, TextareaHTMLAttributes } from "react";
|
||||||
import { autobind, cssNames, debouncePromise, getRandId } from "../../utils";
|
import { autobind, cssNames, debouncePromise, getRandId } from "../../utils";
|
||||||
import { Icon } from "../icon";
|
import { Icon } from "../icon";
|
||||||
|
import { Tooltip, TooltipProps } from "../tooltip";
|
||||||
import * as Validators from "./input_validators";
|
import * as Validators from "./input_validators";
|
||||||
import { InputValidator } from "./input_validators";
|
import { InputValidator } from "./input_validators";
|
||||||
import isString from "lodash/isString";
|
import isString from "lodash/isString";
|
||||||
import isFunction from "lodash/isFunction";
|
import isFunction from "lodash/isFunction";
|
||||||
import isBoolean from "lodash/isBoolean";
|
import isBoolean from "lodash/isBoolean";
|
||||||
import uniqueId from "lodash/uniqueId";
|
import uniqueId from "lodash/uniqueId";
|
||||||
import { Tooltip } from "../tooltip";
|
|
||||||
|
|
||||||
const { conditionalValidators, ...InputValidators } = Validators;
|
const { conditionalValidators, ...InputValidators } = Validators;
|
||||||
export { InputValidators, InputValidator };
|
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
|
maxRows?: number; // when multiLine={true} define max rows size
|
||||||
dirty?: boolean; // show validation errors even if the field wasn't touched yet
|
dirty?: boolean; // show validation errors even if the field wasn't touched yet
|
||||||
showValidationLine?: boolean; // show animated validation line for async validators
|
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
|
iconLeft?: string | React.ReactNode; // material-icon name in case of string-type
|
||||||
iconRight?: string | React.ReactNode;
|
iconRight?: string | React.ReactNode;
|
||||||
contentRight?: string | React.ReactNode; // Any component of string goes after iconRight
|
contentRight?: string | React.ReactNode; // Any component of string goes after iconRight
|
||||||
@ -63,6 +63,10 @@ export class Input extends React.Component<InputProps, State> {
|
|||||||
errors: [],
|
errors: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
isValid() {
|
||||||
|
return this.state.valid;
|
||||||
|
}
|
||||||
|
|
||||||
setValue(value: string) {
|
setValue(value: string) {
|
||||||
if (value !== this.getValue()) {
|
if (value !== this.getValue()) {
|
||||||
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(this.input.constructor.prototype, "value").set;
|
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(this.input.constructor.prototype, "value").set;
|
||||||
@ -268,7 +272,8 @@ export class Input extends React.Component<InputProps, State> {
|
|||||||
render() {
|
render() {
|
||||||
const {
|
const {
|
||||||
multiLine, showValidationLine, validators, theme, maxRows, children, showErrorsAsTooltip,
|
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
|
...inputProps
|
||||||
} = this.props;
|
} = this.props;
|
||||||
const { focused, dirty, valid, validating, errors } = this.state;
|
const { focused, dirty, valid, validating, errors } = this.state;
|
||||||
@ -294,29 +299,35 @@ export class Input extends React.Component<InputProps, State> {
|
|||||||
ref: this.bindRef,
|
ref: this.bindRef,
|
||||||
spellCheck: "false",
|
spellCheck: "false",
|
||||||
});
|
});
|
||||||
const tooltipId = showErrorsAsTooltip ? getRandId({ prefix: "input_tooltip_id" }) : undefined;
|
|
||||||
const showErrors = errors.length > 0 && !valid && dirty;
|
const showErrors = errors.length > 0 && !valid && dirty;
|
||||||
const errorsInfo = (
|
const errorsInfo = (
|
||||||
<div className="errors box grow">
|
<div className="errors box grow">
|
||||||
{errors.map((error, i) => <p key={i}>{error}</p>)}
|
{errors.map((error, i) => <p key={i}>{error}</p>)}
|
||||||
</div>
|
</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 (
|
return (
|
||||||
<div id={tooltipId} className={className}>
|
<div id={componentId} className={className}>
|
||||||
|
{tooltipError}
|
||||||
<label className="input-area flex gaps align-center" id="">
|
<label className="input-area flex gaps align-center" id="">
|
||||||
{isString(iconLeft) ? <Icon material={iconLeft}/> : iconLeft}
|
{isString(iconLeft) ? <Icon material={iconLeft}/> : iconLeft}
|
||||||
{multiLine ? <textarea {...inputProps as any} /> : <input {...inputProps as any} />}
|
{multiLine ? <textarea {...inputProps as any} /> : <input {...inputProps as any} />}
|
||||||
{isString(iconRight) ? <Icon material={iconRight}/> : iconRight}
|
{isString(iconRight) ? <Icon material={iconRight}/> : iconRight}
|
||||||
{contentRight}
|
{contentRight}
|
||||||
</label>
|
</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">
|
<div className="input-info flex gaps">
|
||||||
{!showErrorsAsTooltip && showErrors && errorsInfo}
|
{!showErrorsAsTooltip && showErrors && errorsInfo}
|
||||||
{this.showMaxLenIndicator && (
|
{this.showMaxLenIndicator && (
|
||||||
|
|||||||
@ -39,13 +39,13 @@ export const isNumber: InputValidator = {
|
|||||||
export const isUrl: InputValidator = {
|
export const isUrl: InputValidator = {
|
||||||
condition: ({ type }) => type === "url",
|
condition: ({ type }) => type === "url",
|
||||||
message: () => _i18n._(t`Wrong url format`),
|
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 = {
|
export const isPath: InputValidator = {
|
||||||
condition: ({ type }) => type === "text",
|
condition: ({ type }) => type === "text",
|
||||||
message: () => _i18n._(t`This field must be a valid path`),
|
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 = {
|
export const minLength: InputValidator = {
|
||||||
|
|||||||
@ -10,13 +10,15 @@ import { Input, InputProps } from "./input";
|
|||||||
|
|
||||||
interface Props extends InputProps {
|
interface Props extends InputProps {
|
||||||
compact?: boolean; // show only search-icon when not focused
|
compact?: boolean; // show only search-icon when not focused
|
||||||
closeIcon?: boolean;
|
bindGlobalFocusHotkey?: boolean;
|
||||||
onClear?: () => void;
|
showClearIcon?: boolean;
|
||||||
|
onClear?(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultProps: Partial<Props> = {
|
const defaultProps: Partial<Props> = {
|
||||||
autoFocus: true,
|
autoFocus: true,
|
||||||
closeIcon: true,
|
bindGlobalFocusHotkey: true,
|
||||||
|
showClearIcon: true,
|
||||||
get placeholder() {
|
get placeholder() {
|
||||||
return _i18n._(t`Search...`);
|
return _i18n._(t`Search...`);
|
||||||
},
|
},
|
||||||
@ -26,27 +28,27 @@ const defaultProps: Partial<Props> = {
|
|||||||
export class SearchInput extends React.Component<Props> {
|
export class SearchInput extends React.Component<Props> {
|
||||||
static defaultProps = defaultProps as object;
|
static defaultProps = defaultProps as object;
|
||||||
|
|
||||||
private input = createRef<Input>();
|
private inputRef = createRef<Input>();
|
||||||
|
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
addEventListener("keydown", this.focus);
|
if (!this.props.bindGlobalFocusHotkey) return;
|
||||||
|
window.addEventListener("keydown", this.onGlobalKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
componentWillUnmount() {
|
componentWillUnmount() {
|
||||||
removeEventListener("keydown", this.focus);
|
window.removeEventListener("keydown", this.onGlobalKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
clear = () => {
|
@autobind()
|
||||||
if (this.props.onClear) {
|
onGlobalKey(evt: KeyboardEvent) {
|
||||||
this.props.onClear();
|
const meta = evt.metaKey || evt.ctrlKey;
|
||||||
|
if (meta && evt.key === "f") {
|
||||||
|
this.inputRef.current.focus();
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
onChange = (val: string, evt: React.ChangeEvent<any>) => {
|
@autobind()
|
||||||
this.props.onChange(val, evt);
|
onKeyDown(evt: React.KeyboardEvent<any>) {
|
||||||
};
|
|
||||||
|
|
||||||
onKeyDown = (evt: React.KeyboardEvent<any>) => {
|
|
||||||
if (this.props.onKeyDown) {
|
if (this.props.onKeyDown) {
|
||||||
this.props.onKeyDown(evt);
|
this.props.onKeyDown(evt);
|
||||||
}
|
}
|
||||||
@ -56,29 +58,31 @@ export class SearchInput extends React.Component<Props> {
|
|||||||
this.clear();
|
this.clear();
|
||||||
evt.stopPropagation();
|
evt.stopPropagation();
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
@autobind()
|
@autobind()
|
||||||
focus(evt: KeyboardEvent) {
|
clear() {
|
||||||
const meta = evt.metaKey || evt.ctrlKey;
|
if (this.props.onClear) {
|
||||||
if (meta && evt.key == "f") {
|
this.props.onClear();
|
||||||
this.input.current.focus();
|
} else {
|
||||||
|
this.inputRef.current.setValue("");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { className, compact, closeIcon, onClear, ...inputProps } = this.props;
|
const { className, compact, onClear, showClearIcon, bindGlobalFocusHotkey, value, ...inputProps } = this.props;
|
||||||
const icon = this.props.value
|
let rightIcon = <Icon small material="search"/>;
|
||||||
? closeIcon ? <Icon small material="close" onClick={this.clear}/> : null
|
if (showClearIcon && value) {
|
||||||
: <Icon small material="search"/>;
|
rightIcon = <Icon small material="close" onClick={this.clear}/>;
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<Input
|
<Input
|
||||||
{...inputProps}
|
{...inputProps}
|
||||||
className={cssNames("SearchInput", className, { compact })}
|
className={cssNames("SearchInput", className, { compact })}
|
||||||
onChange={this.onChange}
|
value={value}
|
||||||
onKeyDown={this.onKeyDown}
|
onKeyDown={this.onKeyDown}
|
||||||
iconRight={icon}
|
iconRight={rightIcon}
|
||||||
ref={this.input}
|
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"
|
"@babel/runtime" "^7.11.2"
|
||||||
"@testing-library/dom" "^7.26.0"
|
"@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@*":
|
"@types/anymatch@*":
|
||||||
version "1.3.1"
|
version "1.3.1"
|
||||||
resolved "https://registry.yarnpkg.com/@types/anymatch/-/anymatch-1.3.1.tgz#336badc1beecb9dacc38bea2cf32adf627a8421a"
|
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"
|
loader-utils "^2.0.0"
|
||||||
schema-utils "^2.6.5"
|
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:
|
file-uri-to-path@1.0.0:
|
||||||
version "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"
|
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:
|
dependencies:
|
||||||
harmony-reflect "^1.4.6"
|
harmony-reflect "^1.4.6"
|
||||||
|
|
||||||
ieee754@^1.1.13, ieee754@^1.1.4:
|
ieee754@^1.1.4:
|
||||||
version "1.1.13"
|
version "1.1.13"
|
||||||
resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.1.13.tgz#ec168558e95aa181fd87d37f55c32bbcb6708b84"
|
resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.1.13.tgz#ec168558e95aa181fd87d37f55c32bbcb6708b84"
|
||||||
integrity sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg==
|
integrity sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg==
|
||||||
@ -11272,11 +11257,6 @@ pbkdf2@^3.0.3:
|
|||||||
safe-buffer "^5.0.1"
|
safe-buffer "^5.0.1"
|
||||||
sha.js "^2.4.8"
|
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:
|
pend@~1.2.0:
|
||||||
version "1.2.0"
|
version "1.2.0"
|
||||||
resolved "https://registry.yarnpkg.com/pend/-/pend-1.2.0.tgz#7a57eb550a6783f9115331fcf4663d5c8e007a50"
|
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"
|
resolved "https://registry.yarnpkg.com/prepend-http/-/prepend-http-2.0.0.tgz#e92434bfa5ea8c19f41cdfd401d741a3c819d897"
|
||||||
integrity sha1-6SQ0v6XqjBn0HN/UAddBo8gZ2Jc=
|
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:
|
pretty-error@^2.1.1:
|
||||||
version "2.1.1"
|
version "2.1.1"
|
||||||
resolved "https://registry.yarnpkg.com/pretty-error/-/pretty-error-2.1.1.tgz#5f4f87c8f91e5ae3f3ba87ab4cf5e03b1a17f1a3"
|
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"
|
isarray "0.0.1"
|
||||||
string_decoder "~0.10.x"
|
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:
|
readdir-scoped-modules@^1.0.0, readdir-scoped-modules@^1.1.0:
|
||||||
version "1.1.0"
|
version "1.1.0"
|
||||||
resolved "https://registry.yarnpkg.com/readdir-scoped-modules/-/readdir-scoped-modules-1.1.0.tgz#8d45407b4f870a0dcaebc0e28670d18e74514309"
|
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:
|
dependencies:
|
||||||
escape-string-regexp "^1.0.2"
|
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:
|
style-loader@^1.2.1:
|
||||||
version "1.2.1"
|
version "1.2.1"
|
||||||
resolved "https://registry.yarnpkg.com/style-loader/-/style-loader-1.2.1.tgz#c5cbbfbf1170d076cfdd86e0109c5bba114baa1a"
|
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"
|
resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.0.tgz#7e1be3470f1e77948bc43d94a3c8f4d7752ba553"
|
||||||
integrity sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw==
|
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:
|
touch@^3.1.0:
|
||||||
version "3.1.0"
|
version "3.1.0"
|
||||||
resolved "https://registry.yarnpkg.com/touch/-/touch-3.1.0.tgz#fe365f5f75ec9ed4e56825e0bb76d24ab74af83b"
|
resolved "https://registry.yarnpkg.com/touch/-/touch-3.1.0.tgz#fe365f5f75ec9ed4e56825e0bb76d24ab74af83b"
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user