mirror of
https://github.com/lensapp/lens.git
synced 2025-05-20 05:10:56 +00:00
Refactor views management
Signed-off-by: Lauri Nevala <lauri.nevala@gmail.com>
This commit is contained in:
parent
7d3e87685b
commit
73cb86583a
2
.gitignore
vendored
2
.gitignore
vendored
@ -5,7 +5,7 @@ node_modules/
|
|||||||
yarn-error.log
|
yarn-error.log
|
||||||
coverage/
|
coverage/
|
||||||
tmp/
|
tmp/
|
||||||
static/build/client/
|
static/build/**
|
||||||
binaries/client/
|
binaries/client/
|
||||||
binaries/server/
|
binaries/server/
|
||||||
locales/**/**.js
|
locales/**/**.js
|
||||||
|
|||||||
@ -3,13 +3,13 @@ import { Application } from "spectron";
|
|||||||
let appPath = ""
|
let appPath = ""
|
||||||
switch(process.platform) {
|
switch(process.platform) {
|
||||||
case "win32":
|
case "win32":
|
||||||
appPath = "./dist/win-unpacked/LensDev.exe"
|
appPath = "./dist/win-unpacked/Lens.exe"
|
||||||
break
|
break
|
||||||
case "linux":
|
case "linux":
|
||||||
appPath = "./dist/linux-unpacked/kontena-lens"
|
appPath = "./dist/linux-unpacked/kontena-lens"
|
||||||
break
|
break
|
||||||
case "darwin":
|
case "darwin":
|
||||||
appPath = "./dist/mac/LensDev.app/Contents/MacOS/LensDev"
|
appPath = "./dist/mac/Lens.app/Contents/MacOS/Lens"
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -20,6 +20,10 @@ export function setup(): Application {
|
|||||||
path: appPath,
|
path: appPath,
|
||||||
startTimeout: 30000,
|
startTimeout: 30000,
|
||||||
waitTimeout: 30000,
|
waitTimeout: 30000,
|
||||||
|
chromeDriverArgs: ['remote-debugging-port=9222'],
|
||||||
|
env: {
|
||||||
|
CICD: "true"
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
import { Application } from "spectron"
|
import { Application } from "spectron"
|
||||||
import * as util from "../helpers/utils"
|
import * as util from "../helpers/utils"
|
||||||
import { spawnSync } from "child_process"
|
import { spawnSync } from "child_process"
|
||||||
import { stat } from "fs"
|
|
||||||
|
|
||||||
jest.setTimeout(20000)
|
jest.setTimeout(20000)
|
||||||
|
|
||||||
@ -11,19 +10,21 @@ describe("app start", () => {
|
|||||||
let app: Application
|
let app: Application
|
||||||
const clickWhatsNew = async (app: Application) => {
|
const clickWhatsNew = async (app: Application) => {
|
||||||
await app.client.waitUntilTextExists("h1", "What's new")
|
await app.client.waitUntilTextExists("h1", "What's new")
|
||||||
await app.client.click("button.btn-primary")
|
await app.client.click("button.primary")
|
||||||
await app.client.waitUntilTextExists("h1", "Welcome")
|
await app.client.waitUntilTextExists("h1", "Welcome")
|
||||||
}
|
}
|
||||||
|
|
||||||
const addMinikubeCluster = async (app: Application) => {
|
const addMinikubeCluster = async (app: Application) => {
|
||||||
await app.client.click("a#add-cluster")
|
await app.client.click("div.add-cluster")
|
||||||
await app.client.waitUntilTextExists("legend", "Choose config:")
|
await app.client.waitUntilTextExists("p", "Choose config")
|
||||||
await app.client.selectByVisibleText("select#kubecontext-select", "minikube (new)")
|
await app.client.click("div#kubecontext-select")
|
||||||
await app.client.click("button.btn-primary")
|
await app.client.waitUntilTextExists("div", "minikube")
|
||||||
|
await app.client.click("div.minikube")
|
||||||
|
await app.client.click("button.primary")
|
||||||
}
|
}
|
||||||
|
|
||||||
const waitForMinikubeDashboard = async (app: Application) => {
|
const waitForMinikubeDashboard = async (app: Application) => {
|
||||||
await app.client.waitUntilTextExists("pre.auth-output", "Authentication proxy started")
|
await app.client.waitUntilTextExists("pre.kube-auth-out", "Authentication proxy started")
|
||||||
let windowCount = await app.client.getWindowCount()
|
let windowCount = await app.client.getWindowCount()
|
||||||
// wait for webview to appear on window count
|
// wait for webview to appear on window count
|
||||||
while (windowCount == 1) {
|
while (windowCount == 1) {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
39
package.json
39
package.json
@ -3,7 +3,7 @@
|
|||||||
"productName": "Lens",
|
"productName": "Lens",
|
||||||
"description": "Lens - The Kubernetes IDE",
|
"description": "Lens - The Kubernetes IDE",
|
||||||
"version": "3.6.0-dev",
|
"version": "3.6.0-dev",
|
||||||
"main": "out/main.js",
|
"main": "static/build/main.js",
|
||||||
"copyright": "© 2020, Lakend Labs, Inc.",
|
"copyright": "© 2020, Lakend Labs, Inc.",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"author": {
|
"author": {
|
||||||
@ -12,29 +12,28 @@
|
|||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "concurrently -k \"yarn dev-run -C\" \"yarn dev:main\" \"yarn dev:renderer\"",
|
"dev": "concurrently -k \"yarn dev-run -C\" \"yarn dev:main\" \"yarn dev:renderer\"",
|
||||||
"dev-run": "nodemon --watch out/main.* --exec \"electron --inspect .\" $@",
|
"dev-run": "nodemon --watch static/build/main.js --exec \"electron --inspect .\" $@",
|
||||||
"dev:main": "env DEBUG=true yarn compile:main --watch $@",
|
"dev:main": "env DEBUG=true yarn compile:main --watch $@",
|
||||||
"dev:renderer": "env DEBUG=true yarn compile:renderer --watch $@",
|
"dev:renderer": "env DEBUG=true yarn compile:renderer --watch $@",
|
||||||
"compile": "concurrently \"yarn i18n:compile\" \"yarn compile:main -p\" \"yarn compile:renderer -p\"",
|
"compile": "env NODE_ENV=production concurrently yarn:compile:*",
|
||||||
"compile:main": "webpack --progress --config webpack.main.ts",
|
"compile:main": "webpack --config webpack.main.ts",
|
||||||
"compile:renderer": "webpack --progress --config webpack.renderer.ts",
|
"compile:renderer": "webpack --config webpack.renderer.ts",
|
||||||
"compile:dll": "webpack --config webpack.dll.ts",
|
"compile:i18n": "lingui compile",
|
||||||
"build:linux": "yarn compile && electron-builder --linux --dir -c.productName=LensDev",
|
"build:linux": "yarn compile && electron-builder --linux --dir -c.productName=Lens",
|
||||||
"build:mac": "yarn compile && electron-builder --mac --dir -c.productName=LensDev",
|
"build:mac": "yarn compile && electron-builder --mac --dir -c.productName=Lens",
|
||||||
"build:win": "yarn compile && electron-builder --win --dir -c.productName=LensDev",
|
"build:win": "yarn compile && electron-builder --win --dir -c.productName=Lens",
|
||||||
"test": "jest --env=jsdom src $@",
|
"test": "jest --env=jsdom src $@",
|
||||||
"integration": "jest --coverage integration $@",
|
"integration": "jest --coverage integration $@",
|
||||||
"dist": "yarn compile && electron-builder -p onTag",
|
"dist": "yarn compile && electron-builder --publish onTag",
|
||||||
"dist:win": "yarn compile && electron-builder -p onTag --x64 --ia32",
|
"dist:win": "yarn compile && electron-builder --publish onTag --x64 --ia32",
|
||||||
"dist:dir": "yarn dist --dir -c.compression=store -c.mac.identity=null",
|
"dist:dir": "yarn dist --dir -c.compression=store -c.mac.identity=null",
|
||||||
"postinstall": "patch-package",
|
"postinstall": "patch-package",
|
||||||
"i18n:extract": "lingui extract",
|
"i18n:extract": "lingui extract",
|
||||||
"i18n:compile": "lingui compile",
|
|
||||||
"download-bins": "concurrently yarn:download:*",
|
"download-bins": "concurrently yarn:download:*",
|
||||||
"download:kubectl": "yarn run ts-node build/download_kubectl.ts",
|
"download:kubectl": "yarn run ts-node build/download_kubectl.ts",
|
||||||
"download:helm": "yarn run ts-node build/download_helm.ts",
|
"download:helm": "yarn run ts-node build/download_helm.ts",
|
||||||
"lint": "eslint $@ --ext js,ts,tsx --max-warnings=0 src/",
|
"lint": "eslint $@ --ext js,ts,tsx --max-warnings=0 src/",
|
||||||
"rebuild-pty": "electron-rebuild -f -w node-pty"
|
"rebuild-pty": "yarn run electron-rebuild -f -w node-pty"
|
||||||
},
|
},
|
||||||
"config": {
|
"config": {
|
||||||
"bundledKubectlVersion": "1.17.4",
|
"bundledKubectlVersion": "1.17.4",
|
||||||
@ -88,7 +87,7 @@
|
|||||||
{
|
{
|
||||||
"from": "static/",
|
"from": "static/",
|
||||||
"to": "static/",
|
"to": "static/",
|
||||||
"filter": "**/*"
|
"filter": "!**/main.js"
|
||||||
},
|
},
|
||||||
"LICENSE"
|
"LICENSE"
|
||||||
],
|
],
|
||||||
@ -187,11 +186,15 @@
|
|||||||
"mac-ca": "^1.0.4",
|
"mac-ca": "^1.0.4",
|
||||||
"marked": "^1.1.0",
|
"marked": "^1.1.0",
|
||||||
"md5-file": "^5.0.0",
|
"md5-file": "^5.0.0",
|
||||||
|
"mobx": "^5.15.5",
|
||||||
|
"mobx-observable-history": "^1.0.3",
|
||||||
"mock-fs": "^4.12.0",
|
"mock-fs": "^4.12.0",
|
||||||
"node-machine-id": "^1.1.12",
|
"node-machine-id": "^1.1.12",
|
||||||
"node-pty": "^0.9.0",
|
"node-pty": "^0.9.0",
|
||||||
"openid-client": "^3.15.2",
|
"openid-client": "^3.15.2",
|
||||||
|
"path-to-regexp": "^6.1.0",
|
||||||
"proper-lockfile": "^4.1.1",
|
"proper-lockfile": "^4.1.1",
|
||||||
|
"react-router": "^5.2.0",
|
||||||
"request": "^2.88.2",
|
"request": "^2.88.2",
|
||||||
"request-promise-native": "^1.0.8",
|
"request-promise-native": "^1.0.8",
|
||||||
"semver": "^7.3.2",
|
"semver": "^7.3.2",
|
||||||
@ -233,7 +236,6 @@
|
|||||||
"@types/md5-file": "^4.0.2",
|
"@types/md5-file": "^4.0.2",
|
||||||
"@types/mini-css-extract-plugin": "^0.9.1",
|
"@types/mini-css-extract-plugin": "^0.9.1",
|
||||||
"@types/react": "^16.9.35",
|
"@types/react": "^16.9.35",
|
||||||
"@types/react-dom": "^16.9.8",
|
|
||||||
"@types/react-router-dom": "^5.1.5",
|
"@types/react-router-dom": "^5.1.5",
|
||||||
"@types/react-select": "^3.0.13",
|
"@types/react-select": "^3.0.13",
|
||||||
"@types/react-window": "^1.8.2",
|
"@types/react-window": "^1.8.2",
|
||||||
@ -265,7 +267,7 @@
|
|||||||
"css-element-queries": "^1.2.3",
|
"css-element-queries": "^1.2.3",
|
||||||
"css-loader": "^3.5.3",
|
"css-loader": "^3.5.3",
|
||||||
"dompurify": "^2.0.11",
|
"dompurify": "^2.0.11",
|
||||||
"electron": "^9.1.0",
|
"electron": "^9.1.2",
|
||||||
"electron-builder": "^22.7.0",
|
"electron-builder": "^22.7.0",
|
||||||
"electron-notarize": "^0.3.0",
|
"electron-notarize": "^0.3.0",
|
||||||
"electron-rebuild": "^1.11.0",
|
"electron-rebuild": "^1.11.0",
|
||||||
@ -281,15 +283,12 @@
|
|||||||
"make-plural": "^6.2.1",
|
"make-plural": "^6.2.1",
|
||||||
"material-design-icons": "^3.0.1",
|
"material-design-icons": "^3.0.1",
|
||||||
"mini-css-extract-plugin": "^0.9.0",
|
"mini-css-extract-plugin": "^0.9.0",
|
||||||
"mobx": "^5.15.4",
|
|
||||||
"mobx-observable-history": "^1.0.3",
|
|
||||||
"mobx-react": "^6.2.2",
|
"mobx-react": "^6.2.2",
|
||||||
"moment": "^2.26.0",
|
"moment": "^2.26.0",
|
||||||
"node-loader": "^0.6.0",
|
"node-loader": "^0.6.0",
|
||||||
"node-sass": "^4.14.1",
|
"node-sass": "^4.14.1",
|
||||||
"nodemon": "^2.0.4",
|
"nodemon": "^2.0.4",
|
||||||
"patch-package": "^6.2.2",
|
"patch-package": "^6.2.2",
|
||||||
"path-to-regexp": "^6.1.0",
|
|
||||||
"postinstall-postinstall": "^2.1.0",
|
"postinstall-postinstall": "^2.1.0",
|
||||||
"raw-loader": "^4.0.1",
|
"raw-loader": "^4.0.1",
|
||||||
"react": "^16.13.1",
|
"react": "^16.13.1",
|
||||||
@ -298,7 +297,7 @@
|
|||||||
"react-select": "^3.1.0",
|
"react-select": "^3.1.0",
|
||||||
"react-window": "^1.8.5",
|
"react-window": "^1.8.5",
|
||||||
"sass-loader": "^8.0.2",
|
"sass-loader": "^8.0.2",
|
||||||
"spectron": "^8.0.0",
|
"spectron": "11.0.0",
|
||||||
"style-loader": "^1.2.1",
|
"style-loader": "^1.2.1",
|
||||||
"terser-webpack-plugin": "^3.0.3",
|
"terser-webpack-plugin": "^3.0.3",
|
||||||
"ts-jest": "^26.1.0",
|
"ts-jest": "^26.1.0",
|
||||||
|
|||||||
@ -135,8 +135,4 @@ export class BaseStore<T = any> extends Singleton {
|
|||||||
recurseEverything: true,
|
recurseEverything: true,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
* [Symbol.iterator]() {
|
|
||||||
yield* Object.entries(this.toJSON());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,16 +3,26 @@ import { ClusterId, clusterStore } from "./cluster-store";
|
|||||||
import { tracker } from "./tracker";
|
import { tracker } from "./tracker";
|
||||||
|
|
||||||
export const clusterIpc = {
|
export const clusterIpc = {
|
||||||
|
init: createIpcChannel({
|
||||||
|
channel: "cluster:init",
|
||||||
|
handle: async (clusterId: ClusterId, frameId: number) => {
|
||||||
|
const cluster = clusterStore.getById(clusterId);
|
||||||
|
if (cluster) {
|
||||||
|
cluster.frameId = frameId; // save cluster's webFrame.routingId to be able to send push-updates
|
||||||
|
return cluster.pushState();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}),
|
||||||
activate: createIpcChannel({
|
activate: createIpcChannel({
|
||||||
channel: "cluster:activate",
|
channel: "cluster:activate",
|
||||||
handle: async (clusterId: ClusterId = clusterStore.activeClusterId) => {
|
handle: (clusterId: ClusterId) => {
|
||||||
return clusterStore.getById(clusterId)?.activate();
|
return clusterStore.getById(clusterId)?.activate();
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|
||||||
disconnect: createIpcChannel({
|
disconnect: createIpcChannel({
|
||||||
channel: "cluster:disconnect",
|
channel: "cluster:disconnect",
|
||||||
handle: (clusterId: ClusterId = clusterStore.activeClusterId) => {
|
handle: (clusterId: ClusterId) => {
|
||||||
tracker.event("cluster", "stop");
|
tracker.event("cluster", "stop");
|
||||||
return clusterStore.getById(clusterId)?.disconnect();
|
return clusterStore.getById(clusterId)?.disconnect();
|
||||||
},
|
},
|
||||||
@ -23,7 +33,6 @@ export const clusterIpc = {
|
|||||||
handle: async (clusterId: ClusterId, feature: string, config?: any) => {
|
handle: async (clusterId: ClusterId, feature: string, config?: any) => {
|
||||||
tracker.event("cluster", "install", feature);
|
tracker.event("cluster", "install", feature);
|
||||||
const cluster = clusterStore.getById(clusterId);
|
const cluster = clusterStore.getById(clusterId);
|
||||||
|
|
||||||
if (cluster) {
|
if (cluster) {
|
||||||
await cluster.installFeature(feature, config)
|
await cluster.installFeature(feature, config)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@ -62,11 +62,10 @@ export class ClusterStore extends BaseStore<ClusterStoreModel> {
|
|||||||
migrations: migrations,
|
migrations: migrations,
|
||||||
});
|
});
|
||||||
if (ipcRenderer) {
|
if (ipcRenderer) {
|
||||||
ipcRenderer.on("cluster:state", (event, clusterState: ClusterState) => {
|
ipcRenderer.on("cluster:state", (event, model: ClusterState) => {
|
||||||
this.applyWithoutSync(() => {
|
this.applyWithoutSync(() => {
|
||||||
logger.debug(`[CLUSTER-STORE]: received state update for cluster=${clusterState.id}`, clusterState);
|
logger.debug(`[CLUSTER-STORE]: received push-state at ${location.host}`, model);
|
||||||
const cluster = this.getById(clusterState.id);
|
this.getById(model.id)?.updateModel(model);
|
||||||
if (cluster) cluster.updateModel(clusterState)
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@ -84,6 +83,14 @@ export class ClusterStore extends BaseStore<ClusterStoreModel> {
|
|||||||
return Array.from(this.clusters.values());
|
return Array.from(this.clusters.values());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isActive(id: ClusterId) {
|
||||||
|
return this.activeClusterId === id;
|
||||||
|
}
|
||||||
|
|
||||||
|
setActive(id: ClusterId) {
|
||||||
|
this.activeClusterId = id;
|
||||||
|
}
|
||||||
|
|
||||||
hasClusters() {
|
hasClusters() {
|
||||||
return this.clusters.size > 0;
|
return this.clusters.size > 0;
|
||||||
}
|
}
|
||||||
@ -156,11 +163,6 @@ export class ClusterStore extends BaseStore<ClusterStoreModel> {
|
|||||||
this.activeClusterId = newClusters.has(activeCluster) ? activeCluster : null;
|
this.activeClusterId = newClusters.has(activeCluster) ? activeCluster : null;
|
||||||
this.clusters.replace(newClusters);
|
this.clusters.replace(newClusters);
|
||||||
this.removedClusters.replace(removedClusters);
|
this.removedClusters.replace(removedClusters);
|
||||||
|
|
||||||
// "auto-select" first cluster if available
|
|
||||||
if (!this.activeClusterId && newClusters.size) {
|
|
||||||
this.activeClusterId = Array.from(newClusters.values())[0].id;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
toJSON(): ClusterStoreModel {
|
toJSON(): ClusterStoreModel {
|
||||||
|
|||||||
333
src/common/cluster-store_test.ts
Normal file
333
src/common/cluster-store_test.ts
Normal file
@ -0,0 +1,333 @@
|
|||||||
|
import fs from "fs";
|
||||||
|
import mockFs from "mock-fs";
|
||||||
|
import yaml from "js-yaml";
|
||||||
|
import { Cluster } from "../main/cluster";
|
||||||
|
import { ClusterStore } from "./cluster-store";
|
||||||
|
import { workspaceStore } from "./workspace-store";
|
||||||
|
import { saveConfigToAppFiles } from "./kube-helpers";
|
||||||
|
|
||||||
|
let clusterStore: ClusterStore;
|
||||||
|
|
||||||
|
describe("empty config", () => {
|
||||||
|
beforeAll(() => {
|
||||||
|
ClusterStore.resetInstance();
|
||||||
|
const mockOpts = {
|
||||||
|
'tmp': {
|
||||||
|
'lens-cluster-store.json': JSON.stringify({})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mockFs(mockOpts);
|
||||||
|
clusterStore = ClusterStore.getInstance<ClusterStore>();
|
||||||
|
return clusterStore.load();
|
||||||
|
})
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
mockFs.restore();
|
||||||
|
})
|
||||||
|
|
||||||
|
it("adds new cluster to store", async () => {
|
||||||
|
const cluster = new Cluster({
|
||||||
|
id: "foo",
|
||||||
|
preferences: {
|
||||||
|
terminalCWD: "/tmp",
|
||||||
|
icon: "data:image/jpeg;base64, iVBORw0KGgoAAAANSUhEUgAAA1wAAAKoCAYAAABjkf5",
|
||||||
|
clusterName: "minikube"
|
||||||
|
},
|
||||||
|
kubeConfigPath: saveConfigToAppFiles("foo", "fancy foo config"),
|
||||||
|
workspace: workspaceStore.currentWorkspaceId
|
||||||
|
});
|
||||||
|
clusterStore.addCluster(cluster);
|
||||||
|
const storedCluster = clusterStore.getById(cluster.id);
|
||||||
|
expect(storedCluster.id).toBe(cluster.id);
|
||||||
|
expect(storedCluster.preferences.terminalCWD).toBe(cluster.preferences.terminalCWD);
|
||||||
|
expect(storedCluster.preferences.icon).toBe(cluster.preferences.icon);
|
||||||
|
})
|
||||||
|
|
||||||
|
it("adds cluster to default workspace", () => {
|
||||||
|
const storedCluster = clusterStore.getById("foo");
|
||||||
|
expect(storedCluster.workspace).toBe("default");
|
||||||
|
})
|
||||||
|
|
||||||
|
it("check if store can contain multiple clusters", () => {
|
||||||
|
const prodCluster = new Cluster({
|
||||||
|
id: "prod",
|
||||||
|
preferences: {
|
||||||
|
clusterName: "prod"
|
||||||
|
},
|
||||||
|
kubeConfigPath: saveConfigToAppFiles("prod", "fancy config"),
|
||||||
|
workspace: "workstation"
|
||||||
|
});
|
||||||
|
const devCluster = new Cluster({
|
||||||
|
id: "dev",
|
||||||
|
preferences: {
|
||||||
|
clusterName: "dev"
|
||||||
|
},
|
||||||
|
kubeConfigPath: saveConfigToAppFiles("dev", "fancy config"),
|
||||||
|
workspace: "workstation"
|
||||||
|
});
|
||||||
|
clusterStore.addCluster(prodCluster);
|
||||||
|
clusterStore.addCluster(devCluster);
|
||||||
|
expect(clusterStore.hasClusters()).toBeTruthy();
|
||||||
|
expect(clusterStore.clusters.size).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gets clusters by workspaces", () => {
|
||||||
|
const wsClusters = clusterStore.getByWorkspaceId("workstation");
|
||||||
|
const defaultClusters = clusterStore.getByWorkspaceId("default");
|
||||||
|
expect(defaultClusters.length).toBe(1);
|
||||||
|
expect(wsClusters.length).toBe(2);
|
||||||
|
expect(wsClusters[0].id).toBe("prod");
|
||||||
|
expect(wsClusters[1].id).toBe("dev");
|
||||||
|
})
|
||||||
|
|
||||||
|
it("checks if last added cluster becomes active", () => {
|
||||||
|
expect(clusterStore.activeCluster.id).toBe("dev");
|
||||||
|
})
|
||||||
|
|
||||||
|
it("sets active cluster", () => {
|
||||||
|
clusterStore.setActive("foo");
|
||||||
|
expect(clusterStore.activeCluster.id).toBe("foo");
|
||||||
|
})
|
||||||
|
|
||||||
|
it("check if cluster's kubeconfig file saved", () => {
|
||||||
|
const file = saveConfigToAppFiles("boo", "kubeconfig");
|
||||||
|
expect(fs.readFileSync(file, "utf8")).toBe("kubeconfig");
|
||||||
|
})
|
||||||
|
|
||||||
|
it("removes cluster from store", async () => {
|
||||||
|
await clusterStore.removeById("foo");
|
||||||
|
expect(clusterStore.getById("foo")).toBeUndefined();
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("config with existing clusters", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
ClusterStore.resetInstance();
|
||||||
|
const mockOpts = {
|
||||||
|
'tmp': {
|
||||||
|
'lens-cluster-store.json': JSON.stringify({
|
||||||
|
__internal__: {
|
||||||
|
migrations: {
|
||||||
|
version: "99.99.99"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
clusters: [
|
||||||
|
{
|
||||||
|
id: 'cluster1',
|
||||||
|
kubeConfig: 'foo',
|
||||||
|
preferences: { terminalCWD: '/foo' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cluster2',
|
||||||
|
kubeConfig: 'foo2',
|
||||||
|
preferences: { terminalCWD: '/foo2' }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mockFs(mockOpts);
|
||||||
|
clusterStore = ClusterStore.getInstance<ClusterStore>();
|
||||||
|
return clusterStore.load();
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
mockFs.restore();
|
||||||
|
})
|
||||||
|
|
||||||
|
it("allows to retrieve a cluster", () => {
|
||||||
|
const storedCluster = clusterStore.getById('cluster1');
|
||||||
|
expect(storedCluster.id).toBe('cluster1');
|
||||||
|
expect(storedCluster.preferences.terminalCWD).toBe('/foo');
|
||||||
|
})
|
||||||
|
|
||||||
|
it("allows to delete a cluster", () => {
|
||||||
|
clusterStore.removeById('cluster2');
|
||||||
|
const storedCluster = clusterStore.getById('cluster1');
|
||||||
|
expect(storedCluster).toBeTruthy();
|
||||||
|
const storedCluster2 = clusterStore.getById('cluster2');
|
||||||
|
expect(storedCluster2).toBeUndefined();
|
||||||
|
})
|
||||||
|
|
||||||
|
it("allows getting all of the clusters", async () => {
|
||||||
|
const storedClusters = clusterStore.clustersList;
|
||||||
|
expect(storedClusters[0].id).toBe('cluster1')
|
||||||
|
expect(storedClusters[0].preferences.terminalCWD).toBe('/foo')
|
||||||
|
expect(storedClusters[1].id).toBe('cluster2')
|
||||||
|
expect(storedClusters[1].preferences.terminalCWD).toBe('/foo2')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("pre 2.0 config with an existing cluster", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
ClusterStore.resetInstance();
|
||||||
|
const mockOpts = {
|
||||||
|
'tmp': {
|
||||||
|
'lens-cluster-store.json': JSON.stringify({
|
||||||
|
__internal__: {
|
||||||
|
migrations: {
|
||||||
|
version: "1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
cluster1: 'kubeconfig content'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
};
|
||||||
|
mockFs(mockOpts);
|
||||||
|
clusterStore = ClusterStore.getInstance<ClusterStore>();
|
||||||
|
return clusterStore.load();
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
mockFs.restore();
|
||||||
|
})
|
||||||
|
|
||||||
|
it("migrates to modern format with kubeconfig in a file", async () => {
|
||||||
|
const config = clusterStore.clustersList[0].kubeConfigPath;
|
||||||
|
expect(fs.readFileSync(config, "utf8")).toBe("kubeconfig content");
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("pre 2.6.0 config with a cluster that has arrays in auth config", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
ClusterStore.resetInstance();
|
||||||
|
const mockOpts = {
|
||||||
|
'tmp': {
|
||||||
|
'lens-cluster-store.json': JSON.stringify({
|
||||||
|
__internal__: {
|
||||||
|
migrations: {
|
||||||
|
version: "2.4.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
cluster1: {
|
||||||
|
kubeConfig: "apiVersion: v1\nclusters:\n- cluster:\n server: https://10.211.55.6:8443\n name: minikube\ncontexts:\n- context:\n cluster: minikube\n user: minikube\n name: minikube\ncurrent-context: minikube\nkind: Config\npreferences: {}\nusers:\n- name: minikube\n user:\n client-certificate: /Users/kimmo/.minikube/client.crt\n client-key: /Users/kimmo/.minikube/client.key\n auth-provider:\n config:\n access-token:\n - should be string\n expiry:\n - should be string\n"
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mockFs(mockOpts);
|
||||||
|
clusterStore = ClusterStore.getInstance<ClusterStore>();
|
||||||
|
return clusterStore.load();
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
mockFs.restore();
|
||||||
|
})
|
||||||
|
|
||||||
|
it("replaces array format access token and expiry into string", async () => {
|
||||||
|
const file = clusterStore.clustersList[0].kubeConfigPath;
|
||||||
|
const config = fs.readFileSync(file, "utf8");
|
||||||
|
const kc = yaml.safeLoad(config);
|
||||||
|
expect(kc.users[0].user['auth-provider'].config['access-token']).toBe("should be string");
|
||||||
|
expect(kc.users[0].user['auth-provider'].config['expiry']).toBe("should be string");
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("pre 2.6.0 config with a cluster icon", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
ClusterStore.resetInstance();
|
||||||
|
const mockOpts = {
|
||||||
|
'tmp': {
|
||||||
|
'lens-cluster-store.json': JSON.stringify({
|
||||||
|
__internal__: {
|
||||||
|
migrations: {
|
||||||
|
version: "2.4.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
cluster1: {
|
||||||
|
kubeConfig: "foo",
|
||||||
|
icon: "icon path",
|
||||||
|
preferences: {
|
||||||
|
terminalCWD: "/tmp"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mockFs(mockOpts);
|
||||||
|
clusterStore = ClusterStore.getInstance<ClusterStore>();
|
||||||
|
return clusterStore.load();
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
mockFs.restore();
|
||||||
|
})
|
||||||
|
|
||||||
|
it("moves the icon into preferences", async () => {
|
||||||
|
const storedClusterData = clusterStore.clustersList[0];
|
||||||
|
expect(storedClusterData.hasOwnProperty('icon')).toBe(false);
|
||||||
|
expect(storedClusterData.preferences.hasOwnProperty('icon')).toBe(true);
|
||||||
|
expect(storedClusterData.preferences.icon).toBe("icon path");
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("for a pre 2.7.0-beta.0 config without a workspace", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
ClusterStore.resetInstance();
|
||||||
|
const mockOpts = {
|
||||||
|
'tmp': {
|
||||||
|
'lens-cluster-store.json': JSON.stringify({
|
||||||
|
__internal__: {
|
||||||
|
migrations: {
|
||||||
|
version: "2.6.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
cluster1: {
|
||||||
|
kubeConfig: "foo",
|
||||||
|
icon: "icon path",
|
||||||
|
preferences: {
|
||||||
|
terminalCWD: "/tmp"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mockFs(mockOpts);
|
||||||
|
clusterStore = ClusterStore.getInstance<ClusterStore>();
|
||||||
|
return clusterStore.load();
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
mockFs.restore();
|
||||||
|
})
|
||||||
|
|
||||||
|
it("adds cluster to default workspace", async () => {
|
||||||
|
const storedClusterData = clusterStore.clustersList[0];
|
||||||
|
expect(storedClusterData.workspace).toBe('default');
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("pre 3.6.0-beta.1 config with an existing cluster", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
ClusterStore.resetInstance();
|
||||||
|
const mockOpts = {
|
||||||
|
'tmp': {
|
||||||
|
'lens-cluster-store.json': JSON.stringify({
|
||||||
|
__internal__: {
|
||||||
|
migrations: {
|
||||||
|
version: "2.7.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
clusters: [
|
||||||
|
{
|
||||||
|
id: 'cluster1',
|
||||||
|
kubeConfig: 'kubeconfig content'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
};
|
||||||
|
mockFs(mockOpts);
|
||||||
|
clusterStore = ClusterStore.getInstance<ClusterStore>();
|
||||||
|
return clusterStore.load();
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
mockFs.restore();
|
||||||
|
})
|
||||||
|
|
||||||
|
it("migrates to modern format with kubeconfig in a file", async () => {
|
||||||
|
const config = clusterStore.clustersList[0].kubeConfigPath;
|
||||||
|
expect(fs.readFileSync(config, "utf8")).toBe("kubeconfig content");
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -7,23 +7,59 @@ import logger from "../main/logger";
|
|||||||
|
|
||||||
export type IpcChannel = string;
|
export type IpcChannel = string;
|
||||||
|
|
||||||
export interface IpcHandleOpts {
|
export interface IpcChannelOptions {
|
||||||
timeout?: number;
|
channel: IpcChannel; // main <-> renderer communication channel name
|
||||||
|
handle?: (...args: any[]) => Promise<any> | any; // message handler
|
||||||
|
autoBind?: boolean; // auto-bind message handler in main-process, default: true
|
||||||
|
timeout?: number; // timeout for waiting response from the sender
|
||||||
|
once?: boolean; // one-time event
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IpcMessageHandler<T extends any[] = any> {
|
export function createIpcChannel({ autoBind = true, once, timeout = 0, handle, channel }: IpcChannelOptions) {
|
||||||
(...args: T): any;
|
const ipcChannel = {
|
||||||
|
channel: channel,
|
||||||
|
handleInMain: () => {
|
||||||
|
logger.info(`[IPC]: setup channel "${channel}"`);
|
||||||
|
const ipcHandler = once ? ipcMain.handleOnce : ipcMain.handle;
|
||||||
|
ipcHandler(channel, async (event, ...args) => {
|
||||||
|
let timerId: any;
|
||||||
|
try {
|
||||||
|
if (timeout > 0) {
|
||||||
|
timerId = setTimeout(() => {
|
||||||
|
throw new Error(`[IPC]: response timeout in ${timeout}ms`)
|
||||||
|
}, timeout);
|
||||||
|
}
|
||||||
|
return await handle(...args); // todo: maybe exec in separate thread/worker
|
||||||
|
} catch (error) {
|
||||||
|
throw error
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timerId);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
removeHandler() {
|
||||||
|
ipcMain.removeHandler(channel);
|
||||||
|
},
|
||||||
|
invokeFromRenderer: async <T>(...args: any[]): Promise<T> => {
|
||||||
|
return ipcRenderer.invoke(channel, ...args);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if (autoBind && ipcMain) {
|
||||||
|
ipcChannel.handleInMain();
|
||||||
|
}
|
||||||
|
return ipcChannel;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IpcMessageOpts<A extends any[] = any> {
|
export interface IpcBroadcastParams<A extends any[] = any> {
|
||||||
channel: IpcChannel
|
channel: IpcChannel
|
||||||
webContentId?: number; // sends to single webContents view
|
webContentId?: number; // send to single webContents view
|
||||||
|
frameId?: number; // send to inner frame of webContents
|
||||||
filter?: (webContent: WebContents) => boolean
|
filter?: (webContent: WebContents) => boolean
|
||||||
timeout?: number; // todo: add support
|
timeout?: number; // todo: add support
|
||||||
args?: A;
|
args?: A;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function broadcastIpc({ channel, webContentId, filter, args = [] }: IpcMessageOpts) {
|
export function broadcastIpc({ channel, frameId, webContentId, filter, args = [] }: IpcBroadcastParams) {
|
||||||
const singleView = webContentId ? webContents.fromId(webContentId) : null;
|
const singleView = webContentId ? webContents.fromId(webContentId) : null;
|
||||||
let views = singleView ? [singleView] : webContents.getAllWebContents();
|
let views = singleView ? [singleView] : webContents.getAllWebContents();
|
||||||
if (filter) {
|
if (filter) {
|
||||||
@ -31,65 +67,10 @@ export function broadcastIpc({ channel, webContentId, filter, args = [] }: IpcMe
|
|||||||
}
|
}
|
||||||
views.forEach(webContent => {
|
views.forEach(webContent => {
|
||||||
const type = webContent.getType();
|
const type = webContent.getType();
|
||||||
logger.debug(`[IPC]: sending message "${channel}" to ${type}=${webContent.id}`, { args });
|
logger.debug(`[IPC]: broadcasting "${channel}" to ${type}=${webContent.id}`, { args });
|
||||||
webContent.send(channel, ...[args].flat());
|
webContent.send(channel, ...args);
|
||||||
|
if (frameId) {
|
||||||
|
webContent.sendToFrame(frameId, channel, ...args)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// todo: support timeout + merge with sendMessage?
|
|
||||||
export async function invokeIpc<R = any>(channel: IpcChannel, ...args: any[]): Promise<R> {
|
|
||||||
logger.info(`[IPC]: invoke channel "${channel}"`, { args });
|
|
||||||
return ipcRenderer.invoke(channel, ...args);
|
|
||||||
}
|
|
||||||
|
|
||||||
// todo: make isomorphic api
|
|
||||||
export function handleIpc(channel: IpcChannel, handler: IpcMessageHandler, options: IpcHandleOpts = {}) {
|
|
||||||
const { timeout = 0 } = options;
|
|
||||||
logger.info(`[IPC]: setup to handle "${channel}"`);
|
|
||||||
|
|
||||||
ipcMain.handle(channel, async (event, ...args) => {
|
|
||||||
logger.info(`[IPC]: handle "${channel}"`, { args });
|
|
||||||
return new Promise(async (resolve, reject) => {
|
|
||||||
let timerId;
|
|
||||||
if (timeout) {
|
|
||||||
timerId = setTimeout(() => {
|
|
||||||
const timeoutError = new Error("[IPC]: response timeout");
|
|
||||||
reject(timeoutError);
|
|
||||||
}, timeout);
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const result = await handler(...args); // todo: maybe exec in separate thread/worker
|
|
||||||
resolve(result);
|
|
||||||
clearTimeout(timerId);
|
|
||||||
} catch (err) {
|
|
||||||
reject(err);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface IpcPairOptions {
|
|
||||||
channel: IpcChannel
|
|
||||||
handle?: IpcMessageHandler
|
|
||||||
autoBind?: boolean;
|
|
||||||
timeout?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// todo: improve api
|
|
||||||
export function createIpcChannel({ channel, autoBind, ...initOpts }: IpcPairOptions) {
|
|
||||||
const bindHandler = (opts: { handler?: IpcMessageHandler, options?: IpcHandleOpts } = {}) => {
|
|
||||||
const handler = opts.handler || initOpts.handle || Function;
|
|
||||||
const options = opts.options || { timeout: initOpts.timeout };
|
|
||||||
handleIpc(channel, handler, options);
|
|
||||||
};
|
|
||||||
if (autoBind) {
|
|
||||||
bindHandler();
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
channel: channel,
|
|
||||||
handleInMain: bindHandler,
|
|
||||||
invokeFromRenderer(...args: any[]) {
|
|
||||||
return invokeIpc(channel, ...args);
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,11 +1,10 @@
|
|||||||
import { app, remote } from "electron";
|
import { app, remote } from "electron";
|
||||||
import { KubeConfig, V1Node, V1Pod } from "@kubernetes/client-node"
|
import { KubeConfig, V1Node, V1Pod } from "@kubernetes/client-node"
|
||||||
import { ensureDirSync, readFile, writeFileSync } from "fs-extra";
|
import fse, { ensureDirSync, readFile, writeFileSync } from "fs-extra";
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import os from "os"
|
import os from "os"
|
||||||
import yaml from "js-yaml"
|
import yaml from "js-yaml"
|
||||||
import logger from "../main/logger";
|
import logger from "../main/logger";
|
||||||
import fse from "fs-extra"
|
|
||||||
|
|
||||||
function resolveTilde(filePath: string) {
|
function resolveTilde(filePath: string) {
|
||||||
if (filePath[0] === "~" && (filePath[1] === "/" || filePath.length === 1)) {
|
if (filePath[0] === "~" && (filePath[1] === "/" || filePath.length === 1)) {
|
||||||
@ -135,8 +134,6 @@ export function podHasIssues(pod: V1Pod) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Logic adapted from dashboard
|
|
||||||
// see: https://github.com/kontena/kontena-k8s-dashboard/blob/7d8f9cb678cc817a22dd1886c5e79415b212b9bf/client/api/endpoints/nodes.api.ts#L147
|
|
||||||
export function getNodeWarningConditions(node: V1Node) {
|
export function getNodeWarningConditions(node: V1Node) {
|
||||||
return node.status.conditions.filter(c =>
|
return node.status.conditions.filter(c =>
|
||||||
c.status.toLowerCase() === "true" && c.type !== "Ready" && c.type !== "HostUpgrades"
|
c.status.toLowerCase() === "true" && c.type !== "Ready" && c.type !== "HostUpgrades"
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import ua from "universal-analytics"
|
|||||||
import { machineIdSync } from "node-machine-id"
|
import { machineIdSync } from "node-machine-id"
|
||||||
import Singleton from "./utils/singleton";
|
import Singleton from "./utils/singleton";
|
||||||
import { userStore } from "./user-store"
|
import { userStore } from "./user-store"
|
||||||
|
import logger from "../main/logger";
|
||||||
|
|
||||||
export class Tracker extends Singleton {
|
export class Tracker extends Singleton {
|
||||||
static readonly GA_ID = "UA-159377374-1"
|
static readonly GA_ID = "UA-159377374-1"
|
||||||
@ -40,7 +41,7 @@ export class Tracker extends Singleton {
|
|||||||
...otherParams,
|
...otherParams,
|
||||||
}).send()
|
}).send()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`Failed to track "${eventCategory}:${eventAction}"`, err)
|
logger.error(`Failed to track "${eventCategory}:${eventAction}"`, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -71,12 +71,10 @@ export class UserStore extends BaseStore<UserStoreModel> {
|
|||||||
if (kubeConfig) {
|
if (kubeConfig) {
|
||||||
this.newContexts.clear();
|
this.newContexts.clear();
|
||||||
const localContexts = loadConfig(kubeConfig).getContexts();
|
const localContexts = loadConfig(kubeConfig).getContexts();
|
||||||
localContexts.forEach(({ cluster, name }) => {
|
localContexts
|
||||||
if (!cluster) return;
|
.filter(ctx => ctx.cluster)
|
||||||
if (!this.seenContexts.has(name)) {
|
.filter(ctx => !this.seenContexts.has(ctx.name))
|
||||||
this.newContexts.add(name)
|
.forEach(ctx => this.newContexts.add(ctx.name));
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
102
src/common/user-store_test.ts
Normal file
102
src/common/user-store_test.ts
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
import mockFs from "mock-fs"
|
||||||
|
|
||||||
|
jest.mock("electron", () => {
|
||||||
|
return {
|
||||||
|
app: {
|
||||||
|
getVersion: () => '99.99.99',
|
||||||
|
getPath: () => 'tmp',
|
||||||
|
getLocale: () => 'en'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
import { UserStore } from "./user-store"
|
||||||
|
import { SemVer } from "semver"
|
||||||
|
import electron from "electron"
|
||||||
|
|
||||||
|
describe("user store tests", () => {
|
||||||
|
describe("for an empty config", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
UserStore.resetInstance()
|
||||||
|
mockFs({ tmp: { 'config.json': "{}" } })
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
mockFs.restore()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("allows setting and retrieving lastSeenAppVersion", () => {
|
||||||
|
const us = UserStore.getInstance<UserStore>();
|
||||||
|
|
||||||
|
us.lastSeenAppVersion = "1.2.3";
|
||||||
|
expect(us.lastSeenAppVersion).toBe("1.2.3");
|
||||||
|
})
|
||||||
|
|
||||||
|
it("allows adding and listing seen contexts", () => {
|
||||||
|
const us = UserStore.getInstance<UserStore>();
|
||||||
|
|
||||||
|
us.seenContexts.add('foo')
|
||||||
|
expect(us.seenContexts.size).toBe(1)
|
||||||
|
|
||||||
|
us.seenContexts.add('foo')
|
||||||
|
us.seenContexts.add('bar')
|
||||||
|
expect(us.seenContexts.size).toBe(2) // check 'foo' isn't added twice
|
||||||
|
expect(us.seenContexts.has('foo')).toBe(true)
|
||||||
|
expect(us.seenContexts.has('bar')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("allows setting and getting preferences", () => {
|
||||||
|
const us = UserStore.getInstance<UserStore>();
|
||||||
|
|
||||||
|
us.preferences.httpsProxy = 'abcd://defg';
|
||||||
|
|
||||||
|
expect(us.preferences.httpsProxy).toBe('abcd://defg')
|
||||||
|
expect(us.preferences.colorTheme).toBe(UserStore.defaultTheme)
|
||||||
|
|
||||||
|
us.preferences.colorTheme = "light";
|
||||||
|
expect(us.preferences.colorTheme).toBe('light')
|
||||||
|
})
|
||||||
|
|
||||||
|
it("correctly resets theme to default value", () => {
|
||||||
|
const us = UserStore.getInstance<UserStore>();
|
||||||
|
|
||||||
|
us.preferences.colorTheme = "some other theme";
|
||||||
|
us.resetTheme();
|
||||||
|
expect(us.preferences.colorTheme).toBe(UserStore.defaultTheme);
|
||||||
|
})
|
||||||
|
|
||||||
|
it("correctly calculates if the last seen version is an old release", () => {
|
||||||
|
const us = UserStore.getInstance<UserStore>();
|
||||||
|
|
||||||
|
expect(us.isNewVersion).toBe(true);
|
||||||
|
|
||||||
|
us.lastSeenAppVersion = (new SemVer(electron.app.getVersion())).inc("major").format();
|
||||||
|
expect(us.isNewVersion).toBe(false);
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("migrations", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
UserStore.resetInstance()
|
||||||
|
mockFs({
|
||||||
|
'tmp': {
|
||||||
|
'config.json': JSON.stringify({
|
||||||
|
user: { username: 'foobar' },
|
||||||
|
preferences: { colorTheme: 'light' },
|
||||||
|
lastSeenAppVersion: '1.2.3'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
mockFs.restore()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("sets last seen app version to 0.0.0", () => {
|
||||||
|
const us = UserStore.getInstance<UserStore>();
|
||||||
|
|
||||||
|
expect(us.lastSeenAppVersion).toBe('0.0.0')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
12
src/common/utils/defineGlobal.ts
Executable file
12
src/common/utils/defineGlobal.ts
Executable file
@ -0,0 +1,12 @@
|
|||||||
|
// Setup variable in global scope (top-level object)
|
||||||
|
// Global type definition must be added separately to `mocks.d.ts` in form:
|
||||||
|
// declare const __globalName: any;
|
||||||
|
|
||||||
|
export function defineGlobal(propName: string, descriptor: PropertyDescriptor) {
|
||||||
|
const scope = typeof global !== "undefined" ? global : window;
|
||||||
|
if (scope.hasOwnProperty(propName)) {
|
||||||
|
console.info(`Global variable "${propName}" already exists. Skipping.`)
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Object.defineProperty(scope, propName, descriptor);
|
||||||
|
}
|
||||||
@ -1,6 +1,7 @@
|
|||||||
// App's common configuration for any process (main, renderer, build pipeline, etc.)
|
// App's common configuration for any process (main, renderer, build pipeline, etc.)
|
||||||
import packageInfo from "../../package.json"
|
|
||||||
import path from "path";
|
import path from "path";
|
||||||
|
import packageInfo from "../../package.json"
|
||||||
|
import { defineGlobal } from "./utils/defineGlobal";
|
||||||
|
|
||||||
export const isMac = process.platform === "darwin"
|
export const isMac = process.platform === "darwin"
|
||||||
export const isWindows = process.platform === "win32"
|
export const isWindows = process.platform === "win32"
|
||||||
@ -10,16 +11,26 @@ export const isDevelopment = isDebugging || !isProduction;
|
|||||||
export const isTestEnv = !!process.env.JEST_WORKER_ID;
|
export const isTestEnv = !!process.env.JEST_WORKER_ID;
|
||||||
|
|
||||||
export const appName = `${packageInfo.productName}${isDevelopment ? "Dev" : ""}`
|
export const appName = `${packageInfo.productName}${isDevelopment ? "Dev" : ""}`
|
||||||
|
export const publicPath = "/build/"
|
||||||
|
|
||||||
// System paths
|
// Webpack build paths
|
||||||
export const contextDir = process.cwd();
|
export const contextDir = process.cwd();
|
||||||
export const staticDir = path.join(contextDir, "static");
|
export const buildDir = path.join(contextDir, "static", publicPath);
|
||||||
export const outDir = path.join(contextDir, "out");
|
|
||||||
export const mainDir = path.join(contextDir, "src/main");
|
export const mainDir = path.join(contextDir, "src/main");
|
||||||
export const rendererDir = path.join(contextDir, "src/renderer");
|
export const rendererDir = path.join(contextDir, "src/renderer");
|
||||||
export const htmlTemplate = path.resolve(rendererDir, "template.html");
|
export const htmlTemplate = path.resolve(rendererDir, "template.html");
|
||||||
export const sassCommonVars = path.resolve(rendererDir, "components/vars.scss");
|
export const sassCommonVars = path.resolve(rendererDir, "components/vars.scss");
|
||||||
|
|
||||||
|
// Special runtime paths
|
||||||
|
defineGlobal("__static", {
|
||||||
|
get() {
|
||||||
|
if (isDevelopment) {
|
||||||
|
return path.resolve(contextDir, "static");
|
||||||
|
}
|
||||||
|
return path.resolve(process.resourcesPath, "static")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// Apis
|
// Apis
|
||||||
export const apiPrefix = "/api" // local router apis
|
export const apiPrefix = "/api" // local router apis
|
||||||
export const apiKubePrefix = "/api-kube" // k8s cluster apis
|
export const apiKubePrefix = "/api-kube" // k8s cluster apis
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { action, computed, observable, reaction, toJS } from "mobx";
|
import { action, computed, observable, toJS } from "mobx";
|
||||||
import { BaseStore } from "./base-store";
|
import { BaseStore } from "./base-store";
|
||||||
import { clusterStore } from "./cluster-store"
|
import { clusterStore } from "./cluster-store"
|
||||||
|
|
||||||
@ -22,15 +22,6 @@ export class WorkspaceStore extends BaseStore<WorkspaceStoreModel> {
|
|||||||
super({
|
super({
|
||||||
configName: "lens-workspace-store",
|
configName: "lens-workspace-store",
|
||||||
});
|
});
|
||||||
|
|
||||||
// switch to first available cluster in current workspace
|
|
||||||
reaction(() => this.currentWorkspaceId, workspaceId => {
|
|
||||||
const clusters = clusterStore.getByWorkspaceId(workspaceId);
|
|
||||||
const activeClusterInWorkspace = clusters.some(cluster => cluster.id === clusterStore.activeClusterId);
|
|
||||||
if (!activeClusterInWorkspace) {
|
|
||||||
clusterStore.activeClusterId = clusters.length ? clusters[0].id : null;
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@observable currentWorkspaceId = WorkspaceStore.defaultId;
|
@observable currentWorkspaceId = WorkspaceStore.defaultId;
|
||||||
@ -60,6 +51,10 @@ export class WorkspaceStore extends BaseStore<WorkspaceStoreModel> {
|
|||||||
|
|
||||||
@action
|
@action
|
||||||
setActive(id = WorkspaceStore.defaultId) {
|
setActive(id = WorkspaceStore.defaultId) {
|
||||||
|
if (!this.getById(id)) {
|
||||||
|
throw new Error(`workspace ${id} doesn't exist`);
|
||||||
|
}
|
||||||
|
|
||||||
this.currentWorkspaceId = id;
|
this.currentWorkspaceId = id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
128
src/common/workspace-store_test.ts
Normal file
128
src/common/workspace-store_test.ts
Normal file
@ -0,0 +1,128 @@
|
|||||||
|
import mockFs from "mock-fs"
|
||||||
|
|
||||||
|
jest.mock("electron", () => {
|
||||||
|
return {
|
||||||
|
app: {
|
||||||
|
getVersion: () => '99.99.99',
|
||||||
|
getPath: () => 'tmp',
|
||||||
|
getLocale: () => 'en'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
import { WorkspaceStore } from "./workspace-store"
|
||||||
|
|
||||||
|
describe("workspace store tests", () => {
|
||||||
|
describe("for an empty config", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
WorkspaceStore.resetInstance()
|
||||||
|
mockFs({ tmp: { 'lens-workspace-store.json': "{}" } })
|
||||||
|
|
||||||
|
await WorkspaceStore.getInstance<WorkspaceStore>().load();
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
mockFs.restore()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("default workspace should always exist", () => {
|
||||||
|
const ws = WorkspaceStore.getInstance<WorkspaceStore>();
|
||||||
|
|
||||||
|
expect(ws.workspaces.size).toBe(1);
|
||||||
|
expect(ws.getById(WorkspaceStore.defaultId)).not.toBe(null);
|
||||||
|
})
|
||||||
|
|
||||||
|
it("cannot remove the default workspace", () => {
|
||||||
|
const ws = WorkspaceStore.getInstance<WorkspaceStore>();
|
||||||
|
|
||||||
|
expect(() => ws.removeWorkspace(WorkspaceStore.defaultId)).toThrowError("Cannot remove");
|
||||||
|
})
|
||||||
|
|
||||||
|
it("can update default workspace name", () => {
|
||||||
|
const ws = WorkspaceStore.getInstance<WorkspaceStore>();
|
||||||
|
|
||||||
|
ws.saveWorkspace({
|
||||||
|
id: WorkspaceStore.defaultId,
|
||||||
|
name: "foobar",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(ws.currentWorkspace.name).toBe("foobar");
|
||||||
|
})
|
||||||
|
|
||||||
|
it("can add workspaces", () => {
|
||||||
|
const ws = WorkspaceStore.getInstance<WorkspaceStore>();
|
||||||
|
|
||||||
|
ws.saveWorkspace({
|
||||||
|
id: "123",
|
||||||
|
name: "foobar",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(ws.getById("123").name).toBe("foobar");
|
||||||
|
})
|
||||||
|
|
||||||
|
it("cannot set a non-existent workspace to be active", () => {
|
||||||
|
const ws = WorkspaceStore.getInstance<WorkspaceStore>();
|
||||||
|
|
||||||
|
expect(() => ws.setActive("abc")).toThrow("doesn't exist");
|
||||||
|
})
|
||||||
|
|
||||||
|
it("can set a existent workspace to be active", () => {
|
||||||
|
const ws = WorkspaceStore.getInstance<WorkspaceStore>();
|
||||||
|
|
||||||
|
ws.saveWorkspace({
|
||||||
|
id: "abc",
|
||||||
|
name: "foobar",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(() => ws.setActive("abc")).not.toThrowError();
|
||||||
|
})
|
||||||
|
|
||||||
|
it("can remove a workspace", () => {
|
||||||
|
const ws = WorkspaceStore.getInstance<WorkspaceStore>();
|
||||||
|
|
||||||
|
ws.saveWorkspace({
|
||||||
|
id: "123",
|
||||||
|
name: "foobar",
|
||||||
|
});
|
||||||
|
ws.saveWorkspace({
|
||||||
|
id: "1234",
|
||||||
|
name: "foobar 1",
|
||||||
|
});
|
||||||
|
ws.removeWorkspace("123");
|
||||||
|
|
||||||
|
expect(ws.workspaces.size).toBe(2);
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("for a non-empty config", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
WorkspaceStore.resetInstance()
|
||||||
|
mockFs({
|
||||||
|
tmp: {
|
||||||
|
'lens-workspace-store.json': JSON.stringify({
|
||||||
|
currentWorkspace: "abc",
|
||||||
|
workspaces: [{
|
||||||
|
id: "abc",
|
||||||
|
name: "test"
|
||||||
|
}, {
|
||||||
|
id: "default",
|
||||||
|
name: "default"
|
||||||
|
}]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
await WorkspaceStore.getInstance<WorkspaceStore>().load();
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
mockFs.restore()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("doesn't revert to default workspace", async () => {
|
||||||
|
const ws = WorkspaceStore.getInstance<WorkspaceStore>();
|
||||||
|
|
||||||
|
expect(ws.currentWorkspaceId).toBe("abc");
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -1,9 +1,10 @@
|
|||||||
|
import "../common/cluster-ipc";
|
||||||
import type http from "http"
|
import type http from "http"
|
||||||
import { autorun } from "mobx";
|
import { autorun } from "mobx";
|
||||||
import { ClusterId, clusterStore } from "../common/cluster-store"
|
import { ClusterId, clusterStore } from "../common/cluster-store"
|
||||||
import { Cluster } from "./cluster"
|
import { Cluster } from "./cluster"
|
||||||
import { clusterIpc } from "../common/cluster-ipc";
|
|
||||||
import logger from "./logger";
|
import logger from "./logger";
|
||||||
|
import { apiKubePrefix } from "../common/vars";
|
||||||
|
|
||||||
export class ClusterManager {
|
export class ClusterManager {
|
||||||
constructor(public readonly port: number) {
|
constructor(public readonly port: number) {
|
||||||
@ -29,13 +30,6 @@ export class ClusterManager {
|
|||||||
}, {
|
}, {
|
||||||
delay: 250
|
delay: 250
|
||||||
});
|
});
|
||||||
|
|
||||||
// listen for ipc-events that must/can be handled *only* in main-process (nodeIntegration=true)
|
|
||||||
clusterIpc.activate.handleInMain();
|
|
||||||
clusterIpc.disconnect.handleInMain();
|
|
||||||
clusterIpc.installFeature.handleInMain();
|
|
||||||
clusterIpc.uninstallFeature.handleInMain();
|
|
||||||
clusterIpc.upgradeFeature.handleInMain();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
stop() {
|
stop() {
|
||||||
@ -49,8 +43,23 @@ export class ClusterManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getClusterForRequest(req: http.IncomingMessage): Cluster {
|
getClusterForRequest(req: http.IncomingMessage): Cluster {
|
||||||
logger.info(`getClusterForRequest(): ${req.headers.host}${req.url}`)
|
let cluster: Cluster = null
|
||||||
const clusterId = req.headers.host.split(".")[0]
|
|
||||||
return this.getCluster(clusterId)
|
// lens-server is connecting to 127.0.0.1:<port>/<uid>
|
||||||
|
if (req.headers.host.startsWith("127.0.0.1")) {
|
||||||
|
const clusterId = req.url.split("/")[1]
|
||||||
|
if (clusterId) {
|
||||||
|
cluster = this.getCluster(clusterId)
|
||||||
|
if (cluster) {
|
||||||
|
// we need to swap path prefix so that request is proxied to kube api
|
||||||
|
req.url = req.url.replace(`/${clusterId}`, apiKubePrefix)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const id = req.headers.host.split(".")[0]
|
||||||
|
cluster = this.getCluster(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
return cluster;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -40,6 +40,7 @@ export interface ClusterState extends ClusterModel {
|
|||||||
|
|
||||||
export class Cluster implements ClusterModel {
|
export class Cluster implements ClusterModel {
|
||||||
public id: ClusterId;
|
public id: ClusterId;
|
||||||
|
public frameId: number;
|
||||||
public kubeCtl: Kubectl
|
public kubeCtl: Kubectl
|
||||||
public contextHandler: ContextHandler;
|
public contextHandler: ContextHandler;
|
||||||
protected kubeconfigManager: KubeconfigManager;
|
protected kubeconfigManager: KubeconfigManager;
|
||||||
@ -67,9 +68,8 @@ export class Cluster implements ClusterModel {
|
|||||||
@observable allowedNamespaces: string[] = [];
|
@observable allowedNamespaces: string[] = [];
|
||||||
@observable allowedResources: string[] = [];
|
@observable allowedResources: string[] = [];
|
||||||
|
|
||||||
@computed get host() {
|
@computed get available() {
|
||||||
const proxyHost = new URL(this.kubeProxyUrl).host;
|
return this.accessible && !this.disconnected;
|
||||||
return `${this.id}.${proxyHost}`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor(model: ClusterModel) {
|
constructor(model: ClusterModel) {
|
||||||
@ -79,7 +79,7 @@ export class Cluster implements ClusterModel {
|
|||||||
@action
|
@action
|
||||||
updateModel(model: ClusterModel) {
|
updateModel(model: ClusterModel) {
|
||||||
Object.assign(this, model);
|
Object.assign(this, model);
|
||||||
this.apiUrl = this.getKubeconfig().getCurrentCluster().server;
|
this.apiUrl = this.getKubeconfig().getCurrentCluster()?.server;
|
||||||
this.contextName = this.contextName || this.preferences.clusterName;
|
this.contextName = this.contextName || this.preferences.clusterName;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -222,8 +222,9 @@ export class Cluster implements ClusterModel {
|
|||||||
return request(apiUrl, {
|
return request(apiUrl, {
|
||||||
json: true,
|
json: true,
|
||||||
timeout: 5000,
|
timeout: 5000,
|
||||||
|
...options,
|
||||||
headers: {
|
headers: {
|
||||||
Host: this.host, // provide cluster-id for ClusterManager.getClusterForRequest()
|
Host: `${this.id}.${new URL(this.kubeProxyUrl).host}`, // required in ClusterManager.getClusterForRequest()
|
||||||
...(options.headers || {}),
|
...(options.headers || {}),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@ -233,6 +234,7 @@ export class Cluster implements ClusterModel {
|
|||||||
const prometheusPrefix = this.preferences.prometheus?.prefix || "";
|
const prometheusPrefix = this.preferences.prometheus?.prefix || "";
|
||||||
const metricsPath = `/api/v1/namespaces/${prometheusPath}/proxy${prometheusPrefix}/api/v1/query_range`;
|
const metricsPath = `/api/v1/namespaces/${prometheusPath}/proxy${prometheusPrefix}/api/v1/query_range`;
|
||||||
return this.k8sRequest(metricsPath, {
|
return this.k8sRequest(metricsPath, {
|
||||||
|
timeout: 0,
|
||||||
resolveWithFullResponse: false,
|
resolveWithFullResponse: false,
|
||||||
json: true,
|
json: true,
|
||||||
qs: queryParams,
|
qs: queryParams,
|
||||||
@ -388,8 +390,8 @@ export class Cluster implements ClusterModel {
|
|||||||
pushState = (state = this.getState()): ClusterState => {
|
pushState = (state = this.getState()): ClusterState => {
|
||||||
logger.debug(`[CLUSTER]: push-state`, state);
|
logger.debug(`[CLUSTER]: push-state`, state);
|
||||||
broadcastIpc({
|
broadcastIpc({
|
||||||
// webContentId: viewId, // todo: send to cluster-view only
|
|
||||||
channel: "cluster:state",
|
channel: "cluster:state",
|
||||||
|
frameId: this.frameId,
|
||||||
args: [state],
|
args: [state],
|
||||||
});
|
});
|
||||||
return state;
|
return state;
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
import "../common/system-ca"
|
import "../common/system-ca"
|
||||||
import "../common/prometheus-providers"
|
import "../common/prometheus-providers"
|
||||||
import { app, dialog } from "electron"
|
import { app, dialog } from "electron"
|
||||||
import { appName, staticDir } from "../common/vars";
|
import { appName } from "../common/vars";
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { LensProxy } from "./lens-proxy"
|
import { LensProxy } from "./lens-proxy"
|
||||||
import { WindowManager } from "./window-manager";
|
import { WindowManager } from "./window-manager";
|
||||||
@ -19,6 +19,12 @@ import { workspaceStore } from "../common/workspace-store";
|
|||||||
import { tracker } from "../common/tracker";
|
import { tracker } from "../common/tracker";
|
||||||
import logger from "./logger"
|
import logger from "./logger"
|
||||||
|
|
||||||
|
const workingDir = path.join(app.getPath("appData"), appName);
|
||||||
|
app.setName(appName);
|
||||||
|
if(!process.env.CICD) {
|
||||||
|
app.setPath("userData", workingDir);
|
||||||
|
}
|
||||||
|
|
||||||
let windowManager: WindowManager;
|
let windowManager: WindowManager;
|
||||||
let clusterManager: ClusterManager;
|
let clusterManager: ClusterManager;
|
||||||
let proxyServer: LensProxy;
|
let proxyServer: LensProxy;
|
||||||
@ -30,17 +36,13 @@ if (app.commandLine.getSwitchValue("proxy-server") !== "") {
|
|||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
await shellSync();
|
await shellSync();
|
||||||
|
|
||||||
const workingDir = path.join(app.getPath("appData"), appName);
|
|
||||||
app.setName(appName);
|
|
||||||
app.setPath("userData", workingDir);
|
|
||||||
logger.info(`🚀 Starting Lens from "${workingDir}"`)
|
logger.info(`🚀 Starting Lens from "${workingDir}"`)
|
||||||
|
|
||||||
tracker.event("app", "start");
|
tracker.event("app", "start");
|
||||||
const updater = new AppUpdater()
|
const updater = new AppUpdater()
|
||||||
updater.start();
|
updater.start();
|
||||||
|
|
||||||
registerFileProtocol("static", staticDir);
|
registerFileProtocol("static", __static);
|
||||||
|
|
||||||
// find free port
|
// find free port
|
||||||
let proxyPort: number
|
let proxyPort: number
|
||||||
|
|||||||
168
src/main/menu.ts
168
src/main/menu.ts
@ -1,8 +1,7 @@
|
|||||||
import type { WindowManager } from "./window-manager";
|
|
||||||
import { app, BrowserWindow, dialog, Menu, MenuItem, MenuItemConstructorOptions, shell, webContents } from "electron"
|
import { app, BrowserWindow, dialog, Menu, MenuItem, MenuItemConstructorOptions, shell, webContents } from "electron"
|
||||||
import { autorun } from "mobx";
|
import { autorun } from "mobx";
|
||||||
|
import { WindowManager } from "./window-manager";
|
||||||
import { appName, isMac, issuesTrackerUrl, isWindows, slackUrl } from "../common/vars";
|
import { appName, isMac, issuesTrackerUrl, isWindows, slackUrl } from "../common/vars";
|
||||||
import { clusterStore } from "../common/cluster-store";
|
|
||||||
import { addClusterURL } from "../renderer/components/+add-cluster/add-cluster.route";
|
import { addClusterURL } from "../renderer/components/+add-cluster/add-cluster.route";
|
||||||
import { preferencesURL } from "../renderer/components/+preferences/preferences.route";
|
import { preferencesURL } from "../renderer/components/+preferences/preferences.route";
|
||||||
import { whatsNewURL } from "../renderer/components/+whats-new/whats-new.route";
|
import { whatsNewURL } from "../renderer/components/+whats-new/whats-new.route";
|
||||||
@ -10,53 +9,119 @@ import { clusterSettingsURL } from "../renderer/components/+cluster-settings/clu
|
|||||||
import logger from "./logger";
|
import logger from "./logger";
|
||||||
|
|
||||||
export function initMenu(windowManager: WindowManager) {
|
export function initMenu(windowManager: WindowManager) {
|
||||||
autorun(() => {
|
autorun(() => buildMenu(windowManager), {
|
||||||
logger.debug(`[MENU]: building menu, cluster=${clusterStore.activeClusterId}`);
|
delay: 100
|
||||||
buildMenu(windowManager);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildMenu(windowManager: WindowManager) {
|
export function buildMenu(windowManager: WindowManager) {
|
||||||
function macOnly(menuItems: MenuItemConstructorOptions[]): MenuItemConstructorOptions[] {
|
function ignoreOnMac(menuItems: MenuItemConstructorOptions[]) {
|
||||||
if (!isMac) return [];
|
if (isMac) return [];
|
||||||
return menuItems;
|
return menuItems;
|
||||||
}
|
}
|
||||||
|
|
||||||
const fileMenu: MenuItemConstructorOptions = {
|
function activeClusterOnly(menuItems: MenuItemConstructorOptions[]) {
|
||||||
label: isMac ? app.getName() : "File",
|
if (!windowManager.activeClusterId) {
|
||||||
|
menuItems.forEach(item => {
|
||||||
|
item.enabled = false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return menuItems;
|
||||||
|
}
|
||||||
|
|
||||||
|
function navigate(url: string) {
|
||||||
|
logger.info(`[MENU]: navigating to ${url}`);
|
||||||
|
windowManager.navigate({
|
||||||
|
channel: "menu:navigate",
|
||||||
|
url: url,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function showAbout(browserWindow: BrowserWindow) {
|
||||||
|
const appInfo = [
|
||||||
|
`${appName}: ${app.getVersion()}`,
|
||||||
|
`Electron: ${process.versions.electron}`,
|
||||||
|
`Chrome: ${process.versions.chrome}`,
|
||||||
|
`Copyright 2020 Lakend Labs, Inc.`,
|
||||||
|
]
|
||||||
|
dialog.showMessageBoxSync(browserWindow, {
|
||||||
|
title: `${isWindows ? " ".repeat(2) : ""}${appName}`,
|
||||||
|
type: "info",
|
||||||
|
buttons: ["Close"],
|
||||||
|
message: `Lens`,
|
||||||
|
detail: appInfo.join("\r\n")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const mt: MenuItemConstructorOptions[] = [];
|
||||||
|
|
||||||
|
const macAppMenu: MenuItemConstructorOptions = {
|
||||||
|
label: app.getName(),
|
||||||
submenu: [
|
submenu: [
|
||||||
{
|
{
|
||||||
label: 'Add Cluster',
|
label: "About Lens",
|
||||||
click() {
|
click(menuItem: MenuItem, browserWindow: BrowserWindow) {
|
||||||
windowManager.navigateMain(addClusterURL())
|
showAbout(browserWindow)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
...(clusterStore.activeCluster ? [{
|
|
||||||
label: 'Cluster Settings',
|
|
||||||
click() {
|
|
||||||
windowManager.navigateMain(clusterSettingsURL())
|
|
||||||
}
|
|
||||||
}] : []),
|
|
||||||
{ type: 'separator' },
|
{ type: 'separator' },
|
||||||
{
|
{
|
||||||
label: 'Preferences',
|
label: 'Preferences',
|
||||||
click() {
|
click() {
|
||||||
windowManager.navigateMain(preferencesURL())
|
navigate(preferencesURL())
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
...macOnly([
|
{ type: 'separator' },
|
||||||
{ type: 'separator' },
|
{ role: 'services' },
|
||||||
{ role: 'services' },
|
{ type: 'separator' },
|
||||||
{ type: 'separator' },
|
{ role: 'hide' },
|
||||||
{ role: 'hide' },
|
{ role: 'hideOthers' },
|
||||||
{ role: 'hideOthers' },
|
{ role: 'unhide' },
|
||||||
{ role: 'unhide' },
|
|
||||||
]),
|
|
||||||
{ type: 'separator' },
|
{ type: 'separator' },
|
||||||
{ role: 'quit' }
|
{ role: 'quit' }
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (isMac) {
|
||||||
|
mt.push(macAppMenu);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileMenu: MenuItemConstructorOptions = {
|
||||||
|
label: "File",
|
||||||
|
submenu: [
|
||||||
|
{
|
||||||
|
label: 'Add Cluster',
|
||||||
|
click() {
|
||||||
|
navigate(addClusterURL())
|
||||||
|
}
|
||||||
|
},
|
||||||
|
...activeClusterOnly([
|
||||||
|
{
|
||||||
|
label: 'Cluster Settings',
|
||||||
|
click() {
|
||||||
|
navigate(clusterSettingsURL({
|
||||||
|
params: {
|
||||||
|
clusterId: windowManager.activeClusterId
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]),
|
||||||
|
...ignoreOnMac([
|
||||||
|
{ type: 'separator' },
|
||||||
|
{
|
||||||
|
label: 'Preferences',
|
||||||
|
click() {
|
||||||
|
navigate(preferencesURL())
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ type: 'separator' },
|
||||||
|
{ role: 'quit' }
|
||||||
|
])
|
||||||
|
]
|
||||||
|
};
|
||||||
|
mt.push(fileMenu)
|
||||||
|
|
||||||
const editMenu: MenuItemConstructorOptions = {
|
const editMenu: MenuItemConstructorOptions = {
|
||||||
label: 'Edit',
|
label: 'Edit',
|
||||||
submenu: [
|
submenu: [
|
||||||
@ -71,7 +136,7 @@ export function buildMenu(windowManager: WindowManager) {
|
|||||||
{ role: 'selectAll' },
|
{ role: 'selectAll' },
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
mt.push(editMenu)
|
||||||
const viewMenu: MenuItemConstructorOptions = {
|
const viewMenu: MenuItemConstructorOptions = {
|
||||||
label: 'View',
|
label: 'View',
|
||||||
submenu: [
|
submenu: [
|
||||||
@ -79,21 +144,21 @@ export function buildMenu(windowManager: WindowManager) {
|
|||||||
label: 'Back',
|
label: 'Back',
|
||||||
accelerator: 'CmdOrCtrl+[',
|
accelerator: 'CmdOrCtrl+[',
|
||||||
click() {
|
click() {
|
||||||
webContents.getFocusedWebContents().executeJavaScript('window.history.back()')
|
webContents.getFocusedWebContents()?.goBack();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Forward',
|
label: 'Forward',
|
||||||
accelerator: 'CmdOrCtrl+]',
|
accelerator: 'CmdOrCtrl+]',
|
||||||
click() {
|
click() {
|
||||||
webContents.getFocusedWebContents().executeJavaScript('window.history.forward()')
|
webContents.getFocusedWebContents()?.goForward();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Reload',
|
label: 'Reload',
|
||||||
accelerator: 'CmdOrCtrl+R',
|
accelerator: 'CmdOrCtrl+R',
|
||||||
click() {
|
click() {
|
||||||
webContents.getFocusedWebContents().reload()
|
webContents.getFocusedWebContents()?.reload();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ role: 'toggleDevTools' },
|
{ role: 'toggleDevTools' },
|
||||||
@ -105,16 +170,11 @@ export function buildMenu(windowManager: WindowManager) {
|
|||||||
{ role: 'togglefullscreen' }
|
{ role: 'togglefullscreen' }
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
mt.push(viewMenu)
|
||||||
|
|
||||||
const helpMenu: MenuItemConstructorOptions = {
|
const helpMenu: MenuItemConstructorOptions = {
|
||||||
role: 'help',
|
role: 'help',
|
||||||
submenu: [
|
submenu: [
|
||||||
{
|
|
||||||
label: "What's new?",
|
|
||||||
click() {
|
|
||||||
windowManager.navigateMain(whatsNewURL())
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
label: "License",
|
label: "License",
|
||||||
click: async () => {
|
click: async () => {
|
||||||
@ -134,27 +194,23 @@ export function buildMenu(windowManager: WindowManager) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "About Lens",
|
label: "What's new?",
|
||||||
click(menuItem: MenuItem, browserWindow: BrowserWindow) {
|
click() {
|
||||||
const appInfo = [
|
navigate(whatsNewURL())
|
||||||
`${appName}: ${app.getVersion()}`,
|
},
|
||||||
`Electron: ${process.versions.electron}`,
|
},
|
||||||
`Chrome: ${process.versions.chrome}`,
|
...ignoreOnMac([
|
||||||
`Copyright 2020 Lakend Labs, Inc.`,
|
{
|
||||||
]
|
label: "About Lens",
|
||||||
dialog.showMessageBoxSync(browserWindow, {
|
click(menuItem: MenuItem, browserWindow: BrowserWindow) {
|
||||||
title: `${isWindows ? " ".repeat(2) : ""}${appName}`,
|
showAbout(browserWindow)
|
||||||
type: "info",
|
}
|
||||||
buttons: ["Close"],
|
|
||||||
message: `Lens`,
|
|
||||||
detail: appInfo.join("\r\n")
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
])
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
||||||
Menu.setApplicationMenu(Menu.buildFromTemplate([
|
mt.push(helpMenu)
|
||||||
fileMenu, editMenu, viewMenu, helpMenu
|
|
||||||
]));
|
Menu.setApplicationMenu(Menu.buildFromTemplate(mt));
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import http from "http"
|
|||||||
import path from "path"
|
import path from "path"
|
||||||
import { readFile } from "fs-extra"
|
import { readFile } from "fs-extra"
|
||||||
import { Cluster } from "./cluster"
|
import { Cluster } from "./cluster"
|
||||||
import { apiPrefix, appName, outDir } from "../common/vars";
|
import { apiPrefix, appName, publicPath } from "../common/vars";
|
||||||
import { helmRoute, kubeconfigRoute, metricsRoute, portForwardRoute, resourceApplierRoute, watchRoute } from "./routes";
|
import { helmRoute, kubeconfigRoute, metricsRoute, portForwardRoute, resourceApplierRoute, watchRoute } from "./routes";
|
||||||
|
|
||||||
export interface RouterRequestOpts {
|
export interface RouterRequestOpts {
|
||||||
@ -95,14 +95,14 @@ export class Router {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async handleStaticFile(filePath: string, res: http.ServerResponse) {
|
async handleStaticFile(filePath: string, res: http.ServerResponse) {
|
||||||
const asset = path.join(outDir, filePath);
|
const asset = path.join(__static, filePath);
|
||||||
try {
|
try {
|
||||||
const data = await readFile(asset);
|
const data = await readFile(asset);
|
||||||
res.setHeader("Content-Type", this.getMimeType(asset));
|
res.setHeader("Content-Type", this.getMimeType(asset));
|
||||||
res.write(data)
|
res.write(data)
|
||||||
res.end()
|
res.end()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.handleStaticFile(`${appName}.html`, res);
|
this.handleStaticFile(`${publicPath}/${appName}.html`, res);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -121,7 +121,7 @@ export class Router {
|
|||||||
this.router.add({ method: "post", path: `${apiPrefix}/metrics` }, metricsRoute.routeMetrics.bind(metricsRoute))
|
this.router.add({ method: "post", path: `${apiPrefix}/metrics` }, metricsRoute.routeMetrics.bind(metricsRoute))
|
||||||
|
|
||||||
// Port-forward API
|
// Port-forward API
|
||||||
this.router.add({ method: "post", path: `${apiPrefix}/services/{namespace}/{service}/port-forward/{port}` }, portForwardRoute.routeServicePortForward.bind(portForwardRoute))
|
this.router.add({ method: "post", path: `${apiPrefix}/pods/{namespace}/{resourceType}/{resourceName}/port-forward/{port}` }, portForwardRoute.routePortForward.bind(portForwardRoute))
|
||||||
|
|
||||||
// Helm API
|
// Helm API
|
||||||
this.router.add({ method: "get", path: `${apiPrefix}/v2/charts` }, helmRoute.listCharts.bind(helmRoute))
|
this.router.add({ method: "get", path: `${apiPrefix}/v2/charts` }, helmRoute.listCharts.bind(helmRoute))
|
||||||
|
|||||||
@ -16,8 +16,10 @@ class MetricsRoute extends LensApi {
|
|||||||
let prometheusPath: string
|
let prometheusPath: string
|
||||||
let prometheusProvider: PrometheusProvider
|
let prometheusProvider: PrometheusProvider
|
||||||
try {
|
try {
|
||||||
prometheusPath = await cluster.contextHandler.getPrometheusPath()
|
[prometheusPath, prometheusProvider] = await Promise.all([
|
||||||
prometheusProvider = await cluster.contextHandler.getPrometheusProvider()
|
cluster.contextHandler.getPrometheusPath(),
|
||||||
|
cluster.contextHandler.getPrometheusProvider()
|
||||||
|
])
|
||||||
} catch {
|
} catch {
|
||||||
this.respondJson(response, {})
|
this.respondJson(response, {})
|
||||||
return
|
return
|
||||||
|
|||||||
@ -14,7 +14,7 @@ class PortForward {
|
|||||||
return PortForward.portForwards.find((pf) => {
|
return PortForward.portForwards.find((pf) => {
|
||||||
return (
|
return (
|
||||||
pf.clusterId == forward.clusterId &&
|
pf.clusterId == forward.clusterId &&
|
||||||
pf.kind == "service" &&
|
pf.kind == forward.kind &&
|
||||||
pf.name == forward.name &&
|
pf.name == forward.name &&
|
||||||
pf.namespace == forward.namespace &&
|
pf.namespace == forward.namespace &&
|
||||||
pf.port == forward.port
|
pf.port == forward.port
|
||||||
@ -42,7 +42,7 @@ class PortForward {
|
|||||||
"--kubeconfig", this.kubeConfig,
|
"--kubeconfig", this.kubeConfig,
|
||||||
"port-forward",
|
"port-forward",
|
||||||
"-n", this.namespace,
|
"-n", this.namespace,
|
||||||
`service/${this.name}`,
|
`${this.kind}/${this.name}`,
|
||||||
`${this.localPort}:${this.port}`
|
`${this.localPort}:${this.port}`
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -72,21 +72,22 @@ class PortForward {
|
|||||||
|
|
||||||
class PortForwardRoute extends LensApi {
|
class PortForwardRoute extends LensApi {
|
||||||
|
|
||||||
public async routeServicePortForward(request: LensApiRequest) {
|
public async routePortForward(request: LensApiRequest) {
|
||||||
const { params, response, cluster} = request
|
const { params, response, cluster} = request
|
||||||
|
const { namespace, port, resourceType, resourceName } = params
|
||||||
|
|
||||||
let portForward = PortForward.getPortforward({
|
let portForward = PortForward.getPortforward({
|
||||||
clusterId: cluster.id, kind: "service", name: params.service,
|
clusterId: cluster.id, kind: resourceType, name: resourceName,
|
||||||
namespace: params.namespace, port: params.port
|
namespace: namespace, port: port
|
||||||
})
|
})
|
||||||
if (!portForward) {
|
if (!portForward) {
|
||||||
logger.info(`Creating a new port-forward ${params.namespace}/${params.service}:${params.port}`)
|
logger.info(`Creating a new port-forward ${namespace}/${resourceType}/${resourceName}:${port}`)
|
||||||
portForward = new PortForward({
|
portForward = new PortForward({
|
||||||
clusterId: cluster.id,
|
clusterId: cluster.id,
|
||||||
kind: "service",
|
kind: resourceType,
|
||||||
namespace: params.namespace,
|
namespace: namespace,
|
||||||
name: params.service,
|
name: resourceName,
|
||||||
port: params.port,
|
port: port,
|
||||||
kubeConfig: cluster.getProxyKubeconfigPath()
|
kubeConfig: cluster.getProxyKubeconfigPath()
|
||||||
})
|
})
|
||||||
const started = await portForward.start()
|
const started = await portForward.start()
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
import { BrowserWindow, shell } from "electron"
|
import type { ClusterId } from "../common/cluster-store";
|
||||||
|
import { BrowserWindow, dialog, ipcMain, shell, WebContents, webContents } from "electron"
|
||||||
import windowStateKeeper from "electron-window-state"
|
import windowStateKeeper from "electron-window-state"
|
||||||
|
import { observable } from "mobx";
|
||||||
import { initMenu } from "./menu";
|
import { initMenu } from "./menu";
|
||||||
|
|
||||||
export class WindowManager {
|
export class WindowManager {
|
||||||
@ -7,9 +9,9 @@ export class WindowManager {
|
|||||||
protected splashWindow: BrowserWindow;
|
protected splashWindow: BrowserWindow;
|
||||||
protected windowState: windowStateKeeper.State;
|
protected windowState: windowStateKeeper.State;
|
||||||
|
|
||||||
constructor(protected proxyPort: number) {
|
@observable activeClusterId: ClusterId;
|
||||||
initMenu(this);
|
|
||||||
|
|
||||||
|
constructor(protected proxyPort: number) {
|
||||||
// Manage main window size and position with state persistence
|
// Manage main window size and position with state persistence
|
||||||
this.windowState = windowStateKeeper({
|
this.windowState = windowStateKeeper({
|
||||||
defaultHeight: 900,
|
defaultHeight: 900,
|
||||||
@ -26,6 +28,7 @@ export class WindowManager {
|
|||||||
backgroundColor: "#1e2124",
|
backgroundColor: "#1e2124",
|
||||||
webPreferences: {
|
webPreferences: {
|
||||||
nodeIntegration: true,
|
nodeIntegration: true,
|
||||||
|
nodeIntegrationInSubFrames: true,
|
||||||
enableRemoteModule: true,
|
enableRemoteModule: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@ -37,20 +40,33 @@ export class WindowManager {
|
|||||||
shell.openExternal(url);
|
shell.openExternal(url);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// track visible cluster from ui
|
||||||
|
ipcMain.on("cluster-view:change", (event, clusterId: ClusterId) => {
|
||||||
|
this.activeClusterId = clusterId;
|
||||||
|
});
|
||||||
|
|
||||||
// load & show app
|
// load & show app
|
||||||
this.showMain();
|
this.showMain();
|
||||||
|
initMenu(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
// fixme
|
navigate({ url, channel, frameId }: { url: string, channel: string, frameId?: number }) {
|
||||||
navigateMain(url: string) {
|
if (frameId) {
|
||||||
this.mainView.webContents.executeJavaScript("console.log('implement me!')")
|
this.mainView.webContents.sendToFrame(frameId, channel, url);
|
||||||
|
} else {
|
||||||
|
this.mainView.webContents.send(channel, url);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async showMain() {
|
async showMain() {
|
||||||
await this.showSplash();
|
try {
|
||||||
await this.mainView.loadURL(`http://localhost:${this.proxyPort}`)
|
await this.showSplash();
|
||||||
this.mainView.show();
|
await this.mainView.loadURL(`http://localhost:${this.proxyPort}`)
|
||||||
this.splashWindow.hide();
|
this.mainView.show();
|
||||||
|
this.splashWindow.close();
|
||||||
|
} catch (err) {
|
||||||
|
dialog.showErrorBox("ERROR!", err.toString())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async showSplash() {
|
async showSplash() {
|
||||||
@ -63,6 +79,9 @@ export class WindowManager {
|
|||||||
frame: false,
|
frame: false,
|
||||||
resizable: false,
|
resizable: false,
|
||||||
show: false,
|
show: false,
|
||||||
|
webPreferences: {
|
||||||
|
nodeIntegration: true
|
||||||
|
}
|
||||||
});
|
});
|
||||||
await this.splashWindow.loadURL("static://splash.html");
|
await this.splashWindow.loadURL("static://splash.html");
|
||||||
}
|
}
|
||||||
|
|||||||
@ -50,6 +50,9 @@ export function parseKubeApi(path: string): IKubeApiParsed {
|
|||||||
apiGroup = left.join("/");
|
apiGroup = left.join("/");
|
||||||
} else {
|
} else {
|
||||||
switch (left.length) {
|
switch (left.length) {
|
||||||
|
case 4:
|
||||||
|
[apiGroup, apiVersion, resource, name] = left
|
||||||
|
break;
|
||||||
case 2:
|
case 2:
|
||||||
resource = left.pop();
|
resource = left.pop();
|
||||||
// fallthrough
|
// fallthrough
|
||||||
@ -66,7 +69,7 @@ export function parseKubeApi(path: string): IKubeApiParsed {
|
|||||||
* - `GROUP` is /^D(\.D)*$/ where D is `DNS_LABEL` and length <= 253
|
* - `GROUP` is /^D(\.D)*$/ where D is `DNS_LABEL` and length <= 253
|
||||||
*
|
*
|
||||||
* There is no well defined selection from an array of items that were
|
* There is no well defined selection from an array of items that were
|
||||||
* seperated by '/'
|
* separated by '/'
|
||||||
*
|
*
|
||||||
* Solution is to create a huristic. Namely:
|
* Solution is to create a huristic. Namely:
|
||||||
* 1. if '.' in left[0] then apiGroup <- left[0]
|
* 1. if '.' in left[0] then apiGroup <- left[0]
|
||||||
|
|||||||
@ -6,6 +6,19 @@ interface KubeApi_Parse_Test {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const tests: KubeApi_Parse_Test[] = [
|
const tests: KubeApi_Parse_Test[] = [
|
||||||
|
{
|
||||||
|
url: "/apis/apiextensions.k8s.io/v1beta1/customresourcedefinitions/prometheuses.monitoring.coreos.com",
|
||||||
|
expected: {
|
||||||
|
apiBase: "/apis/apiextensions.k8s.io/v1beta1/customresourcedefinitions",
|
||||||
|
apiPrefix: "/apis",
|
||||||
|
apiGroup: "apiextensions.k8s.io",
|
||||||
|
apiVersion: "v1beta1",
|
||||||
|
apiVersionWithGroup: "apiextensions.k8s.io/v1beta1",
|
||||||
|
namespace: undefined,
|
||||||
|
resource: "customresourcedefinitions",
|
||||||
|
name: "prometheuses.monitoring.coreos.com"
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
url: "/api/v1/namespaces/kube-system/pods/coredns-6955765f44-v8p27",
|
url: "/api/v1/namespaces/kube-system/pods/coredns-6955765f44-v8p27",
|
||||||
expected: {
|
expected: {
|
||||||
|
|||||||
@ -1,20 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { Notifications } from "./components/notifications";
|
|
||||||
import { Trans } from "@lingui/macro";
|
|
||||||
|
|
||||||
export function browserCheck() {
|
|
||||||
const ua = window.navigator.userAgent
|
|
||||||
const msie = ua.indexOf('MSIE ') // IE < 11
|
|
||||||
const trident = ua.indexOf('Trident/') // IE 11
|
|
||||||
const edge = ua.indexOf('Edge') // Edge
|
|
||||||
if (msie > 0 || trident > 0 || edge > 0) {
|
|
||||||
Notifications.info(
|
|
||||||
<p>
|
|
||||||
<Trans>
|
|
||||||
<b>Your browser does not support all Lens features. </b>{" "}
|
|
||||||
Please consider using another browser.
|
|
||||||
</Trans>
|
|
||||||
</p>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,2 +1,11 @@
|
|||||||
.AddCluster {
|
.AddCluster {
|
||||||
|
.Select {
|
||||||
|
&__control {
|
||||||
|
box-shadow: 0 0 0 1px $borderFaintColor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
color: $pink-400;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -15,8 +15,9 @@ import { getKubeConfigLocal, loadConfig, saveConfigToAppFiles, splitConfig, vali
|
|||||||
import { clusterStore } from "../../../common/cluster-store";
|
import { clusterStore } from "../../../common/cluster-store";
|
||||||
import { workspaceStore } from "../../../common/workspace-store";
|
import { workspaceStore } from "../../../common/workspace-store";
|
||||||
import { v4 as uuid } from "uuid"
|
import { v4 as uuid } from "uuid"
|
||||||
import { navigation } from "../../navigation";
|
import { navigate } from "../../navigation";
|
||||||
import { userStore } from "../../../common/user-store";
|
import { userStore } from "../../../common/user-store";
|
||||||
|
import { clusterViewURL } from "../cluster-manager/cluster-view.route";
|
||||||
|
|
||||||
@observer
|
@observer
|
||||||
export class AddCluster extends React.Component {
|
export class AddCluster extends React.Component {
|
||||||
@ -70,8 +71,9 @@ export class AddCluster extends React.Component {
|
|||||||
if (value instanceof KubeConfig) {
|
if (value instanceof KubeConfig) {
|
||||||
const context = value.currentContext;
|
const context = value.currentContext;
|
||||||
const isNew = userStore.newContexts.has(context);
|
const isNew = userStore.newContexts.has(context);
|
||||||
|
const className = `${context} kube-context flex gaps align-center`
|
||||||
return (
|
return (
|
||||||
<div className="kube-context flex gaps align-center">
|
<div className={className}>
|
||||||
<span>{context}</span>
|
<span>{context}</span>
|
||||||
{isNew && <Icon material="fiber_new"/>}
|
{isNew && <Icon material="fiber_new"/>}
|
||||||
</div>
|
</div>
|
||||||
@ -102,7 +104,7 @@ export class AddCluster extends React.Component {
|
|||||||
httpsProxy: proxyServer || undefined,
|
httpsProxy: proxyServer || undefined,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
navigation.goBack(); // return to previous opened page for the cluster view
|
navigate(clusterViewURL({ params: { clusterId } }))
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.error = String(err);
|
this.error = String(err);
|
||||||
} finally {
|
} finally {
|
||||||
@ -124,7 +126,7 @@ export class AddCluster extends React.Component {
|
|||||||
to allow you to operate easily on multiple clusters and/or contexts.
|
to allow you to operate easily on multiple clusters and/or contexts.
|
||||||
</p>
|
</p>
|
||||||
<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>
|
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>
|
||||||
<p>
|
<p>
|
||||||
NOTE: Any manually added cluster is not merged into your kubeconfig file.
|
NOTE: Any manually added cluster is not merged into your kubeconfig file.
|
||||||
@ -137,22 +139,20 @@ export class AddCluster extends React.Component {
|
|||||||
app.
|
app.
|
||||||
</p>
|
</p>
|
||||||
<a href="https://kubernetes.io/docs/reference/access-authn-authz/authentication/#option-1-oidc-authenticator" target="_blank">
|
<a href="https://kubernetes.io/docs/reference/access-authn-authz/authentication/#option-1-oidc-authenticator" target="_blank">
|
||||||
<h4>OIDC (OpenID Connect)</h4>
|
<h3>OIDC (OpenID Connect)</h3>
|
||||||
</a>
|
</a>
|
||||||
<div>
|
<p>
|
||||||
<p>
|
When connecting Lens to OIDC enabled cluster, there's few things you as a user need to take into account.
|
||||||
When connecting Lens to OIDC enabled cluster, there's few things you as a user need to take into account.
|
</p>
|
||||||
</p>
|
<p><b>Dedicated refresh token</b></p>
|
||||||
<b>Dedicated refresh token</b>
|
<p>
|
||||||
<p>
|
As Lens app utilized kubeconfig is "disconnected" from your main kubeconfig Lens needs to have it's own refresh token it utilizes.
|
||||||
As Lens app utilized kubeconfig is "disconnected" from your main kubeconfig Lens needs to have it's own refresh token it utilizes.
|
If you share the refresh token with e.g. <code>kubectl</code> who ever uses the token first will invalidate it for the next user.
|
||||||
If you share the refresh token with e.g. <code>kubectl</code> who ever uses the token first will invalidate it for the next user.
|
One way to achieve this is with <a href="https://github.com/int128/kubelogin" target="_blank">kubelogin</a> tool by removing the tokens
|
||||||
One way to achieve this is with <a href="https://github.com/int128/kubelogin" target="_blank">kubelogin</a> tool by removing the tokens
|
(both <code>id_token</code> and <code>refresh_token</code>) from
|
||||||
(both <code>id_token</code> and <code>refresh_token</code>) from
|
the config and issuing <code>kubelogin</code> command. That'll take you through the login process and will result you having "dedicated" refresh token.
|
||||||
the config and issuing <code>kubelogin</code> command. That'll take you through the login process and will result you having "dedicated" refresh token.
|
</p>
|
||||||
</p>
|
<h3>Exec auth plugins</h3>
|
||||||
</div>
|
|
||||||
<h4>Exec auth plugins</h4>
|
|
||||||
<p>
|
<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
|
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
|
any binaries
|
||||||
@ -167,12 +167,14 @@ export class AddCluster extends React.Component {
|
|||||||
return (
|
return (
|
||||||
<WizardLayout className="AddCluster" infoPanel={this.renderInfo()}>
|
<WizardLayout className="AddCluster" infoPanel={this.renderInfo()}>
|
||||||
<h2><Trans>Add Cluster</Trans></h2>
|
<h2><Trans>Add Cluster</Trans></h2>
|
||||||
|
<p>Choose config:</p>
|
||||||
<Select
|
<Select
|
||||||
placeholder={<Trans>Select kubeconfig</Trans>}
|
placeholder={<Trans>Select kubeconfig</Trans>}
|
||||||
value={this.clusterConfig}
|
value={this.clusterConfig}
|
||||||
options={this.clusterOptions}
|
options={this.clusterOptions}
|
||||||
onChange={({ value }: SelectOption) => this.clusterConfig = value}
|
onChange={({ value }: SelectOption) => this.clusterConfig = value}
|
||||||
formatOptionLabel={this.formatClusterContextLabel}
|
formatOptionLabel={this.formatClusterContextLabel}
|
||||||
|
id="kubecontext-select"
|
||||||
/>
|
/>
|
||||||
<div className="cluster-settings">
|
<div className="cluster-settings">
|
||||||
<a href="#" onClick={() => this.showSettings = !this.showSettings}>
|
<a href="#" onClick={() => this.showSettings = !this.showSettings}>
|
||||||
@ -181,14 +183,15 @@ export class AddCluster extends React.Component {
|
|||||||
</div>
|
</div>
|
||||||
{this.showSettings && (
|
{this.showSettings && (
|
||||||
<div className="proxy-settings">
|
<div className="proxy-settings">
|
||||||
|
<p>HTTP Proxy server. Used for communicating with Kubernetes API.</p>
|
||||||
<Input
|
<Input
|
||||||
autoFocus
|
autoFocus
|
||||||
placeholder={_i18n._(t`A HTTP proxy server URL (format: http://<address>:<port>)`)}
|
|
||||||
value={this.proxyServer}
|
value={this.proxyServer}
|
||||||
onChange={value => this.proxyServer = value}
|
onChange={value => this.proxyServer = value}
|
||||||
|
theme="round-black"
|
||||||
/>
|
/>
|
||||||
<small className="hint">
|
<small className="hint">
|
||||||
<Trans>HTTP Proxy server. Used for communicating with Kubernetes API.</Trans>
|
{'A HTTP proxy server URL (format: http://<address>:<port>).'}
|
||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -197,6 +200,7 @@ export class AddCluster extends React.Component {
|
|||||||
<p>Kubeconfig:</p>
|
<p>Kubeconfig:</p>
|
||||||
<AceEditor
|
<AceEditor
|
||||||
autoFocus
|
autoFocus
|
||||||
|
showGutter={false}
|
||||||
mode="yaml"
|
mode="yaml"
|
||||||
value={this.customConfig}
|
value={this.customConfig}
|
||||||
onChange={value => this.customConfig = value}
|
onChange={value => this.customConfig = value}
|
||||||
@ -209,7 +213,7 @@ export class AddCluster extends React.Component {
|
|||||||
<div className="actions-panel">
|
<div className="actions-panel">
|
||||||
<Button
|
<Button
|
||||||
primary
|
primary
|
||||||
label={<Trans>Add cluster</Trans>}
|
label={<Trans>Add cluster(s)</Trans>}
|
||||||
onClick={this.addCluster}
|
onClick={this.addCluster}
|
||||||
waiting={this.isWaiting}
|
waiting={this.isWaiting}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -1,8 +1,12 @@
|
|||||||
|
import type { IClusterViewRouteParams } from "../cluster-manager/cluster-view.route";
|
||||||
import { RouteProps } from "react-router";
|
import { RouteProps } from "react-router";
|
||||||
import { buildURL } from "../../navigation";
|
import { buildURL } from "../../navigation";
|
||||||
|
|
||||||
export const clusterSettingsRoute: RouteProps = {
|
export interface IClusterSettingsRouteParams extends IClusterViewRouteParams {
|
||||||
path: "/cluster-settings"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const clusterSettingsURL = buildURL(clusterSettingsRoute.path)
|
export const clusterSettingsRoute: RouteProps = {
|
||||||
|
path: `/cluster/:clusterId/settings`,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const clusterSettingsURL = buildURL<IClusterSettingsRouteParams>(clusterSettingsRoute.path)
|
||||||
|
|||||||
@ -1,86 +1,83 @@
|
|||||||
.ClusterSettings {
|
.ClusterSettings {
|
||||||
overflow-y: scroll;
|
.WizardLayout {
|
||||||
grid-template-columns: unset;
|
grid-template-columns: unset;
|
||||||
|
grid-template-rows: 76px 1fr;
|
||||||
|
padding: 0;
|
||||||
|
|
||||||
.info-col {
|
.head-col {
|
||||||
display: none;
|
justify-content: space-between;
|
||||||
|
|
||||||
|
:nth-child(2) {
|
||||||
|
flex: 1 0 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.content-col {
|
.content-col {
|
||||||
margin-right: unset;
|
margin: 0;
|
||||||
}
|
padding-top: $padding * 3;
|
||||||
|
background-color: transparent;
|
||||||
|
|
||||||
* {
|
.SubTitle {
|
||||||
margin-top: 40px;
|
text-transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
&:first-child {
|
> div {
|
||||||
margin-top: 0px;
|
margin-top: $margin * 5;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
h4 {
|
.admin-note {
|
||||||
margin-top: 20px;
|
font-size: small;
|
||||||
|
opacity: 0.5;
|
||||||
|
margin-left: $margin;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-area {
|
||||||
|
margin-top: $margin * 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-loader {
|
||||||
|
margin-top: $margin * 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
font-size: smaller;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
p + p, .hint + p {
|
||||||
|
padding-top: $padding;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-table {
|
.status-table {
|
||||||
margin-top: 20px;
|
margin: $margin * 3 0;
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 3fr;
|
|
||||||
grid-gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading {
|
.Table {
|
||||||
margin-top: 20px;
|
border: 1px solid var(--drawerSubtitleBackground);
|
||||||
text-align: center;
|
border-radius: $radius;
|
||||||
|
|
||||||
.Spinner {
|
.TableRow {
|
||||||
display: inline-block;
|
&:not(:last-of-type) {
|
||||||
|
border-bottom: 1px solid var(--drawerSubtitleBackground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.value {
|
||||||
|
flex-grow: 2;
|
||||||
|
word-break: break-word;
|
||||||
|
color: var(--textColorSecondary);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.Input,.Select {
|
.Input, .Select {
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.Icon:not(.updated):not(.clean) {
|
.Select {
|
||||||
color: #ad0000;
|
&__control {
|
||||||
}
|
box-shadow: 0 0 0 1px $borderFaintColor;
|
||||||
|
}
|
||||||
.Icon.updated {
|
|
||||||
color: #00dd1d;
|
|
||||||
}
|
|
||||||
|
|
||||||
.updated {
|
|
||||||
animation: updated-name 1s 1;
|
|
||||||
animation-fill-mode: forwards;
|
|
||||||
animation-delay: 3s;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes updated-name {
|
|
||||||
from {opacity :1;}
|
|
||||||
to {opacity :0;}
|
|
||||||
}
|
|
||||||
|
|
||||||
.center {
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
input[type="text"]::placeholder {
|
|
||||||
font-size: small;
|
|
||||||
color: #707070;
|
|
||||||
}
|
|
||||||
|
|
||||||
input[type="text"] {
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
button {
|
|
||||||
margin-top: 5px;
|
|
||||||
|
|
||||||
.Spinner {
|
|
||||||
width: 10px;
|
|
||||||
height: 10px;
|
|
||||||
border-color: transparent black;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -1,24 +1,43 @@
|
|||||||
import "./cluster-settings.scss"
|
import "./cluster-settings.scss";
|
||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
import { Features } from "./features"
|
import { Features } from "./features";
|
||||||
import { Removal } from "./removal"
|
import { Removal } from "./removal";
|
||||||
import { Status } from "./status"
|
import { Status } from "./status";
|
||||||
import { General } from "./general"
|
import { General } from "./general";
|
||||||
import { getHostedCluster } from "../../../common/cluster-store"
|
|
||||||
import { WizardLayout } from "../layout/wizard-layout";
|
import { WizardLayout } from "../layout/wizard-layout";
|
||||||
|
import { ClusterIcon } from "../cluster-icon";
|
||||||
|
import { Icon } from "../icon";
|
||||||
|
import { getMatchedCluster } from "../cluster-manager/cluster-view.route";
|
||||||
|
import { navigate } from "../../navigation";
|
||||||
|
|
||||||
@observer
|
@observer
|
||||||
export class ClusterSettings extends React.Component {
|
export class ClusterSettings extends React.Component {
|
||||||
render() {
|
render() {
|
||||||
const cluster = getHostedCluster();
|
const cluster = getMatchedCluster();
|
||||||
|
if (!cluster) return null;
|
||||||
|
const header = (
|
||||||
|
<>
|
||||||
|
<ClusterIcon
|
||||||
|
cluster={cluster}
|
||||||
|
showErrors={false}
|
||||||
|
showTooltip={false}
|
||||||
|
/>
|
||||||
|
<h2>{cluster.preferences.clusterName}</h2>
|
||||||
|
<Icon material="close" onClick={() => navigate("/")} big/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<WizardLayout className="ClusterSettings">
|
<div className="ClusterSettings">
|
||||||
<Status cluster={cluster}></Status>
|
<WizardLayout header={header} centered>
|
||||||
<General cluster={cluster}></General>
|
<Status cluster={cluster}></Status>
|
||||||
<Features cluster={cluster}></Features>
|
<General cluster={cluster}></General>
|
||||||
<Removal cluster={cluster}></Removal>
|
<Features cluster={cluster}></Features>
|
||||||
</WizardLayout>
|
<Removal cluster={cluster}></Removal>
|
||||||
)
|
</WizardLayout>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,86 +1,43 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { Cluster } from "../../../../main/cluster";
|
|
||||||
import { Input } from "../../input";
|
|
||||||
import { Spinner } from "../../spinner";
|
|
||||||
import { clusterStore } from "../../../../common/cluster-store"
|
|
||||||
import { Icon } from "../../icon";
|
|
||||||
import { Tooltip, TooltipPosition } from "../../tooltip";
|
|
||||||
import { autobind } from "../../../utils";
|
|
||||||
import { TextInputStatus } from "./statuses"
|
|
||||||
import { observable } from "mobx";
|
import { observable } from "mobx";
|
||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
|
import { Cluster } from "../../../../main/cluster";
|
||||||
|
import { Input } from "../../input";
|
||||||
|
import { SubTitle } from "../../layout/sub-title";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
cluster: Cluster;
|
cluster: Cluster;
|
||||||
}
|
}
|
||||||
|
|
||||||
@observer
|
@observer
|
||||||
export class ClusterHomeDirSetting extends React.Component<Props> {
|
export class ClusterHomeDirSetting extends React.Component<Props> {
|
||||||
@observable directory = this.props.cluster.preferences.terminalCWD || "";
|
@observable directory = this.props.cluster.preferences.terminalCWD || "";
|
||||||
@observable status = TextInputStatus.CLEAN;
|
|
||||||
@observable errorText?: string;
|
save = () => {
|
||||||
|
this.props.cluster.preferences.terminalCWD = this.directory;
|
||||||
|
};
|
||||||
|
|
||||||
|
onChange = (value: string) => {
|
||||||
|
this.directory = value;
|
||||||
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
return <>
|
return (
|
||||||
<h4>Working Directory</h4>
|
<>
|
||||||
<p>Set initial working directory for terminals. When set it will the `pwd` when a new terminal instance is opened for this cluster.</p>
|
<SubTitle title="Working Directory"/>
|
||||||
<Input
|
<p>Terminal working directory.</p>
|
||||||
theme="round-black"
|
<Input
|
||||||
className="box grow"
|
theme="round-black"
|
||||||
value={this.directory}
|
value={this.directory}
|
||||||
onSubmit={this.onWorkingDirectorySubmit}
|
onChange={this.onChange}
|
||||||
onChange={this.onWorkingDirectoryChange}
|
onBlur={this.save}
|
||||||
iconRight={this.getIconRight()}
|
placeholder="$HOME"
|
||||||
placeholder="$HOME"
|
/>
|
||||||
/>
|
<span className="hint">
|
||||||
</>;
|
An explicit start path where the terminal will be launched,{" "}
|
||||||
}
|
this is used as the current working directory (cwd) for the shell process.
|
||||||
|
</span>
|
||||||
@autobind()
|
</>
|
||||||
onWorkingDirectoryChange(directory: string, _e: React.ChangeEvent) {
|
);
|
||||||
if (this.status === TextInputStatus.UPDATING) {
|
|
||||||
console.log("prevent changing cluster directory while updating");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.status = this.dirDiffers(directory);
|
|
||||||
this.directory = directory;
|
|
||||||
}
|
|
||||||
|
|
||||||
dirDiffers(directory: string): TextInputStatus {
|
|
||||||
const { terminalCWD = "" } = this.props.cluster.preferences;
|
|
||||||
|
|
||||||
return directory === terminalCWD ? TextInputStatus.CLEAN : TextInputStatus.DIRTY;
|
|
||||||
}
|
|
||||||
|
|
||||||
getIconRight(): React.ReactNode {
|
|
||||||
switch (this.status) {
|
|
||||||
case TextInputStatus.CLEAN:
|
|
||||||
return null;
|
|
||||||
case TextInputStatus.DIRTY:
|
|
||||||
return <Icon size="16px" material="fiber_manual_record"/>;
|
|
||||||
case TextInputStatus.UPDATED:
|
|
||||||
return <Icon size="16px" className="updated" material="done"/>;
|
|
||||||
case TextInputStatus.UPDATING:
|
|
||||||
return <Spinner />;
|
|
||||||
case TextInputStatus.ERROR:
|
|
||||||
return <Icon id="cluster-directory-setting-error-icon" size="16px" material="error">
|
|
||||||
<Tooltip targetId="cluster-directory-setting-error-icon" position={TooltipPosition.TOP}>
|
|
||||||
{this.errorText}
|
|
||||||
</Tooltip>
|
|
||||||
</Icon>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@autobind()
|
|
||||||
onWorkingDirectorySubmit(directory: string) {
|
|
||||||
if (this.dirDiffers(directory) !== TextInputStatus.DIRTY) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.status = TextInputStatus.UPDATING
|
|
||||||
this.props.cluster.preferences.terminalCWD = directory;
|
|
||||||
this.directory = directory;
|
|
||||||
this.status = TextInputStatus.UPDATED
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1,16 +1,20 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { Cluster } from "../../../../main/cluster";
|
import { Cluster } from "../../../../main/cluster";
|
||||||
import { clusterStore } from "../../../../common/cluster-store"
|
|
||||||
import { Icon } from "../../icon";
|
|
||||||
import { FilePicker, OverSizeLimitStyle } from "../../file-picker";
|
import { FilePicker, OverSizeLimitStyle } from "../../file-picker";
|
||||||
import { autobind } from "../../../utils";
|
import { autobind } from "../../../utils";
|
||||||
import { Button } from "../../button";
|
import { Button } from "../../button";
|
||||||
import { GeneralInputStatus } from "./statuses"
|
|
||||||
import { observable } from "mobx";
|
import { observable } from "mobx";
|
||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
|
import { SubTitle } from "../../layout/sub-title";
|
||||||
|
import { ClusterIcon } from "../../cluster-icon";
|
||||||
|
|
||||||
|
enum GeneralInputStatus {
|
||||||
|
CLEAN = "clean",
|
||||||
|
ERROR = "error",
|
||||||
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
cluster: Cluster;
|
cluster: Cluster;
|
||||||
}
|
}
|
||||||
|
|
||||||
@observer
|
@observer
|
||||||
@ -21,7 +25,6 @@ export class ClusterIconSetting extends React.Component<Props> {
|
|||||||
@autobind()
|
@autobind()
|
||||||
async onIconPick([file]: File[]) {
|
async onIconPick([file]: File[]) {
|
||||||
const { cluster } = this.props;
|
const { cluster } = this.props;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (file) {
|
if (file) {
|
||||||
const buf = Buffer.from(await file.arrayBuffer());
|
const buf = Buffer.from(await file.arrayBuffer());
|
||||||
@ -38,35 +41,36 @@ export class ClusterIconSetting extends React.Component<Props> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getClearButton() {
|
getClearButton() {
|
||||||
const { cluster } = this.props;
|
if (this.props.cluster.preferences.icon) {
|
||||||
|
return <Button tooltip="Revert back to default icon" accent onClick={() => this.onIconPick([])}>Clear</Button>
|
||||||
if (cluster.preferences.icon) {
|
|
||||||
return <Button accent onClick={() => this.onIconPick([])}>Clear</Button>
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
return <>
|
const label = (
|
||||||
<h4>Cluster Icon</h4>
|
<>
|
||||||
<p>Set cluster icon. By default it is automatically generated. {this.getIconRight()}</p>
|
<ClusterIcon
|
||||||
<div className="center">
|
cluster={this.props.cluster}
|
||||||
<FilePicker
|
showErrors={false}
|
||||||
accept="image/*"
|
showTooltip={false}
|
||||||
labelText="Browse for new icon..."
|
|
||||||
onOverSizeLimit={OverSizeLimitStyle.FILTER}
|
|
||||||
handler={this.onIconPick}
|
|
||||||
/>
|
/>
|
||||||
{this.getClearButton()}
|
{"Browse for new icon..."}
|
||||||
</div>
|
</>
|
||||||
</>;
|
);
|
||||||
}
|
return (
|
||||||
|
<>
|
||||||
getIconRight(): React.ReactNode {
|
<SubTitle title="Cluster Icon" />
|
||||||
switch (this.status) {
|
<p>Define cluster icon. By default automatically generated.</p>
|
||||||
case GeneralInputStatus.CLEAN:
|
<div className="file-loader">
|
||||||
return null;
|
<FilePicker
|
||||||
case GeneralInputStatus.ERROR:
|
accept="image/*"
|
||||||
return <Icon size="16px" material="error" title={this.errorText}></Icon>
|
label={label}
|
||||||
}
|
onOverSizeLimit={OverSizeLimitStyle.FILTER}
|
||||||
|
handler={this.onIconPick}
|
||||||
|
/>
|
||||||
|
{this.getClearButton()}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1,85 +1,40 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { Cluster } from "../../../../main/cluster";
|
import { Cluster } from "../../../../main/cluster";
|
||||||
import { Input } from "../../input";
|
import { Input } from "../../input";
|
||||||
import { Spinner } from "../../spinner";
|
|
||||||
import { clusterStore } from "../../../../common/cluster-store"
|
|
||||||
import { Icon } from "../../icon";
|
|
||||||
import { Tooltip, TooltipPosition } from "../../tooltip";
|
|
||||||
import { autobind } from "../../../utils";
|
|
||||||
import { TextInputStatus } from "./statuses"
|
|
||||||
import { observable } from "mobx";
|
import { observable } from "mobx";
|
||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
|
import { SubTitle } from "../../layout/sub-title";
|
||||||
|
import { isRequired } from "../../input/input.validators";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
cluster: Cluster;
|
cluster: Cluster;
|
||||||
}
|
}
|
||||||
|
|
||||||
@observer
|
@observer
|
||||||
export class ClusterNameSetting extends React.Component<Props> {
|
export class ClusterNameSetting extends React.Component<Props> {
|
||||||
@observable name = this.props.cluster.preferences.clusterName || "";
|
@observable name = this.props.cluster.preferences.clusterName || "";
|
||||||
@observable status = TextInputStatus.CLEAN;
|
|
||||||
@observable errorText?: string;
|
save = () => {
|
||||||
|
this.props.cluster.preferences.clusterName = this.name;
|
||||||
|
};
|
||||||
|
|
||||||
|
onChange = (value: string) => {
|
||||||
|
this.name = value;
|
||||||
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
return <>
|
return (
|
||||||
<h4>Cluster Name</h4>
|
<>
|
||||||
<p>Change cluster name:</p>
|
<SubTitle title="Cluster Name"/>
|
||||||
<Input
|
<p>Define cluster name.</p>
|
||||||
theme="round-black"
|
<Input
|
||||||
className="box grow"
|
theme="round-black"
|
||||||
value={this.name}
|
validators={isRequired}
|
||||||
onSubmit={this.onClusterNameSubmit}
|
value={this.name}
|
||||||
onChange={this.onClusterNameChange}
|
onChange={this.onChange}
|
||||||
iconRight={this.getIconRight()}
|
onBlur={this.save}
|
||||||
/>
|
/>
|
||||||
</>;
|
</>
|
||||||
}
|
);
|
||||||
|
|
||||||
@autobind()
|
|
||||||
onClusterNameChange(name: string, _e: React.ChangeEvent) {
|
|
||||||
if (this.status === TextInputStatus.UPDATING) {
|
|
||||||
console.log("prevent changing cluster name while updating");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.status = this.nameDiffers(name)
|
|
||||||
this.name = name;
|
|
||||||
}
|
|
||||||
|
|
||||||
nameDiffers(name: string): TextInputStatus {
|
|
||||||
const { clusterName } = this.props.cluster.preferences;
|
|
||||||
|
|
||||||
return name === clusterName ? TextInputStatus.CLEAN : TextInputStatus.DIRTY;
|
|
||||||
}
|
|
||||||
|
|
||||||
getIconRight(): React.ReactNode {
|
|
||||||
switch (this.status) {
|
|
||||||
case TextInputStatus.CLEAN:
|
|
||||||
return null;
|
|
||||||
case TextInputStatus.DIRTY:
|
|
||||||
return <Icon size="16px" material="fiber_manual_record"/>;
|
|
||||||
case TextInputStatus.UPDATED:
|
|
||||||
return <Icon size="16px" className="updated" material="done"/>;
|
|
||||||
case TextInputStatus.UPDATING:
|
|
||||||
return <Spinner/>;
|
|
||||||
case TextInputStatus.ERROR:
|
|
||||||
return <Icon id="cluster-name-setting-error-icon" size="16px" material="error">
|
|
||||||
<Tooltip targetId="cluster-name-setting-error-icon" position={TooltipPosition.TOP}>
|
|
||||||
{this.errorText}
|
|
||||||
</Tooltip>
|
|
||||||
</Icon>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@autobind()
|
|
||||||
onClusterNameSubmit(name: string) {
|
|
||||||
if (this.nameDiffers(name) !== TextInputStatus.DIRTY) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.status = TextInputStatus.UPDATING
|
|
||||||
this.props.cluster.preferences.clusterName = name;
|
|
||||||
this.name = name;
|
|
||||||
this.status = TextInputStatus.UPDATED
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1,41 +1,105 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { Cluster } from "../../../../main/cluster";
|
|
||||||
import { clusterStore } from "../../../../common/cluster-store"
|
|
||||||
import { Select, SelectOption, SelectProps } from "../../select";
|
|
||||||
import { prometheusProviders } from "../../../../common/prometheus-providers";
|
|
||||||
import { autobind } from "../../../utils";
|
|
||||||
import { observable } from "mobx";
|
|
||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
|
import { prometheusProviders } from "../../../../common/prometheus-providers";
|
||||||
|
import { Cluster } from "../../../../main/cluster";
|
||||||
|
import { SubTitle } from "../../layout/sub-title";
|
||||||
|
import { Select, SelectOption } from "../../select";
|
||||||
|
import { Input } from "../../input";
|
||||||
|
import { observable, computed } from "mobx";
|
||||||
|
|
||||||
const prometheusGuide = "https://github.com/lensapp/lens/blob/master/troubleshooting/custom-prometheus.md";
|
|
||||||
const options: SelectOption<string>[] = [
|
const options: SelectOption<string>[] = [
|
||||||
{ value: "", label: "Auto detect" },
|
{ value: "", label: "Auto detect" },
|
||||||
...prometheusProviders.map(pp => ({value: pp.id, label: pp.name}))
|
...prometheusProviders.map(pp => ({value: pp.id, label: pp.name}))
|
||||||
];
|
];
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
cluster: Cluster;
|
cluster: Cluster;
|
||||||
}
|
}
|
||||||
|
|
||||||
@observer
|
@observer
|
||||||
export class ClusterPrometheusSetting extends React.Component<Props> {
|
export class ClusterPrometheusSetting extends React.Component<Props> {
|
||||||
@observable prometheusProvider = this.props.cluster.preferences.prometheusProvider?.type || "";
|
@observable path = "";
|
||||||
|
@observable provider = "";
|
||||||
render() {
|
|
||||||
return <>
|
@computed get canEditPrometheusPath() {
|
||||||
<h4>Cluster Prometheus</h4>
|
if (this.provider === "" || this.provider === "lens") return false;
|
||||||
<p>Use pre-installed Prometheus service for metrics. Please refer to <a href={prometheusGuide}>this guide</a> for possible configuration changes.</p>
|
return true;
|
||||||
<Select
|
|
||||||
value={this.prometheusProvider}
|
|
||||||
options={options}
|
|
||||||
onChange={this.changePrometheusProvider}
|
|
||||||
/>
|
|
||||||
</>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@autobind()
|
componentDidMount() {
|
||||||
changePrometheusProvider({ value: prometheusProvider }: SelectProps<string>) {
|
const { prometheus, prometheusProvider } = this.props.cluster.preferences;
|
||||||
this.prometheusProvider = prometheusProvider;
|
if (prometheus) {
|
||||||
this.props.cluster.preferences.prometheusProvider = { type: prometheusProvider };
|
const prefix = prometheus.prefix || "";
|
||||||
|
this.path = `${prometheus.namespace}/${prometheus.service}:${prometheus.port}${prefix}`;
|
||||||
|
}
|
||||||
|
if (prometheusProvider) {
|
||||||
|
this.provider = prometheusProvider.type;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
parsePrometheusPath = () => {
|
||||||
|
if (!this.provider || !this.path) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const parsed = this.path.split(/\/|:/, 3);
|
||||||
|
const apiPrefix = this.path.substring(parsed.join("/").length);
|
||||||
|
if (!parsed[0] || !parsed[1] || !parsed[2]) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
namespace: parsed[0],
|
||||||
|
service: parsed[1],
|
||||||
|
port: parseInt(parsed[2]),
|
||||||
|
prefix: apiPrefix
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onSaveProvider = () => {
|
||||||
|
this.props.cluster.preferences.prometheusProvider = this.provider ?
|
||||||
|
{ type: this.provider } :
|
||||||
|
null;
|
||||||
|
}
|
||||||
|
|
||||||
|
onSavePath = () => {
|
||||||
|
this.props.cluster.preferences.prometheus = this.parsePrometheusPath();
|
||||||
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SubTitle title="Prometheus"/>
|
||||||
|
<p>
|
||||||
|
Use pre-installed Prometheus service for metrics. Please refer to the{" "}
|
||||||
|
<a href="https://github.com/lensapp/lens/blob/master/troubleshooting/custom-prometheus.md" target="_blank">guide</a>{" "}
|
||||||
|
for possible configuration changes.
|
||||||
|
</p>
|
||||||
|
<p>Prometheus installation method.</p>
|
||||||
|
<Select
|
||||||
|
value={this.provider}
|
||||||
|
onChange={({value}) => {
|
||||||
|
this.provider = value;
|
||||||
|
this.onSaveProvider();
|
||||||
|
}}
|
||||||
|
options={options}
|
||||||
|
/>
|
||||||
|
<span className="hint">What query format is used to fetch metrics from Prometheus</span>
|
||||||
|
{this.canEditPrometheusPath && (
|
||||||
|
<>
|
||||||
|
<p>Prometheus service address.</p>
|
||||||
|
<Input
|
||||||
|
theme="round-black"
|
||||||
|
value={this.path}
|
||||||
|
onChange={(value) => this.path = value}
|
||||||
|
onBlur={this.onSavePath}
|
||||||
|
placeholder="<namespace>/<service>:<port>"
|
||||||
|
/>
|
||||||
|
<span className="hint">
|
||||||
|
An address to an existing Prometheus installation{" "}
|
||||||
|
({'<namespace>/<service>:<port>'}). Lens tries to auto-detect address if left empty.
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1,105 +1,41 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { Cluster } from "../../../../main/cluster";
|
|
||||||
import { Input } from "../../input";
|
|
||||||
import { Spinner } from "../../spinner";
|
|
||||||
import { clusterStore } from "../../../../common/cluster-store"
|
|
||||||
import { Icon } from "../../icon";
|
|
||||||
import { Tooltip, TooltipPosition } from "../../tooltip";
|
|
||||||
import { autobind } from "../../../utils";
|
|
||||||
import { TextInputStatus } from "./statuses"
|
|
||||||
import { observable } from "mobx";
|
import { observable } from "mobx";
|
||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
|
import { Cluster } from "../../../../main/cluster";
|
||||||
|
import { Input } from "../../input";
|
||||||
|
import { isUrl } from "../../input/input.validators";
|
||||||
|
import { SubTitle } from "../../layout/sub-title";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
cluster: Cluster;
|
cluster: Cluster;
|
||||||
}
|
}
|
||||||
|
|
||||||
@observer
|
@observer
|
||||||
export class ClusterProxySetting extends React.Component<Props> {
|
export class ClusterProxySetting extends React.Component<Props> {
|
||||||
@observable proxy = this.props.cluster.preferences.httpsProxy || "";
|
@observable proxy = this.props.cluster.preferences.httpsProxy || "";
|
||||||
@observable status = TextInputStatus.CLEAN;
|
|
||||||
@observable errorText?: string;
|
save = () => {
|
||||||
|
this.props.cluster.preferences.httpsProxy = this.proxy;
|
||||||
|
};
|
||||||
|
|
||||||
|
onChange = (value: string) => {
|
||||||
|
this.proxy = value;
|
||||||
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
return <>
|
return (
|
||||||
<h4>HTTPS Proxy</h4>
|
<>
|
||||||
<p>HTTPS Proxy server. Used for communicating with Kubernetes API.</p>
|
<SubTitle title="HTTP Proxy"/>
|
||||||
<Input
|
<p>HTTP Proxy server. Used for communicating with Kubernetes API.</p>
|
||||||
theme="round-black"
|
<Input
|
||||||
className="box grow"
|
theme="round-black"
|
||||||
value={this.proxy}
|
value={this.proxy}
|
||||||
onSubmit={this.updateClusterProxy}
|
onChange={this.onChange}
|
||||||
onChange={this.changeProxyState}
|
onBlur={this.save}
|
||||||
iconRight={this.getIconRight()}
|
placeholder="http://<address>:<port>"
|
||||||
placeholder="https://<address>:<port>"
|
validators={isUrl}
|
||||||
/>
|
/>
|
||||||
</>;
|
</>
|
||||||
}
|
);
|
||||||
|
|
||||||
@autobind()
|
|
||||||
changeProxyState(proxy: string, _e: React.ChangeEvent) {
|
|
||||||
if (this.status === TextInputStatus.UPDATING) {
|
|
||||||
console.log("prevent changing cluster proxy while updating");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.status = this.proxyDiffers(proxy);
|
|
||||||
this.proxy = proxy;
|
|
||||||
}
|
|
||||||
|
|
||||||
proxyDiffers(proxy: string): TextInputStatus {
|
|
||||||
const { httpsProxy = "" } = this.props.cluster.preferences;
|
|
||||||
|
|
||||||
return proxy === httpsProxy ? TextInputStatus.CLEAN : TextInputStatus.DIRTY;
|
|
||||||
}
|
|
||||||
|
|
||||||
getIconRight(): React.ReactNode {
|
|
||||||
switch (this.status) {
|
|
||||||
case TextInputStatus.CLEAN:
|
|
||||||
return null;
|
|
||||||
case TextInputStatus.DIRTY:
|
|
||||||
return <Icon size="16px" material="fiber_manual_record"/>;
|
|
||||||
case TextInputStatus.UPDATED:
|
|
||||||
return <Icon size="16px" className="updated" material="done"/>;
|
|
||||||
case TextInputStatus.UPDATING:
|
|
||||||
return <Spinner />;
|
|
||||||
case TextInputStatus.ERROR:
|
|
||||||
return <Icon id="cluster-proxy-setting-error-icon" size="16px" material="error">
|
|
||||||
<Tooltip targetId="cluster-proxy-setting-error-icon" position={TooltipPosition.TOP}>
|
|
||||||
{this.errorText}
|
|
||||||
</Tooltip>
|
|
||||||
</Icon>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@autobind()
|
|
||||||
updateClusterProxy(proxy: string) {
|
|
||||||
if (this.proxyDiffers(proxy) !== TextInputStatus.DIRTY) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const url = new URL(proxy);
|
|
||||||
|
|
||||||
if (url.protocol !== "https") {
|
|
||||||
this.status = TextInputStatus.ERROR
|
|
||||||
this.errorText= `Proxy's protocol should be "https"`
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (url.port === "") {
|
|
||||||
this.status = TextInputStatus.ERROR
|
|
||||||
this.errorText= "Proxy should include a port"
|
|
||||||
return
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
this.status = TextInputStatus.ERROR
|
|
||||||
this.errorText= "Invalid URL"
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
this.status = TextInputStatus.UPDATING
|
|
||||||
this.props.cluster.preferences.httpsProxy = proxy;
|
|
||||||
this.proxy = proxy;
|
|
||||||
this.status = TextInputStatus.UPDATED
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1,36 +1,36 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { Cluster } from "../../../../main/cluster";
|
|
||||||
import { clusterStore } from "../../../../common/cluster-store"
|
|
||||||
import { workspaceStore } from "../../../../common/workspace-store"
|
|
||||||
import { Select, SelectOption } from "../../../components/select";
|
|
||||||
import { GeneralInputStatus } from "./statuses"
|
|
||||||
import { observable } from "mobx";
|
|
||||||
import { autobind } from "../../../utils";
|
|
||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { workspacesURL } from "../../+workspaces";
|
||||||
|
import { workspaceStore } from "../../../../common/workspace-store";
|
||||||
|
import { Cluster } from "../../../../main/cluster";
|
||||||
|
import { Select } from "../../../components/select";
|
||||||
|
import { SubTitle } from "../../layout/sub-title";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
cluster: Cluster;
|
cluster: Cluster;
|
||||||
}
|
}
|
||||||
|
|
||||||
@observer
|
@observer
|
||||||
export class ClusterWorkspaceSetting extends React.Component<Props> {
|
export class ClusterWorkspaceSetting extends React.Component<Props> {
|
||||||
@observable workspace = this.props.cluster.workspace;
|
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
return <>
|
return (
|
||||||
<h4>Cluster Workspace</h4>
|
<>
|
||||||
<p>Change cluster workspace:</p>
|
<SubTitle title="Cluster Workspace"/>
|
||||||
<Select
|
<p>
|
||||||
value={workspaceStore.currentWorkspaceId}
|
Define cluster{" "}
|
||||||
options={workspaceStore.workspacesList.map(w => ({value: w.id, label: <span>{w.name}</span>}))}
|
<Link to={workspacesURL()}>
|
||||||
onChange={this.changeWorkspace}
|
workspace
|
||||||
/>
|
</Link>.
|
||||||
</>;
|
</p>
|
||||||
}
|
<Select
|
||||||
|
value={this.props.cluster.workspace}
|
||||||
@autobind()
|
onChange={({value}) => this.props.cluster.workspace = value}
|
||||||
changeWorkspace({ value: workspace }: SelectOption<string>) {
|
options={workspaceStore.workspacesList.map(w =>
|
||||||
this.workspace = workspace;
|
({value: w.id, label: w.name})
|
||||||
this.props.cluster.workspace = workspace;
|
)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -0,0 +1,93 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { observable, reaction, comparer } from "mobx";
|
||||||
|
import { observer, disposeOnUnmount } from "mobx-react";
|
||||||
|
import { clusterIpc } from "../../../../common/cluster-ipc";
|
||||||
|
import { Cluster } from "../../../../main/cluster";
|
||||||
|
import { Button } from "../../button";
|
||||||
|
import { Notifications } from "../../notifications";
|
||||||
|
import { Spinner } from "../../spinner";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
cluster: Cluster
|
||||||
|
feature: string
|
||||||
|
}
|
||||||
|
|
||||||
|
@observer
|
||||||
|
export class InstallFeature extends React.Component<Props> {
|
||||||
|
@observable loading = false;
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
disposeOnUnmount(this,
|
||||||
|
reaction(() => this.props.cluster.features[this.props.feature], () => {
|
||||||
|
this.loading = false;
|
||||||
|
}, { equals: comparer.structural })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
getActionButtons() {
|
||||||
|
const { cluster, feature } = this.props;
|
||||||
|
const features = cluster.features[feature];
|
||||||
|
const disabled = !cluster.isAdmin || this.loading;
|
||||||
|
const loadingIcon = this.loading ? <Spinner/> : null;
|
||||||
|
if (!features) return null;
|
||||||
|
return (
|
||||||
|
<div className="flex gaps align-center">
|
||||||
|
{features.canUpgrade &&
|
||||||
|
<Button
|
||||||
|
primary
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={this.runAction(() =>
|
||||||
|
clusterIpc.upgradeFeature.invokeFromRenderer(cluster.id, feature))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Upgrade
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
{features.installed &&
|
||||||
|
<Button
|
||||||
|
accent
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={this.runAction(() =>
|
||||||
|
clusterIpc.uninstallFeature.invokeFromRenderer(cluster.id, feature))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Uninstall
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
{!features.installed && !features.canUpgrade &&
|
||||||
|
<Button
|
||||||
|
primary
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={this.runAction(() =>
|
||||||
|
clusterIpc.installFeature.invokeFromRenderer(cluster.id, feature))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Install
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
{loadingIcon}
|
||||||
|
{!cluster.isAdmin && <span className='admin-note'>Actions can only be performed by admins.</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
runAction(action: () => Promise<any>): () => Promise<void> {
|
||||||
|
return async () => {
|
||||||
|
try {
|
||||||
|
this.loading = true;
|
||||||
|
await action();
|
||||||
|
} catch (err) {
|
||||||
|
Notifications.error(err.toString());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{this.props.children}
|
||||||
|
<div className="button-area">{this.getActionButtons()}</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,109 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { Cluster } from "../../../../main/cluster";
|
|
||||||
import { Button } from "../../button";
|
|
||||||
import { autobind } from "../../../utils";
|
|
||||||
import { Tooltip, TooltipPosition } from "../../tooltip";
|
|
||||||
import { MetricsFeature } from "../../../../features/metrics";
|
|
||||||
import { Spinner } from "../../spinner";
|
|
||||||
import { Icon } from "../../icon";
|
|
||||||
import { clusterIpc } from "../../../../common/cluster-ipc";
|
|
||||||
import { observable } from "mobx";
|
|
||||||
import { ActionStatus } from "./statuses"
|
|
||||||
import { observer } from "mobx-react";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
cluster: Cluster;
|
|
||||||
}
|
|
||||||
|
|
||||||
@observer
|
|
||||||
export class InstallMetrics extends React.Component<Props> {
|
|
||||||
@observable status = ActionStatus.IDLE;
|
|
||||||
@observable errorText?: string;
|
|
||||||
|
|
||||||
render() {
|
|
||||||
return <>
|
|
||||||
<h4>Metrics</h4>
|
|
||||||
<p>
|
|
||||||
User Mode feature enables non-admin users to see namespaces they have access to.
|
|
||||||
This is achieved by configuring RBAC rules so that every authenticated user is granted to list namespaces.
|
|
||||||
</p>
|
|
||||||
<div className="center">
|
|
||||||
{this.getActionButtons()}
|
|
||||||
</div>
|
|
||||||
</>;
|
|
||||||
}
|
|
||||||
|
|
||||||
getStatusIcon(): React.ReactNode {
|
|
||||||
switch (this.status) {
|
|
||||||
case ActionStatus.IDLE:
|
|
||||||
return null;
|
|
||||||
case ActionStatus.PROCESSING:
|
|
||||||
return <Spinner />;
|
|
||||||
case ActionStatus.ERROR:
|
|
||||||
return <Icon size="16px" material="error" title={this.errorText}></Icon>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
getDisabledToolTip(id: string, action: string): React.ReactNode {
|
|
||||||
const { cluster } = this.props;
|
|
||||||
if (cluster.isAdmin) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Tooltip targetId={id} position={TooltipPosition.TOP}>
|
|
||||||
{action} only allowed by admins
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
getActionButtons(): React.ReactNode[] {
|
|
||||||
const { cluster } = this.props
|
|
||||||
const buttons = [];
|
|
||||||
|
|
||||||
if (cluster.features[MetricsFeature.id]?.canUpgrade) {
|
|
||||||
buttons.push(
|
|
||||||
<Button key="upgrade" id="cluster-feature-metrics-upgrade" disabled={!cluster.isAdmin} primary onClick={this.runAction("upgradeFeature")}>
|
|
||||||
Upgrade {this.getStatusIcon()} {this.getDisabledToolTip("cluster-feature-metrics-upgrade", "Upgrading")}
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cluster.features[MetricsFeature.id]?.installed) {
|
|
||||||
buttons.push(
|
|
||||||
<Button key="uninstall" id="cluster-feature-metrics-uninstall" disabled={!cluster.isAdmin} primary onClick={this.runAction("uninstallFeature")}>
|
|
||||||
Uninstall {this.getStatusIcon()} {this.getDisabledToolTip("cluster-feature-metrics-uninstall", "Uninstalling")}
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
buttons.push(
|
|
||||||
<Button key="install" id="cluster-feature-metrics-install" disabled={!cluster.isAdmin} primary onClick={this.runAction("installFeature")}>
|
|
||||||
Install {this.getStatusIcon()} {this.getDisabledToolTip("cluster-feature-metrics-install", "Installing")}
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return buttons;
|
|
||||||
}
|
|
||||||
|
|
||||||
runAction(action: keyof typeof clusterIpc): () => Promise<void> {
|
|
||||||
return async () => {
|
|
||||||
const { cluster } = this.props;
|
|
||||||
console.log(`running ${action} ${MetricsFeature.id} onto ${cluster.preferences.clusterName}`);
|
|
||||||
|
|
||||||
try {
|
|
||||||
this.status = ActionStatus.PROCESSING
|
|
||||||
await clusterIpc[action].invokeFromRenderer(cluster.id, MetricsFeature.id);
|
|
||||||
try {
|
|
||||||
await cluster.refresh();
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
}
|
|
||||||
this.status = ActionStatus.IDLE
|
|
||||||
} catch (err) {
|
|
||||||
this.status = ActionStatus.ERROR
|
|
||||||
this.errorText = err.toString()
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,108 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { Cluster } from "../../../../main/cluster";
|
|
||||||
import { Button } from "../../button";
|
|
||||||
import { autobind } from "../../../utils";
|
|
||||||
import { Tooltip, TooltipPosition } from "../../tooltip";
|
|
||||||
import { Spinner } from "../../spinner";
|
|
||||||
import { Icon } from "../../icon";
|
|
||||||
import { UserModeFeature } from "../../../../features/user-mode";
|
|
||||||
import { clusterIpc } from "../../../../common/cluster-ipc";
|
|
||||||
import { observable } from "mobx";
|
|
||||||
import { ActionStatus } from "./statuses"
|
|
||||||
import { observer } from "mobx-react";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
cluster: Cluster;
|
|
||||||
}
|
|
||||||
|
|
||||||
@observer
|
|
||||||
export class InstallUserMode extends React.Component<Props> {
|
|
||||||
@observable status = ActionStatus.IDLE;
|
|
||||||
@observable errorText?: string;
|
|
||||||
|
|
||||||
render() {
|
|
||||||
return <>
|
|
||||||
<h4>User Mode</h4>
|
|
||||||
<p>
|
|
||||||
User Mode feature enables non-admin users to see namespaces they have access to.
|
|
||||||
This is achieved by configuring RBAC rules so that every authenticated user is granted to list namespaces.
|
|
||||||
</p>
|
|
||||||
<div className="center">
|
|
||||||
{this.getActionButtons()}
|
|
||||||
</div>
|
|
||||||
</>;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
getStatusIcon(): React.ReactNode {
|
|
||||||
switch (this.status) {
|
|
||||||
case ActionStatus.IDLE:
|
|
||||||
return null;
|
|
||||||
case ActionStatus.PROCESSING:
|
|
||||||
return <Spinner key="spinner" />;
|
|
||||||
case ActionStatus.ERROR:
|
|
||||||
return <Icon key="error" size="16px" material="error" title={this.errorText}></Icon>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
getDisabledToolTip(id: string, action: string): React.ReactNode {
|
|
||||||
const { cluster } = this.props;
|
|
||||||
if (cluster.isAdmin) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return <Tooltip targetId={id} position={TooltipPosition.TOP}>
|
|
||||||
{action} only allowed by admins
|
|
||||||
</Tooltip>;
|
|
||||||
}
|
|
||||||
|
|
||||||
getActionButtons(): React.ReactNode[] {
|
|
||||||
const { cluster } = this.props
|
|
||||||
const buttons = [];
|
|
||||||
|
|
||||||
if (cluster.features[UserModeFeature.id]?.canUpgrade) {
|
|
||||||
buttons.push(
|
|
||||||
<Button key="upgrade" id="cluster-feature-user-mode-upgrade" disabled={!cluster.isAdmin} primary onClick={this.runAction("upgradeFeature")}>
|
|
||||||
Upgrade {this.getStatusIcon()} {this.getDisabledToolTip("cluster-feature-user-mode-upgrade", "Upgrading")}
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cluster.features[UserModeFeature.id]?.installed) {
|
|
||||||
buttons.push(
|
|
||||||
<Button key="uninstall" id="cluster-feature-user-mode-uninstall" disabled={!cluster.isAdmin} primary onClick={this.runAction("uninstallFeature")}>
|
|
||||||
Uninstall {this.getStatusIcon()} {this.getDisabledToolTip("cluster-feature-user-mode-uninstall", "Uninstalling")}
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
buttons.push(
|
|
||||||
<Button key="install" id="cluster-feature-user-mode-install" disabled={!cluster.isAdmin} primary onClick={this.runAction("installFeature")}>
|
|
||||||
Install {this.getStatusIcon()} {this.getDisabledToolTip("cluster-feature-user-mode-install", "Installing")}
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return buttons;
|
|
||||||
}
|
|
||||||
|
|
||||||
runAction(action: keyof typeof clusterIpc): () => Promise<void> {
|
|
||||||
return async () => {
|
|
||||||
const { cluster } = this.props;
|
|
||||||
console.log(`running ${action} ${UserModeFeature.id} onto ${cluster.preferences.clusterName}`);
|
|
||||||
|
|
||||||
try {
|
|
||||||
this.status = ActionStatus.PROCESSING
|
|
||||||
await clusterIpc[action].invokeFromRenderer(cluster.id, UserModeFeature.id);
|
|
||||||
try {
|
|
||||||
await cluster.refresh();
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
}
|
|
||||||
this.status = ActionStatus.IDLE
|
|
||||||
} catch (err) {
|
|
||||||
this.status = ActionStatus.ERROR
|
|
||||||
this.errorText = err.toString()
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,63 +1,37 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { Cluster } from "../../../../main/cluster";
|
|
||||||
import { Button } from "../../button";
|
|
||||||
import { autobind } from "../../../utils";
|
|
||||||
import { Spinner } from "../../spinner";
|
|
||||||
import { Icon } from "../../icon";
|
|
||||||
import { ConfirmDialog } from "../../confirm-dialog";
|
|
||||||
import { Trans } from "@lingui/macro";
|
import { Trans } from "@lingui/macro";
|
||||||
|
import { observer } from "mobx-react";
|
||||||
import { clusterIpc } from "../../../../common/cluster-ipc";
|
import { clusterIpc } from "../../../../common/cluster-ipc";
|
||||||
import { clusterStore } from "../../../../common/cluster-store";
|
import { clusterStore } from "../../../../common/cluster-store";
|
||||||
import { observable } from "mobx";
|
import { Cluster } from "../../../../main/cluster";
|
||||||
import { observer } from "mobx-react";
|
import { autobind } from "../../../utils";
|
||||||
import { RemovalStatus } from "./statuses"
|
import { Button } from "../../button";
|
||||||
|
import { ConfirmDialog } from "../../confirm-dialog";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
cluster: Cluster;
|
cluster: Cluster;
|
||||||
}
|
}
|
||||||
|
|
||||||
@observer
|
@observer
|
||||||
export class RemoveClusterButton extends React.Component<Props> {
|
export class RemoveClusterButton extends React.Component<Props> {
|
||||||
@observable status = RemovalStatus.PRESENT;
|
@autobind()
|
||||||
@observable errorText?: string;
|
|
||||||
|
|
||||||
render() {
|
|
||||||
return (
|
|
||||||
<div className="center">
|
|
||||||
<Button accent onClick={this.confirmRemoveCluster}>Remove Cluster {this.getStatusIcon()}</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
getStatusIcon(): React.ReactNode {
|
|
||||||
switch (this.status) {
|
|
||||||
case RemovalStatus.PRESENT:
|
|
||||||
return null;
|
|
||||||
case RemovalStatus.PROCESSING:
|
|
||||||
return <Spinner />;
|
|
||||||
case RemovalStatus.ERROR:
|
|
||||||
return <Icon size="16px" material="error" title={this.errorText}></Icon>;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@autobind()
|
|
||||||
confirmRemoveCluster() {
|
confirmRemoveCluster() {
|
||||||
const { cluster } = this.props;
|
const { cluster } = this.props;
|
||||||
|
|
||||||
ConfirmDialog.open({
|
ConfirmDialog.open({
|
||||||
message: <p>Are you sure you want to remove <b>{cluster.preferences.clusterName}</b> from Lens?</p>,
|
message: <p>Are you sure you want to remove <b>{cluster.preferences.clusterName}</b> from Lens?</p>,
|
||||||
labelOk: <Trans>Yes</Trans>,
|
labelOk: <Trans>Yes</Trans>,
|
||||||
labelCancel: <Trans>No</Trans>,
|
labelCancel: <Trans>No</Trans>,
|
||||||
ok: async () => {
|
ok: async () => {
|
||||||
try {
|
await clusterStore.removeById(cluster.id);
|
||||||
this.status = RemovalStatus.PROCESSING;
|
|
||||||
await clusterIpc.disconnect.invokeFromRenderer(cluster.id);
|
|
||||||
await clusterStore.removeById(cluster.id);
|
|
||||||
} catch (err) {
|
|
||||||
this.status = RemovalStatus.ERROR;
|
|
||||||
this.errorText = err.toString();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return (
|
||||||
|
<Button accent onClick={this.confirmRemoveCluster} className="button-area">
|
||||||
|
Remove Cluster
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -1,24 +0,0 @@
|
|||||||
export enum TextInputStatus {
|
|
||||||
CLEAN = "clean",
|
|
||||||
DIRTY = "dirty",
|
|
||||||
UPDATING = "updating",
|
|
||||||
ERROR = "error",
|
|
||||||
UPDATED = "updated",
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum GeneralInputStatus {
|
|
||||||
CLEAN = "clean",
|
|
||||||
ERROR = "error",
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum ActionStatus {
|
|
||||||
IDLE = "idle",
|
|
||||||
PROCESSING = "processing",
|
|
||||||
ERROR = "error"
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum RemovalStatus {
|
|
||||||
PRESENT = "present",
|
|
||||||
PROCESSING = "processing",
|
|
||||||
ERROR = "error",
|
|
||||||
}
|
|
||||||
@ -1,7 +1,9 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { Cluster } from "../../../main/cluster";
|
import { Cluster } from "../../../main/cluster";
|
||||||
import { InstallMetrics } from "./components/install-metrics";
|
import { InstallFeature } from "./components/install-feature";
|
||||||
import { InstallUserMode } from "./components/install-user-mode";
|
import { SubTitle } from "../layout/sub-title";
|
||||||
|
import { MetricsFeature } from "../../../features/metrics";
|
||||||
|
import { UserModeFeature } from "../../../features/user-mode";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
cluster: Cluster;
|
cluster: Cluster;
|
||||||
@ -11,10 +13,30 @@ export class Features extends React.Component<Props> {
|
|||||||
render() {
|
render() {
|
||||||
const { cluster } = this.props;
|
const { cluster } = this.props;
|
||||||
|
|
||||||
return <div>
|
return (
|
||||||
<h2>Features</h2>
|
<div>
|
||||||
<InstallMetrics cluster={cluster}/>
|
<h2>Features</h2>
|
||||||
<InstallUserMode cluster={cluster}/>
|
<InstallFeature cluster={cluster} feature={MetricsFeature.id}>
|
||||||
</div>;
|
<>
|
||||||
|
<SubTitle title="Metrics"/>
|
||||||
|
<p>
|
||||||
|
Enable timeseries data visualization (Prometheus stack) for your cluster.
|
||||||
|
Install this only if you don't have existing Prometheus stack installed.
|
||||||
|
You can see preview of manifests{" "}
|
||||||
|
<a href="https://github.com/lensapp/lens/tree/master/src/features/metrics" target="_blank">here</a>.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
</InstallFeature>
|
||||||
|
<InstallFeature cluster={cluster} feature={UserModeFeature.id}>
|
||||||
|
<>
|
||||||
|
<SubTitle title="User Mode"/>
|
||||||
|
<p>
|
||||||
|
User Mode feature enables non-admin users to see namespaces they have access to.{" "}
|
||||||
|
This is achieved by configuring RBAC rules so that every authenticated user is granted to list namespaces.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
</InstallFeature>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -15,8 +15,6 @@ export class General extends React.Component<Props> {
|
|||||||
render() {
|
render() {
|
||||||
return <div>
|
return <div>
|
||||||
<h2>General</h2>
|
<h2>General</h2>
|
||||||
<hr/>
|
|
||||||
|
|
||||||
<ClusterNameSetting cluster={this.props.cluster} />
|
<ClusterNameSetting cluster={this.props.cluster} />
|
||||||
<ClusterWorkspaceSetting cluster={this.props.cluster} />
|
<ClusterWorkspaceSetting cluster={this.props.cluster} />
|
||||||
<ClusterIconSetting cluster={this.props.cluster} />
|
<ClusterIconSetting cluster={this.props.cluster} />
|
||||||
|
|||||||
@ -3,16 +3,18 @@ import { Cluster } from "../../../main/cluster";
|
|||||||
import { RemoveClusterButton } from "./components/remove-cluster-button";
|
import { RemoveClusterButton } from "./components/remove-cluster-button";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
cluster: Cluster;
|
cluster: Cluster;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Removal extends React.Component<Props> {
|
export class Removal extends React.Component<Props> {
|
||||||
render() {
|
render() {
|
||||||
const { cluster } = this.props;
|
const { cluster } = this.props;
|
||||||
|
|
||||||
return <div>
|
return (
|
||||||
<h2>Removal</h2>
|
<div>
|
||||||
<RemoveClusterButton cluster={cluster} />
|
<h2>Removal</h2>
|
||||||
</div>;
|
<RemoveClusterButton cluster={cluster} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1,41 +1,40 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { Spinner } from "../spinner";
|
|
||||||
import { Cluster } from "../../../main/cluster";
|
import { Cluster } from "../../../main/cluster";
|
||||||
|
import { SubTitle } from "../layout/sub-title";
|
||||||
|
import { Table, TableCell, TableRow } from "../table";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
cluster: Cluster;
|
cluster: Cluster;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Status extends React.Component<Props> {
|
export class Status extends React.Component<Props> {
|
||||||
renderStatusRows(): JSX.Element[] {
|
renderStatusRows() {
|
||||||
const { cluster } = this.props;
|
const { cluster } = this.props;
|
||||||
|
const rows = [
|
||||||
const rows: [string, React.ReactNode][] = [
|
["Online Status", cluster.online ? "online" : `offline (${cluster.failureReason || "unknown reason"})`],
|
||||||
["Online Status", cluster.online ? "online" : `offline (${cluster.failureReason || "unknown reason"}`],
|
|
||||||
["Distribution", cluster.distribution],
|
["Distribution", cluster.distribution],
|
||||||
["Kerbel Version", cluster.version],
|
["Kerbel Version", cluster.version],
|
||||||
["API Address", cluster.apiUrl],
|
["API Address", cluster.apiUrl],
|
||||||
|
["Nodes Count", cluster.nodes || "0"]
|
||||||
];
|
];
|
||||||
|
return (
|
||||||
if (cluster.nodes > 0) {
|
<Table scrollable={false}>
|
||||||
rows.push(["Nodes Count", cluster.nodes]);
|
{rows.map(([name, value]) => {
|
||||||
}
|
return (
|
||||||
|
<TableRow key={name}>
|
||||||
return rows
|
<TableCell>{name}</TableCell>
|
||||||
.map(([header, value]) => [
|
<TableCell className="value">{value}</TableCell>
|
||||||
<h5 key={header+"-header"}>{header}</h5>,
|
</TableRow>
|
||||||
<span key={header + "-value"}>{value}</span>
|
);
|
||||||
])
|
})}
|
||||||
.flat();
|
</Table>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { cluster } = this.props;
|
|
||||||
|
|
||||||
return <div>
|
return <div>
|
||||||
<h2>Status</h2>
|
<h2>Status</h2>
|
||||||
<hr/>
|
<SubTitle title="Cluster Status"/>
|
||||||
<h4>Cluster status</h4>
|
|
||||||
<p>
|
<p>
|
||||||
Cluster status information including: detected distribution, kernel version, and online status.
|
Cluster status information including: detected distribution, kernel version, and online status.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import kebabCase from "lodash/kebabCase";
|
|||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
import { Trans } from "@lingui/macro";
|
import { Trans } from "@lingui/macro";
|
||||||
import { DrawerItem, DrawerTitle } from "../drawer";
|
import { DrawerItem, DrawerTitle } from "../drawer";
|
||||||
import { cpuUnitsToNumber, cssNames, unitsToBytes } from "../../utils";
|
import { cpuUnitsToNumber, cssNames, unitsToBytes, metricUnitsToNumber } from "../../utils";
|
||||||
import { KubeObjectDetailsProps } from "../kube-object";
|
import { KubeObjectDetailsProps } from "../kube-object";
|
||||||
import { ResourceQuota, resourceQuotaApi } from "../../api/endpoints/resource-quota.api";
|
import { ResourceQuota, resourceQuotaApi } from "../../api/endpoints/resource-quota.api";
|
||||||
import { LineProgress } from "../line-progress";
|
import { LineProgress } from "../line-progress";
|
||||||
@ -15,24 +15,30 @@ import { KubeObjectMeta } from "../kube-object/kube-object-meta";
|
|||||||
interface Props extends KubeObjectDetailsProps<ResourceQuota> {
|
interface Props extends KubeObjectDetailsProps<ResourceQuota> {
|
||||||
}
|
}
|
||||||
|
|
||||||
@observer
|
const onlyNumbers = /$[0-9]*^/g;
|
||||||
export class ResourceQuotaDetails extends React.Component<Props> {
|
|
||||||
renderQuotas = (quota: ResourceQuota) => {
|
function transformUnit(name: string, value: string): number {
|
||||||
const { hard, used } = quota.status
|
if (name.includes("memory") || name.includes("storage")) {
|
||||||
if (!hard || !used) return null
|
return unitsToBytes(value)
|
||||||
const transformUnit = (name: string, value: string) => {
|
}
|
||||||
if (name.includes("memory") || name.includes("storage")) {
|
|
||||||
return unitsToBytes(value)
|
if (name.includes("cpu")) {
|
||||||
}
|
return cpuUnitsToNumber(value)
|
||||||
if (name.includes("cpu")) {
|
}
|
||||||
return cpuUnitsToNumber(value)
|
|
||||||
}
|
return metricUnitsToNumber(value);
|
||||||
return parseInt(value)
|
}
|
||||||
}
|
|
||||||
return Object.entries(hard).map(([name, value]) => {
|
function renderQuotas(quota: ResourceQuota): JSX.Element[] {
|
||||||
if (!used[name]) return null
|
const { hard = {}, used = {} } = quota.status
|
||||||
|
|
||||||
|
return Object.entries(hard)
|
||||||
|
.filter(([name]) => used[name])
|
||||||
|
.map(([name, value]) => {
|
||||||
const current = transformUnit(name, used[name])
|
const current = transformUnit(name, used[name])
|
||||||
const max = transformUnit(name, value)
|
const max = transformUnit(name, value)
|
||||||
|
const usage = max === 0 ? 100 : Math.ceil(current / max * 100); // special case 0 max as always 100% usage
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={name} className={cssNames("param", kebabCase(name))}>
|
<div key={name} className={cssNames("param", kebabCase(name))}>
|
||||||
<span className="title">{name}</span>
|
<span className="title">{name}</span>
|
||||||
@ -41,14 +47,16 @@ export class ResourceQuotaDetails extends React.Component<Props> {
|
|||||||
max={max}
|
max={max}
|
||||||
value={current}
|
value={current}
|
||||||
tooltip={
|
tooltip={
|
||||||
<p><Trans>Set</Trans>: {value}. <Trans>Used</Trans>: {Math.ceil(current / max * 100) + "%"}</p>
|
<p><Trans>Set</Trans>: {value}. <Trans>Usage</Trans>: {usage + "%"}</p>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@observer
|
||||||
|
export class ResourceQuotaDetails extends React.Component<Props> {
|
||||||
render() {
|
render() {
|
||||||
const { object: quota } = this.props;
|
const { object: quota } = this.props;
|
||||||
if (!quota) return null;
|
if (!quota) return null;
|
||||||
@ -57,7 +65,7 @@ export class ResourceQuotaDetails extends React.Component<Props> {
|
|||||||
<KubeObjectMeta object={quota}/>
|
<KubeObjectMeta object={quota}/>
|
||||||
|
|
||||||
<DrawerItem name={<Trans>Quotas</Trans>} className="quota-list">
|
<DrawerItem name={<Trans>Quotas</Trans>} className="quota-list">
|
||||||
{this.renderQuotas(quota)}
|
{renderQuotas(quota)}
|
||||||
</DrawerItem>
|
</DrawerItem>
|
||||||
|
|
||||||
{quota.getScopeSelector().length > 0 && (
|
{quota.getScopeSelector().length > 0 && (
|
||||||
|
|||||||
@ -11,7 +11,7 @@ import { Service, serviceApi, endpointApi } from "../../api/endpoints";
|
|||||||
import { _i18n } from "../../i18n";
|
import { _i18n } from "../../i18n";
|
||||||
import { apiManager } from "../../api/api-manager";
|
import { apiManager } from "../../api/api-manager";
|
||||||
import { KubeObjectMeta } from "../kube-object/kube-object-meta";
|
import { KubeObjectMeta } from "../kube-object/kube-object-meta";
|
||||||
import { ServicePorts } from "./service-ports";
|
import { ServicePortComponent } from "./service-port-component";
|
||||||
import { endpointStore } from "../+network-endpoints/endpoints.store";
|
import { endpointStore } from "../+network-endpoints/endpoints.store";
|
||||||
import { ServiceDetailsEndpoint } from "./service-details-endpoint";
|
import { ServiceDetailsEndpoint } from "./service-details-endpoint";
|
||||||
|
|
||||||
@ -61,7 +61,13 @@ export class ServiceDetails extends React.Component<Props> {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<DrawerItem name={<Trans>Ports</Trans>}>
|
<DrawerItem name={<Trans>Ports</Trans>}>
|
||||||
<ServicePorts service={service}/>
|
<div>
|
||||||
|
{
|
||||||
|
service.getPorts().map((port) => (
|
||||||
|
<ServicePortComponent service={service} port={port} key={port.toString()}/>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</div>
|
||||||
</DrawerItem>
|
</DrawerItem>
|
||||||
|
|
||||||
{spec.type === "LoadBalancer" && spec.loadBalancerIP && (
|
{spec.type === "LoadBalancer" && spec.loadBalancerIP && (
|
||||||
|
|||||||
@ -0,0 +1,22 @@
|
|||||||
|
.ServicePortComponent {
|
||||||
|
&.waiting {
|
||||||
|
opacity: 0.5;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:not(:last-child) {
|
||||||
|
margin-bottom: $margin;
|
||||||
|
}
|
||||||
|
|
||||||
|
span {
|
||||||
|
cursor: pointer;
|
||||||
|
color: $primary;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.Spinner {
|
||||||
|
--spinner-size: #{$unit * 2};
|
||||||
|
margin-left: $margin;
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,48 @@
|
|||||||
|
import "./service-port-component.scss"
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { observer } from "mobx-react";
|
||||||
|
import { t } from "@lingui/macro";
|
||||||
|
import { Service, ServicePort } from "../../api/endpoints";
|
||||||
|
import { _i18n } from "../../i18n";
|
||||||
|
import { apiBase } from "../../api"
|
||||||
|
import { observable } from "mobx";
|
||||||
|
import { cssNames } from "../../utils";
|
||||||
|
import { Notifications } from "../notifications";
|
||||||
|
import { Spinner } from "../spinner"
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
service: Service;
|
||||||
|
port: ServicePort;
|
||||||
|
}
|
||||||
|
|
||||||
|
@observer
|
||||||
|
export class ServicePortComponent extends React.Component<Props> {
|
||||||
|
@observable waiting = false;
|
||||||
|
|
||||||
|
async portForward() {
|
||||||
|
const { service, port } = this.props;
|
||||||
|
this.waiting = true;
|
||||||
|
try {
|
||||||
|
await apiBase.post(`/pods/${service.getNs()}/service/${service.getName()}/port-forward/${port.port}`, {})
|
||||||
|
} catch(error) {
|
||||||
|
Notifications.error(error);
|
||||||
|
} finally {
|
||||||
|
this.waiting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { port } = this.props;
|
||||||
|
return (
|
||||||
|
<div className={cssNames("ServicePortComponent", { waiting: this.waiting })}>
|
||||||
|
<span title={_i18n._(t`Open in a browser`)} onClick={() => this.portForward() }>
|
||||||
|
{port.toString()}
|
||||||
|
{this.waiting && (
|
||||||
|
<Spinner />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,24 +0,0 @@
|
|||||||
.ServicePorts {
|
|
||||||
&.waiting {
|
|
||||||
opacity: 0.5;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
p {
|
|
||||||
&:not(:last-child) {
|
|
||||||
margin-bottom: $margin;
|
|
||||||
}
|
|
||||||
|
|
||||||
span {
|
|
||||||
cursor: pointer;
|
|
||||||
color: $primary;
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.Spinner {
|
|
||||||
--spinner-size: #{$unit * 2};
|
|
||||||
margin-left: $margin;
|
|
||||||
position: absolute;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,54 +0,0 @@
|
|||||||
import "./service-ports.scss"
|
|
||||||
|
|
||||||
import React from "react";
|
|
||||||
import { observer } from "mobx-react";
|
|
||||||
import { t } from "@lingui/macro";
|
|
||||||
import { Service, ServicePort } from "../../api/endpoints";
|
|
||||||
import { _i18n } from "../../i18n";
|
|
||||||
import { apiBase } from "../../api"
|
|
||||||
import { observable } from "mobx";
|
|
||||||
import { cssNames } from "../../utils";
|
|
||||||
import { Notifications } from "../notifications";
|
|
||||||
import { Spinner } from "../spinner"
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
service: Service;
|
|
||||||
}
|
|
||||||
|
|
||||||
@observer
|
|
||||||
export class ServicePorts extends React.Component<Props> {
|
|
||||||
@observable waiting = false;
|
|
||||||
|
|
||||||
async portForward(port: ServicePort) {
|
|
||||||
const { service } = this.props;
|
|
||||||
this.waiting = true;
|
|
||||||
apiBase.post(`/services/${service.getNs()}/${service.getName()}/port-forward/${port.port}`, {})
|
|
||||||
.catch(error => {
|
|
||||||
Notifications.error(error);
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
this.waiting = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const { service } = this.props;
|
|
||||||
return (
|
|
||||||
<div className={cssNames("ServicePorts", { waiting: this.waiting })}>
|
|
||||||
{
|
|
||||||
service.getPorts().map((port) => {
|
|
||||||
return(
|
|
||||||
<p key={port.toString()}>
|
|
||||||
<span title={_i18n._(t`Open in a browser`)} onClick={() => this.portForward(port) }>
|
|
||||||
{port.toString()}
|
|
||||||
{this.waiting && (
|
|
||||||
<Spinner />
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,23 +1,51 @@
|
|||||||
.Preferences {
|
.Preferences {
|
||||||
h2 {
|
position: fixed!important; // Allows to cover ClustersMenu
|
||||||
&:not(:first-child) {
|
z-index: 1;
|
||||||
margin-top: $padding * 3;
|
|
||||||
|
.WizardLayout {
|
||||||
|
grid-template-columns: unset;
|
||||||
|
grid-template-rows: 76px 1fr;
|
||||||
|
padding: 0;
|
||||||
|
|
||||||
|
.content-col {
|
||||||
|
background-color: transparent;
|
||||||
|
padding: $padding * 8 0;
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin-bottom: $margin * 2;
|
||||||
|
|
||||||
|
&:not(:first-child) {
|
||||||
|
margin-top: $margin * 3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.repos {
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
.Badge {
|
||||||
|
display: flex;
|
||||||
|
margin: 0;
|
||||||
|
margin-bottom: 1px;
|
||||||
|
padding: $padding $padding * 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
margin-top: -$margin;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-block {
|
.is-mac & {
|
||||||
--flex-gap: #{$padding};
|
.WizardLayout .head-col {
|
||||||
|
padding-top: 32px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.repos {
|
.Select {
|
||||||
--flex-gap: #{$padding};
|
&__control {
|
||||||
|
box-shadow: 0 0 0 1px $borderFaintColor;
|
||||||
> .title {
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
.Badge {
|
|
||||||
margin: $padding / 2;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1,5 +1,5 @@
|
|||||||
import "./preferences.scss"
|
import "./preferences.scss"
|
||||||
import React, { Fragment } from "react";
|
import React from "react";
|
||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
import { action, computed, observable } from "mobx";
|
import { action, computed, observable } from "mobx";
|
||||||
import { t, Trans } from "@lingui/macro";
|
import { t, Trans } from "@lingui/macro";
|
||||||
@ -15,11 +15,12 @@ import { Notifications } from "../notifications";
|
|||||||
import { Badge } from "../badge";
|
import { Badge } from "../badge";
|
||||||
import { Spinner } from "../spinner";
|
import { Spinner } from "../spinner";
|
||||||
import { themeStore } from "../../theme.store";
|
import { themeStore } from "../../theme.store";
|
||||||
|
import { history } from "../../navigation";
|
||||||
|
import { Tooltip } from "../tooltip";
|
||||||
|
|
||||||
@observer
|
@observer
|
||||||
export class Preferences extends React.Component {
|
export class Preferences extends React.Component {
|
||||||
@observable helmLoading = false;
|
@observable helmLoading = false;
|
||||||
@observable helmUpdating = false;
|
|
||||||
@observable helmRepos: HelmRepo[] = [];
|
@observable helmRepos: HelmRepo[] = [];
|
||||||
@observable helmAddedRepos = observable.map<string, HelmRepo>();
|
@observable helmAddedRepos = observable.map<string, HelmRepo>();
|
||||||
|
|
||||||
@ -88,9 +89,9 @@ export class Preferences extends React.Component {
|
|||||||
Notifications.ok(<Trans>Helm branch <b>{repo.name}</b> already in use</Trans>)
|
Notifications.ok(<Trans>Helm branch <b>{repo.name}</b> already in use</Trans>)
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.helmUpdating = false;
|
this.helmLoading = true;
|
||||||
await this.addRepo(repo);
|
await this.addRepo(repo);
|
||||||
this.helmUpdating = false;
|
this.helmLoading = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
formatHelmOptionLabel = ({ value: repo }: SelectOption<HelmRepo>) => {
|
formatHelmOptionLabel = ({ value: repo }: SelectOption<HelmRepo>) => {
|
||||||
@ -103,104 +104,95 @@ export class Preferences extends React.Component {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
renderInfo() {
|
|
||||||
return (
|
|
||||||
<Fragment>
|
|
||||||
<h2>
|
|
||||||
<Trans>Preferences</Trans>
|
|
||||||
</h2>
|
|
||||||
<div className="info-block flex gaps align-flex-start">
|
|
||||||
<Icon small material="info"/>
|
|
||||||
<p>
|
|
||||||
<Trans>Lens Global Settings</Trans> (<Trans>applicable to all clusters</Trans>)
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</Fragment>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { preferences } = userStore;
|
const { preferences } = userStore;
|
||||||
|
const header = (
|
||||||
|
<>
|
||||||
|
<h2>Preferences</h2>
|
||||||
|
<Icon material="close" big onClick={history.goBack}/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<WizardLayout className="Preferences" infoPanel={this.renderInfo()}>
|
<div className="Preferences">
|
||||||
<h2><Trans>Color Theme</Trans></h2>
|
<WizardLayout header={header} centered>
|
||||||
<Select
|
<h2><Trans>Color Theme</Trans></h2>
|
||||||
options={this.themeOptions}
|
<Select
|
||||||
value={preferences.colorTheme}
|
options={this.themeOptions}
|
||||||
onChange={({ value }: SelectOption) => preferences.colorTheme = value}
|
value={preferences.colorTheme}
|
||||||
/>
|
onChange={({ value }: SelectOption) => preferences.colorTheme = value}
|
||||||
|
/>
|
||||||
|
|
||||||
<h2><Trans>Download Mirror</Trans></h2>
|
<h2><Trans>Download Mirror</Trans></h2>
|
||||||
<Select
|
<Select
|
||||||
placeholder={<Trans>Download mirror for kubectl</Trans>}
|
placeholder={<Trans>Download mirror for kubectl</Trans>}
|
||||||
options={this.downloadMirrorOptions}
|
options={this.downloadMirrorOptions}
|
||||||
value={preferences.downloadMirror}
|
value={preferences.downloadMirror}
|
||||||
onChange={({ value }: SelectOption) => preferences.downloadMirror = value}
|
onChange={({ value }: SelectOption) => preferences.downloadMirror = value}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<h2><Trans>Helm</Trans></h2>
|
<h2><Trans>Helm</Trans></h2>
|
||||||
<Select
|
<Select
|
||||||
placeholder={<Trans>Repositories</Trans>}
|
placeholder={<Trans>Repositories</Trans>}
|
||||||
isLoading={this.helmLoading}
|
isLoading={this.helmLoading}
|
||||||
isDisabled={this.helmUpdating}
|
isDisabled={this.helmLoading}
|
||||||
options={this.helmOptions}
|
options={this.helmOptions}
|
||||||
onChange={this.onRepoSelect}
|
onChange={this.onRepoSelect}
|
||||||
formatOptionLabel={this.formatHelmOptionLabel}
|
formatOptionLabel={this.formatHelmOptionLabel}
|
||||||
controlShouldRenderValue={false}
|
controlShouldRenderValue={false}
|
||||||
/>
|
/>
|
||||||
<div className="repos flex gaps align-center">
|
<div className="repos flex gaps column">
|
||||||
<div className="title">
|
|
||||||
<Trans>Added repos:</Trans>
|
|
||||||
</div>
|
|
||||||
<div className="repos-list">
|
|
||||||
{this.helmLoading && <Spinner/>}
|
|
||||||
{Array.from(this.helmAddedRepos).map(([name, repo]) => {
|
{Array.from(this.helmAddedRepos).map(([name, repo]) => {
|
||||||
|
const tooltipId = `message-${name}`;
|
||||||
return (
|
return (
|
||||||
<Badge key={name} className="added-repo flex gaps align-center" title={repo.url}>
|
<Badge key={name} className="added-repo flex gaps align-center justify-space-between">
|
||||||
<span className="repo">{name}</span>
|
<span id={tooltipId} className="repo">{name}</span>
|
||||||
<Icon
|
<Icon
|
||||||
material="remove_circle_outline"
|
material="delete"
|
||||||
onClick={() => this.removeRepo(repo)}
|
onClick={() => this.removeRepo(repo)}
|
||||||
tooltip={<Trans>Remove</Trans>}
|
tooltip={<Trans>Remove</Trans>}
|
||||||
/>
|
/>
|
||||||
|
<Tooltip targetId={tooltipId} formatters={{ narrow: true }}>
|
||||||
|
{repo.url}
|
||||||
|
</Tooltip>
|
||||||
</Badge>
|
</Badge>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<h2><Trans>HTTP Proxy</Trans></h2>
|
<h2><Trans>HTTP Proxy</Trans></h2>
|
||||||
<Input
|
<Input
|
||||||
placeholder={_i18n._(t`Type HTTP proxy url (example: http://proxy.acme.org:8080)`)}
|
theme="round-black"
|
||||||
value={preferences.httpsProxy || ""}
|
placeholder={_i18n._(t`Type HTTP proxy url (example: http://proxy.acme.org:8080)`)}
|
||||||
onChange={v => preferences.httpsProxy = v}
|
value={preferences.httpsProxy || ""}
|
||||||
/>
|
onChange={v => preferences.httpsProxy = v}
|
||||||
<small className="hint">
|
/>
|
||||||
<Trans>Proxy is used only for non-cluster communication.</Trans>
|
<small className="hint">
|
||||||
</small>
|
<Trans>Proxy is used only for non-cluster communication.</Trans>
|
||||||
|
</small>
|
||||||
|
|
||||||
<h2><Trans>Certificate Trust</Trans></h2>
|
<h2><Trans>Certificate Trust</Trans></h2>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
label={<Trans>Allow untrusted Certificate Authorities</Trans>}
|
label={<Trans>Allow untrusted Certificate Authorities</Trans>}
|
||||||
value={preferences.allowUntrustedCAs}
|
value={preferences.allowUntrustedCAs}
|
||||||
onChange={v => preferences.allowUntrustedCAs = v}
|
onChange={v => preferences.allowUntrustedCAs = v}
|
||||||
/>
|
/>
|
||||||
<small className="hint">
|
<small className="hint">
|
||||||
<Trans>This will make Lens to trust ANY certificate authority without any validations.</Trans>{" "}
|
<Trans>This will make Lens to trust ANY certificate authority without any validations.</Trans>{" "}
|
||||||
<Trans>Needed with some corporate proxies that do certificate re-writing.</Trans>{" "}
|
<Trans>Needed with some corporate proxies that do certificate re-writing.</Trans>{" "}
|
||||||
<Trans>Does not affect cluster communications!</Trans>
|
<Trans>Does not affect cluster communications!</Trans>
|
||||||
</small>
|
</small>
|
||||||
|
|
||||||
<h2><Trans>Telemetry & Usage Tracking</Trans></h2>
|
<h2><Trans>Telemetry & Usage Tracking</Trans></h2>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
label={<Trans>Allow telemetry & usage tracking</Trans>}
|
label={<Trans>Allow telemetry & usage tracking</Trans>}
|
||||||
value={preferences.allowTelemetry}
|
value={preferences.allowTelemetry}
|
||||||
onChange={v => preferences.allowTelemetry = v}
|
onChange={v => preferences.allowTelemetry = v}
|
||||||
/>
|
/>
|
||||||
<small className="hint">
|
<small className="hint">
|
||||||
<Trans>Telemetry & usage data is collected to continuously improve the Lens experience.</Trans>
|
<Trans>Telemetry & usage data is collected to continuously improve the Lens experience.</Trans>
|
||||||
</small>
|
</small>
|
||||||
</WizardLayout>
|
</WizardLayout>
|
||||||
)
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,12 +7,11 @@ import { userStore } from "../../../common/user-store"
|
|||||||
import { navigate } from "../../navigation";
|
import { navigate } from "../../navigation";
|
||||||
import { Button } from "../button";
|
import { Button } from "../button";
|
||||||
import { Trans } from "@lingui/macro";
|
import { Trans } from "@lingui/macro";
|
||||||
import { staticDir } from "../../../common/vars";
|
|
||||||
import marked from "marked"
|
import marked from "marked"
|
||||||
|
|
||||||
@observer
|
@observer
|
||||||
export class WhatsNew extends React.Component {
|
export class WhatsNew extends React.Component {
|
||||||
releaseNotes = fs.readFileSync(path.join(staticDir, "RELEASE_NOTES.md")).toString();
|
releaseNotes = fs.readFileSync(path.join(__static, "RELEASE_NOTES.md")).toString();
|
||||||
|
|
||||||
ok = () => {
|
ok = () => {
|
||||||
navigate("/");
|
navigate("/");
|
||||||
|
|||||||
@ -98,7 +98,7 @@ export function DeploymentMenu(props: KubeObjectMenuProps<Deployment>) {
|
|||||||
return (
|
return (
|
||||||
<KubeObjectMenu {...props}>
|
<KubeObjectMenu {...props}>
|
||||||
<MenuItem onClick={() => DeploymentScaleDialog.open(object)}>
|
<MenuItem onClick={() => DeploymentScaleDialog.open(object)}>
|
||||||
<Icon material="control_camera" title={_i18n._(t`Scale`)} interactive={toolbar}/>
|
<Icon material="open_with" title={_i18n._(t`Scale`)} interactive={toolbar}/>
|
||||||
<span className="title"><Trans>Scale</Trans></span>
|
<span className="title"><Trans>Scale</Trans></span>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
</KubeObjectMenu>
|
</KubeObjectMenu>
|
||||||
|
|||||||
@ -0,0 +1,23 @@
|
|||||||
|
.PodContainerPort {
|
||||||
|
&.waiting {
|
||||||
|
opacity: 0.5;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:not(:last-child) {
|
||||||
|
margin-bottom: $margin;
|
||||||
|
}
|
||||||
|
|
||||||
|
span {
|
||||||
|
cursor: pointer;
|
||||||
|
color: $primary;
|
||||||
|
text-decoration: underline;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.Spinner {
|
||||||
|
--spinner-size: #{$unit * 2};
|
||||||
|
margin-left: $margin;
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,54 @@
|
|||||||
|
import "./pod-container-port.scss"
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { observer } from "mobx-react";
|
||||||
|
import { t } from "@lingui/macro";
|
||||||
|
import { Pod, IPodContainer } from "../../api/endpoints";
|
||||||
|
import { _i18n } from "../../i18n";
|
||||||
|
import { apiBase } from "../../api"
|
||||||
|
import { observable } from "mobx";
|
||||||
|
import { cssNames } from "../../utils";
|
||||||
|
import { Notifications } from "../notifications";
|
||||||
|
import { Spinner } from "../spinner"
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
pod: Pod;
|
||||||
|
port: {
|
||||||
|
name?: string;
|
||||||
|
containerPort: number;
|
||||||
|
protocol: string;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@observer
|
||||||
|
export class PodContainerPort extends React.Component<Props> {
|
||||||
|
@observable waiting = false;
|
||||||
|
|
||||||
|
async portForward() {
|
||||||
|
const { pod, port } = this.props;
|
||||||
|
this.waiting = true;
|
||||||
|
try {
|
||||||
|
await apiBase.post(`/pods/${pod.getNs()}/pod/${pod.getName()}/port-forward/${port.containerPort}`, {})
|
||||||
|
} catch(error) {
|
||||||
|
Notifications.error(error);
|
||||||
|
} finally {
|
||||||
|
this.waiting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { port } = this.props;
|
||||||
|
const { name, containerPort, protocol } = port;
|
||||||
|
const text = (name ? name + ': ' : '')+`${containerPort}/${protocol}`
|
||||||
|
return (
|
||||||
|
<div className={cssNames("PodContainerPort", { waiting: this.waiting })}>
|
||||||
|
<span title={_i18n._(t`Open in a browser`)} onClick={() => this.portForward() }>
|
||||||
|
{text}
|
||||||
|
{this.waiting && (
|
||||||
|
<Spinner />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -8,6 +8,7 @@ import { cssNames } from "../../utils";
|
|||||||
import { StatusBrick } from "../status-brick";
|
import { StatusBrick } from "../status-brick";
|
||||||
import { Badge } from "../badge";
|
import { Badge } from "../badge";
|
||||||
import { ContainerEnvironment } from "./pod-container-env";
|
import { ContainerEnvironment } from "./pod-container-env";
|
||||||
|
import { PodContainerPort } from "./pod-container-port";
|
||||||
import { ResourceMetrics } from "../resource-metrics";
|
import { ResourceMetrics } from "../resource-metrics";
|
||||||
import { IMetrics } from "../../api/endpoints/metrics.api";
|
import { IMetrics } from "../../api/endpoints/metrics.api";
|
||||||
import { ContainerCharts } from "./container-charts";
|
import { ContainerCharts } from "./container-charts";
|
||||||
@ -64,13 +65,10 @@ export class PodDetailsContainer extends React.Component<Props> {
|
|||||||
{ports && ports.length > 0 &&
|
{ports && ports.length > 0 &&
|
||||||
<DrawerItem name={<Trans>Ports</Trans>}>
|
<DrawerItem name={<Trans>Ports</Trans>}>
|
||||||
{
|
{
|
||||||
ports.map(port => {
|
ports.map((port) => {
|
||||||
const { name, containerPort, protocol } = port;
|
const key = `${container.name}-port-${port.containerPort}-${port.protocol}`
|
||||||
const key = `${container.name}-port-${containerPort}-${protocol}`
|
return(
|
||||||
return (
|
<PodContainerPort pod={pod} port={port} key={key}/>
|
||||||
<div key={key}>
|
|
||||||
{name ? name + ': ' : ''}{containerPort}/{protocol}
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@ -226,7 +226,7 @@ export class PodLogsDialog extends React.Component<Props> {
|
|||||||
tooltip={(showTimestamps ? _i18n._(t`Hide`) : _i18n._(t`Show`)) + " " + _i18n._(t`timestamps`)}
|
tooltip={(showTimestamps ? _i18n._(t`Hide`) : _i18n._(t`Show`)) + " " + _i18n._(t`timestamps`)}
|
||||||
/>
|
/>
|
||||||
<Icon
|
<Icon
|
||||||
material="save_alt"
|
material="get_app"
|
||||||
onClick={this.downloadLogs}
|
onClick={this.downloadLogs}
|
||||||
tooltip={_i18n._(t`Save`)}
|
tooltip={_i18n._(t`Save`)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -13,7 +13,6 @@ interface Props {
|
|||||||
|
|
||||||
export class AppInit extends React.Component<Props> {
|
export class AppInit extends React.Component<Props> {
|
||||||
static async start(rootElem: HTMLElement) {
|
static async start(rootElem: HTMLElement) {
|
||||||
|
|
||||||
render(<AppInit/>, rootElem); // show loading indicator asap
|
render(<AppInit/>, rootElem); // show loading indicator asap
|
||||||
await AppInit.readyStateCheck(rootElem); // wait while all good to run
|
await AppInit.readyStateCheck(rootElem); // wait while all good to run
|
||||||
}
|
}
|
||||||
|
|||||||
@ -82,7 +82,7 @@ hr {
|
|||||||
h1 {
|
h1 {
|
||||||
color: white;
|
color: white;
|
||||||
font-size: 28px;
|
font-size: 28px;
|
||||||
font-weight: 300;
|
font-weight: normal;
|
||||||
letter-spacing: -.010em;
|
letter-spacing: -.010em;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
@ -99,13 +99,13 @@ h3 {
|
|||||||
|
|
||||||
h4 {
|
h4 {
|
||||||
@extend h3;
|
@extend h3;
|
||||||
font-size: 16px;
|
font-size: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
h5 {
|
h5 {
|
||||||
@extend h4;
|
@extend h4;
|
||||||
padding: $padding / 2 0;
|
padding: $padding / 2 0;
|
||||||
font-size: 14px;
|
font-size: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
h6 {
|
h6 {
|
||||||
|
|||||||
@ -28,14 +28,21 @@ import { DeploymentScaleDialog } from "./+workloads-deployments/deployment-scale
|
|||||||
import { CustomResources } from "./+custom-resources/custom-resources";
|
import { CustomResources } from "./+custom-resources/custom-resources";
|
||||||
import { crdRoute } from "./+custom-resources";
|
import { crdRoute } from "./+custom-resources";
|
||||||
import { isAllowedResource } from "../../common/rbac";
|
import { isAllowedResource } from "../../common/rbac";
|
||||||
import { ClusterSettings, clusterSettingsRoute } from "./+cluster-settings";
|
|
||||||
import { ErrorBoundary } from "./error-boundary";
|
import { ErrorBoundary } from "./error-boundary";
|
||||||
import { Terminal } from "./dock/terminal";
|
import { Terminal } from "./dock/terminal";
|
||||||
|
import { getHostedCluster, getHostedClusterId } from "../../common/cluster-store";
|
||||||
|
import logger from "../../main/logger";
|
||||||
|
import { clusterIpc } from "../../common/cluster-ipc";
|
||||||
|
import { webFrame } from "electron";
|
||||||
|
|
||||||
@observer
|
@observer
|
||||||
export class App extends React.Component {
|
export class App extends React.Component {
|
||||||
static async init() {
|
static async init() {
|
||||||
|
const clusterId = getHostedClusterId();
|
||||||
|
logger.info(`[APP]: Init dashboard, clusterId=${clusterId}`)
|
||||||
await Terminal.preloadFonts()
|
await Terminal.preloadFonts()
|
||||||
|
await clusterIpc.init.invokeFromRenderer(clusterId, webFrame.routingId);
|
||||||
|
await getHostedCluster().whenInitialized;
|
||||||
}
|
}
|
||||||
|
|
||||||
get startURL() {
|
get startURL() {
|
||||||
@ -52,7 +59,6 @@ export class App extends React.Component {
|
|||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<Switch>
|
<Switch>
|
||||||
<Route component={Cluster} {...clusterRoute}/>
|
<Route component={Cluster} {...clusterRoute}/>
|
||||||
<Route component={ClusterSettings} {...clusterSettingsRoute}/>
|
|
||||||
<Route component={Nodes} {...nodesRoute}/>
|
<Route component={Nodes} {...nodesRoute}/>
|
||||||
<Route component={Workloads} {...workloadsRoute}/>
|
<Route component={Workloads} {...workloadsRoute}/>
|
||||||
<Route component={Config} {...configRoute}/>
|
<Route component={Config} {...configRoute}/>
|
||||||
@ -66,9 +72,9 @@ export class App extends React.Component {
|
|||||||
<Redirect exact from="/" to={this.startURL}/>
|
<Redirect exact from="/" to={this.startURL}/>
|
||||||
<Route component={NotFound}/>
|
<Route component={NotFound}/>
|
||||||
</Switch>
|
</Switch>
|
||||||
<KubeObjectDetails/>
|
|
||||||
<Notifications/>
|
<Notifications/>
|
||||||
<ConfirmDialog/>
|
<ConfirmDialog/>
|
||||||
|
<KubeObjectDetails/>
|
||||||
<KubeConfigDialog/>
|
<KubeConfigDialog/>
|
||||||
<AddRoleBindingDialog/>
|
<AddRoleBindingDialog/>
|
||||||
<PodLogsDialog/>
|
<PodLogsDialog/>
|
||||||
|
|||||||
@ -7,6 +7,12 @@
|
|||||||
user-select: none;
|
user-select: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
|
&.interactive {
|
||||||
|
img {
|
||||||
|
opacity: .55;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
&.active, &.interactive:hover {
|
&.active, &.interactive:hover {
|
||||||
background-color: #fff;
|
background-color: #fff;
|
||||||
|
|
||||||
@ -16,7 +22,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
img {
|
img {
|
||||||
opacity: .55;
|
|
||||||
width: var(--size);
|
width: var(--size);
|
||||||
height: var(--size);
|
height: var(--size);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -42,12 +42,12 @@ export class ClusterIcon extends React.Component<Props> {
|
|||||||
active: isActive,
|
active: isActive,
|
||||||
});
|
});
|
||||||
return (
|
return (
|
||||||
<div {...elemProps} className={className} id={clusterIconId}>
|
<div {...elemProps} className={className} id={showTooltip ? clusterIconId : null}>
|
||||||
{showTooltip && (
|
{showTooltip && (
|
||||||
<Tooltip targetId={clusterIconId}>{clusterName}</Tooltip>
|
<Tooltip targetId={clusterIconId}>{clusterName}</Tooltip>
|
||||||
)}
|
)}
|
||||||
{icon && <img src={icon} alt={clusterName}/>}
|
{icon && <img src={icon} alt={clusterName}/>}
|
||||||
{!icon && <Hashicon value={clusterName} options={options}/>}
|
{!icon && <Hashicon value={clusterId} options={options}/>}
|
||||||
{showErrors && isAdmin && eventCount > 0 && (
|
{showErrors && isAdmin && eventCount > 0 && (
|
||||||
<Badge
|
<Badge
|
||||||
className={cssNames("events-count", errorClass)}
|
className={cssNames("events-count", errorClass)}
|
||||||
|
|||||||
@ -1,13 +1,28 @@
|
|||||||
.ClusterManager {
|
.ClusterManager {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-areas: "menu lens-view" "menu lens-view" "bottom-bar bottom-bar";
|
grid-template-areas: "menu main" "menu main" "bottom-bar bottom-bar";
|
||||||
grid-template-rows: auto 1fr min-content;
|
grid-template-rows: auto 1fr min-content;
|
||||||
grid-template-columns: min-content 1fr;
|
grid-template-columns: min-content 1fr;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|
||||||
#lens-view {
|
main {
|
||||||
|
grid-area: main;
|
||||||
position: relative;
|
position: relative;
|
||||||
grid-area: lens-view;
|
display: flex;
|
||||||
|
|
||||||
|
> * {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
display: flex;
|
||||||
|
background-color: $mainBackground;
|
||||||
|
|
||||||
|
> * {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.ClustersMenu {
|
.ClustersMenu {
|
||||||
|
|||||||
@ -1,43 +1,68 @@
|
|||||||
import "./cluster-manager.scss"
|
import "./cluster-manager.scss"
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { observer } from "mobx-react";
|
import { Redirect, Route, Switch } from "react-router";
|
||||||
|
import { reaction } from "mobx";
|
||||||
|
import { disposeOnUnmount, observer } from "mobx-react";
|
||||||
import { ClustersMenu } from "./clusters-menu";
|
import { ClustersMenu } from "./clusters-menu";
|
||||||
import { BottomBar } from "./bottom-bar";
|
import { BottomBar } from "./bottom-bar";
|
||||||
import { cssNames, IClassName } from "../../utils";
|
import { LandingPage, landingRoute, landingURL } from "../+landing-page";
|
||||||
import { ClusterId } from "../../../common/cluster-store";
|
|
||||||
import { Route, Switch } from "react-router";
|
|
||||||
import { LandingPage, landingRoute } from "../+landing-page";
|
|
||||||
import { Preferences, preferencesRoute } from "../+preferences";
|
import { Preferences, preferencesRoute } from "../+preferences";
|
||||||
import { Workspaces, workspacesRoute } from "../+workspaces";
|
import { Workspaces, workspacesRoute } from "../+workspaces";
|
||||||
import { AddCluster, addClusterRoute } from "../+add-cluster";
|
import { AddCluster, addClusterRoute } from "../+add-cluster";
|
||||||
import { ClusterStatus } from "./cluster-status";
|
import { ClusterView } from "./cluster-view";
|
||||||
import { clusterStatusRoute } from "./cluster-status.route";
|
import { ClusterSettings, clusterSettingsRoute } from "../+cluster-settings";
|
||||||
|
import { clusterViewRoute, clusterViewURL, getMatchedCluster, getMatchedClusterId } from "./cluster-view.route";
|
||||||
interface Props {
|
import { clusterStore } from "../../../common/cluster-store";
|
||||||
className?: IClassName;
|
import { hasLoadedView, initView, lensViews, refreshViews } from "./lens-views";
|
||||||
contentClass?: IClassName;
|
|
||||||
}
|
|
||||||
|
|
||||||
@observer
|
@observer
|
||||||
export class ClusterManager extends React.Component<Props> {
|
export class ClusterManager extends React.Component {
|
||||||
activateView(clusterId: ClusterId) {
|
componentDidMount() {
|
||||||
|
disposeOnUnmount(this, [
|
||||||
|
reaction(getMatchedClusterId, initView, {
|
||||||
|
fireImmediately: true
|
||||||
|
}),
|
||||||
|
reaction(() => [
|
||||||
|
hasLoadedView(getMatchedClusterId()), // refresh when cluster's webview loaded
|
||||||
|
getMatchedCluster()?.available, // refresh on disconnect active-cluster
|
||||||
|
], refreshViews, {
|
||||||
|
fireImmediately: true
|
||||||
|
})
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
lensViews.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
get startUrl() {
|
||||||
|
const { activeClusterId } = clusterStore;
|
||||||
|
if (activeClusterId) {
|
||||||
|
return clusterViewURL({
|
||||||
|
params: {
|
||||||
|
clusterId: activeClusterId
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return landingURL()
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { className } = this.props;
|
|
||||||
return (
|
return (
|
||||||
<div className={cssNames("ClusterManager", className)}>
|
<div className="ClusterManager">
|
||||||
<div id="draggable-top"/>
|
<div id="draggable-top"/>
|
||||||
<div id="lens-view">
|
<main>
|
||||||
|
<div id="lens-views"/>
|
||||||
<Switch>
|
<Switch>
|
||||||
<Route component={LandingPage} {...landingRoute}/>
|
<Route component={LandingPage} {...landingRoute}/>
|
||||||
<Route component={Preferences} {...preferencesRoute}/>
|
<Route component={Preferences} {...preferencesRoute}/>
|
||||||
<Route component={Workspaces} {...workspacesRoute}/>
|
<Route component={Workspaces} {...workspacesRoute}/>
|
||||||
<Route component={AddCluster} {...addClusterRoute}/>
|
<Route component={AddCluster} {...addClusterRoute}/>
|
||||||
<Route component={ClusterStatus} {...clusterStatusRoute}/>
|
<Route component={ClusterView} {...clusterViewRoute}/>
|
||||||
<Route render={() => <p>Lens</p>}/>
|
<Route component={ClusterSettings} {...clusterSettingsRoute}/>
|
||||||
|
<Redirect exact to={this.startUrl}/>
|
||||||
</Switch>
|
</Switch>
|
||||||
</div>
|
</main>
|
||||||
<ClustersMenu/>
|
<ClustersMenu/>
|
||||||
<BottomBar/>
|
<BottomBar/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,8 +0,0 @@
|
|||||||
import { RouteProps } from "react-router";
|
|
||||||
import { buildURL } from "../../navigation";
|
|
||||||
|
|
||||||
export const clusterStatusRoute: RouteProps = {
|
|
||||||
path: "/cluster-status"
|
|
||||||
}
|
|
||||||
|
|
||||||
export const clusterStatusURL = buildURL(clusterStatusRoute.path)
|
|
||||||
@ -1,14 +1,17 @@
|
|||||||
.ClusterStatus {
|
.ClusterStatus {
|
||||||
--flex-gap: #{$padding * 2};
|
--flex-gap: #{$padding * 2};
|
||||||
|
|
||||||
|
position: relative;
|
||||||
min-width: 350px;
|
min-width: 350px;
|
||||||
margin: auto;
|
margin: auto;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
z-index: 1;
|
||||||
|
|
||||||
pre {
|
pre {
|
||||||
@include hidden-scrollbar;
|
@include hidden-scrollbar;
|
||||||
max-width: 70vw;
|
max-width: 70vw;
|
||||||
max-height: 40vh;
|
max-height: 40vh;
|
||||||
|
white-space: pre-line;
|
||||||
}
|
}
|
||||||
|
|
||||||
.Icon {
|
.Icon {
|
||||||
|
|||||||
@ -2,102 +2,107 @@ import type { KubeAuthProxyLog } from "../../../main/kube-auth-proxy";
|
|||||||
|
|
||||||
import "./cluster-status.scss"
|
import "./cluster-status.scss"
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { disposeOnUnmount, observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
import { ipcRenderer } from "electron";
|
import { ipcRenderer } from "electron";
|
||||||
import { autorun, computed, observable } from "mobx";
|
import { computed, observable } from "mobx";
|
||||||
import { clusterIpc } from "../../../common/cluster-ipc";
|
import { clusterIpc } from "../../../common/cluster-ipc";
|
||||||
import { Icon } from "../icon";
|
import { Icon } from "../icon";
|
||||||
import { Button } from "../button";
|
import { Button } from "../button";
|
||||||
import { cssNames } from "../../utils";
|
import { cssNames, IClassName } from "../../utils";
|
||||||
import { navigate } from "../../navigation";
|
|
||||||
import { Cluster } from "../../../main/cluster";
|
import { Cluster } from "../../../main/cluster";
|
||||||
|
import { ClusterId, clusterStore } from "../../../common/cluster-store";
|
||||||
|
import { CubeSpinner } from "../spinner";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
className?: IClassName;
|
||||||
|
clusterId: ClusterId;
|
||||||
|
}
|
||||||
|
|
||||||
@observer
|
@observer
|
||||||
export class ClusterStatus extends React.Component {
|
export class ClusterStatus extends React.Component<Props> {
|
||||||
@observable authOutput: KubeAuthProxyLog[] = [];
|
@observable authOutput: KubeAuthProxyLog[] = [];
|
||||||
@observable isReconnecting = false;
|
@observable isReconnecting = false;
|
||||||
|
|
||||||
// fixme
|
get cluster(): Cluster {
|
||||||
@computed get cluster(): Cluster {
|
return clusterStore.getById(this.props.clusterId);
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@computed get hasErrors(): boolean {
|
@computed get hasErrors(): boolean {
|
||||||
return this.authOutput.some(({ error }) => error) || !!this.cluster.failureReason;
|
return this.authOutput.some(({ error }) => error) || !!this.cluster.failureReason;
|
||||||
}
|
}
|
||||||
|
|
||||||
@disposeOnUnmount
|
|
||||||
autoRedirectToMain = autorun(() => {
|
|
||||||
if (this.cluster.accessible && !this.hasErrors) {
|
|
||||||
navigate("/");
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
async componentDidMount() {
|
async componentDidMount() {
|
||||||
if (this.cluster.disconnected) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.authOutput = [{ data: "Connecting..." }];
|
|
||||||
ipcRenderer.on(`kube-auth:${this.cluster.id}`, (evt, res: KubeAuthProxyLog) => {
|
ipcRenderer.on(`kube-auth:${this.cluster.id}`, (evt, res: KubeAuthProxyLog) => {
|
||||||
this.authOutput.push({
|
this.authOutput.push({
|
||||||
data: res.data.trimRight(),
|
data: res.data.trimRight(),
|
||||||
error: res.error,
|
error: res.error,
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
await this.refreshClusterState();
|
if (!this.cluster.initialized || this.cluster.disconnected) {
|
||||||
|
await this.refreshCluster();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
componentWillUnmount() {
|
componentWillUnmount() {
|
||||||
ipcRenderer.removeAllListeners(`kube-auth:${this.cluster.id}`);
|
ipcRenderer.removeAllListeners(`kube-auth:${this.props.clusterId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async refreshClusterState() {
|
refreshCluster = async () => {
|
||||||
return clusterIpc.activate.invokeFromRenderer();
|
await clusterIpc.activate.invokeFromRenderer(this.props.clusterId);
|
||||||
}
|
}
|
||||||
|
|
||||||
reconnect = async () => {
|
reconnect = async () => {
|
||||||
this.authOutput = [{ data: "Reconnecting..." }];
|
|
||||||
this.isReconnecting = true;
|
this.isReconnecting = true;
|
||||||
await this.refreshClusterState();
|
await this.refreshCluster();
|
||||||
this.isReconnecting = false;
|
this.isReconnecting = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
renderContent() {
|
||||||
const { authOutput, cluster, hasErrors } = this;
|
const { authOutput, cluster, hasErrors } = this;
|
||||||
const isDisconnected = !!cluster.disconnected;
|
|
||||||
const failureReason = cluster.failureReason;
|
const failureReason = cluster.failureReason;
|
||||||
const isError = hasErrors || isDisconnected;
|
if (!hasErrors || this.isReconnecting) {
|
||||||
return (
|
return (
|
||||||
<div className="ClusterStatus flex column gaps">
|
<>
|
||||||
{isError && (
|
<CubeSpinner />
|
||||||
<Icon
|
|
||||||
material="cloud_off"
|
|
||||||
className={cssNames({ error: hasErrors })}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<h2>
|
|
||||||
{cluster.contextName}
|
|
||||||
</h2>
|
|
||||||
{!isDisconnected && (
|
|
||||||
<pre className="kube-auth-out">
|
<pre className="kube-auth-out">
|
||||||
|
<p>{this.isReconnecting ? "Reconnecting..." : "Connecting..."}</p>
|
||||||
{authOutput.map(({ data, error }, index) => {
|
{authOutput.map(({ data, error }, index) => {
|
||||||
return <p key={index} className={cssNames({ error })}>{data}</p>
|
return <p key={index} className={cssNames({ error })}>{data}</p>
|
||||||
})}
|
})}
|
||||||
</pre>
|
</pre>
|
||||||
)}
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Icon material="cloud_off" className="error" />
|
||||||
|
<h2>
|
||||||
|
{cluster.preferences.clusterName}
|
||||||
|
</h2>
|
||||||
|
<pre className="kube-auth-out">
|
||||||
|
{authOutput.map(({ data, error }, index) => {
|
||||||
|
return <p key={index} className={cssNames({ error })}>{data}</p>
|
||||||
|
})}
|
||||||
|
</pre>
|
||||||
{failureReason && (
|
{failureReason && (
|
||||||
<div className="failure-reason error">{failureReason}</div>
|
<div className="failure-reason error">{failureReason}</div>
|
||||||
)}
|
)}
|
||||||
{isError && (
|
<Button
|
||||||
<Button
|
primary
|
||||||
primary
|
label="Reconnect"
|
||||||
label="Reconnect"
|
className="box center"
|
||||||
className="box center"
|
onClick={this.reconnect}
|
||||||
onClick={this.reconnect}
|
waiting={this.isReconnecting}
|
||||||
waiting={this.isReconnecting}
|
/>
|
||||||
/>
|
</>
|
||||||
)}
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return (
|
||||||
|
<div className={cssNames("ClusterStatus flex column gaps box center align-center justify-center", this.props.className)}>
|
||||||
|
{this.renderContent()}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,46 @@
|
|||||||
|
import { reaction } from "mobx";
|
||||||
|
import { ipcRenderer } from "electron";
|
||||||
|
import { matchPath, RouteProps } from "react-router";
|
||||||
|
import { buildURL, navigation } from "../../navigation";
|
||||||
|
import { clusterStore, getHostedClusterId } from "../../../common/cluster-store";
|
||||||
|
import { clusterSettingsRoute } from "../+cluster-settings/cluster-settings.route";
|
||||||
|
|
||||||
|
export interface IClusterViewRouteParams {
|
||||||
|
clusterId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const clusterViewRoute: RouteProps = {
|
||||||
|
exact: true,
|
||||||
|
path: "/cluster/:clusterId",
|
||||||
|
}
|
||||||
|
|
||||||
|
export const clusterViewURL = buildURL<IClusterViewRouteParams>(clusterViewRoute.path)
|
||||||
|
|
||||||
|
export function getMatchedClusterId(): string {
|
||||||
|
const matched = matchPath<IClusterViewRouteParams>(navigation.location.pathname, {
|
||||||
|
exact: true,
|
||||||
|
path: [
|
||||||
|
clusterViewRoute.path,
|
||||||
|
clusterSettingsRoute.path,
|
||||||
|
].flat(),
|
||||||
|
})
|
||||||
|
if (matched) {
|
||||||
|
return matched.params.clusterId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMatchedCluster() {
|
||||||
|
return clusterStore.getById(getMatchedClusterId())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh global menu depending on active route's type (common/cluster view)
|
||||||
|
if (ipcRenderer) {
|
||||||
|
const isMainView = !getHostedClusterId();
|
||||||
|
if (isMainView) {
|
||||||
|
reaction(() => getMatchedClusterId(), clusterId => {
|
||||||
|
ipcRenderer.send("cluster-view:change", clusterId);
|
||||||
|
}, {
|
||||||
|
fireImmediately: true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
.ClusterView {
|
||||||
|
&:empty {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
21
src/renderer/components/cluster-manager/cluster-view.tsx
Normal file
21
src/renderer/components/cluster-manager/cluster-view.tsx
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import "./cluster-view.scss"
|
||||||
|
import React from "react";
|
||||||
|
import { observer } from "mobx-react";
|
||||||
|
import { getMatchedCluster } from "./cluster-view.route";
|
||||||
|
import { ClusterStatus } from "./cluster-status";
|
||||||
|
import { hasLoadedView } from "./lens-views";
|
||||||
|
|
||||||
|
@observer
|
||||||
|
export class ClusterView extends React.Component {
|
||||||
|
render() {
|
||||||
|
const cluster = getMatchedCluster();
|
||||||
|
const showStatus = cluster && (!cluster.available || !hasLoadedView(cluster.id))
|
||||||
|
return (
|
||||||
|
<div className="ClusterView">
|
||||||
|
{showStatus && (
|
||||||
|
<ClusterStatus key={cluster.id} clusterId={cluster.id} className="box center"/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -20,7 +20,7 @@ import { landingURL } from "../+landing-page";
|
|||||||
import { Tooltip } from "../tooltip";
|
import { Tooltip } from "../tooltip";
|
||||||
import { ConfirmDialog } from "../confirm-dialog";
|
import { ConfirmDialog } from "../confirm-dialog";
|
||||||
import { clusterIpc } from "../../../common/cluster-ipc";
|
import { clusterIpc } from "../../../common/cluster-ipc";
|
||||||
import { clusterStatusURL } from "./cluster-status.route";
|
import { clusterViewURL, getMatchedClusterId } from "./cluster-view.route";
|
||||||
|
|
||||||
// fixme: allow to rearrange clusters with drag&drop
|
// fixme: allow to rearrange clusters with drag&drop
|
||||||
|
|
||||||
@ -33,11 +33,8 @@ export class ClustersMenu extends React.Component<Props> {
|
|||||||
@observable showHint = true;
|
@observable showHint = true;
|
||||||
|
|
||||||
showCluster = (clusterId: ClusterId) => {
|
showCluster = (clusterId: ClusterId) => {
|
||||||
if (clusterStore.activeClusterId === clusterId) {
|
clusterStore.setActive(clusterId);
|
||||||
navigate("/"); // redirect to index
|
navigate(clusterViewURL({ params: { clusterId } }));
|
||||||
} else {
|
|
||||||
clusterStore.activeClusterId = clusterId;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
addCluster = () => {
|
addCluster = () => {
|
||||||
@ -50,16 +47,22 @@ export class ClustersMenu extends React.Component<Props> {
|
|||||||
|
|
||||||
menu.append(new MenuItem({
|
menu.append(new MenuItem({
|
||||||
label: _i18n._(t`Settings`),
|
label: _i18n._(t`Settings`),
|
||||||
click: () => navigate(clusterSettingsURL())
|
click: () => {
|
||||||
|
navigate(clusterSettingsURL({
|
||||||
|
params: {
|
||||||
|
clusterId: cluster.id
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}
|
||||||
}));
|
}));
|
||||||
if (cluster.online) {
|
if (cluster.online) {
|
||||||
menu.append(new MenuItem({
|
menu.append(new MenuItem({
|
||||||
label: _i18n._(t`Disconnect`),
|
label: _i18n._(t`Disconnect`),
|
||||||
click: async () => {
|
click: async () => {
|
||||||
await clusterIpc.disconnect.invokeFromRenderer(cluster.id);
|
if (clusterStore.isActive(cluster.id)) {
|
||||||
if (cluster.id === clusterStore.activeClusterId) {
|
navigate(landingURL());
|
||||||
navigate(clusterStatusURL());
|
|
||||||
}
|
}
|
||||||
|
await clusterIpc.disconnect.invokeFromRenderer(cluster.id);
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
@ -72,7 +75,10 @@ export class ClustersMenu extends React.Component<Props> {
|
|||||||
accent: true,
|
accent: true,
|
||||||
label: _i18n._(t`Remove`),
|
label: _i18n._(t`Remove`),
|
||||||
},
|
},
|
||||||
ok: () => clusterStore.removeById(cluster.id),
|
ok: () => {
|
||||||
|
clusterStore.removeById(cluster.id);
|
||||||
|
navigate(landingURL());
|
||||||
|
},
|
||||||
message: <p>Are you sure want to remove cluster <b title={cluster.id}>{cluster.contextName}</b>?</p>,
|
message: <p>Are you sure want to remove cluster <b title={cluster.id}>{cluster.contextName}</b>?</p>,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@ -110,7 +116,7 @@ export class ClustersMenu extends React.Component<Props> {
|
|||||||
key={cluster.id}
|
key={cluster.id}
|
||||||
showErrors={true}
|
showErrors={true}
|
||||||
cluster={cluster}
|
cluster={cluster}
|
||||||
isActive={cluster.id === clusterStore.activeClusterId}
|
isActive={cluster.id === getMatchedClusterId()}
|
||||||
onClick={() => this.showCluster(cluster.id)}
|
onClick={() => this.showCluster(cluster.id)}
|
||||||
onContextMenu={() => this.showContextMenu(cluster)}
|
onContextMenu={() => this.showContextMenu(cluster)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
43
src/renderer/components/cluster-manager/lens-views.ts
Normal file
43
src/renderer/components/cluster-manager/lens-views.ts
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
import { observable } from "mobx";
|
||||||
|
import { ClusterId, clusterStore } from "../../../common/cluster-store";
|
||||||
|
import { getMatchedCluster } from "./cluster-view.route"
|
||||||
|
import logger from "../../../main/logger";
|
||||||
|
|
||||||
|
export interface LensView {
|
||||||
|
isLoaded?: boolean
|
||||||
|
clusterId: ClusterId;
|
||||||
|
view: HTMLIFrameElement
|
||||||
|
}
|
||||||
|
|
||||||
|
export const lensViews = observable.map<ClusterId, LensView>();
|
||||||
|
|
||||||
|
export function hasLoadedView(clusterId: ClusterId): boolean {
|
||||||
|
return !!lensViews.get(clusterId)?.isLoaded;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function initView(clusterId: ClusterId) {
|
||||||
|
if (!clusterId || lensViews.has(clusterId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
logger.info(`[LENS-VIEW]: init dashboard, clusterId=${clusterId}`)
|
||||||
|
const cluster = clusterStore.getById(clusterId);
|
||||||
|
await cluster.whenInitialized;
|
||||||
|
const parentElem = document.getElementById("lens-views");
|
||||||
|
const iframe = document.createElement("iframe");
|
||||||
|
iframe.name = cluster.preferences.clusterName;
|
||||||
|
iframe.setAttribute("src", `//${clusterId}.${location.host}`)
|
||||||
|
iframe.addEventListener("load", async () => {
|
||||||
|
logger.info(`[LENS-VIEW]: loaded from ${iframe.src}`)
|
||||||
|
lensViews.get(clusterId).isLoaded = true;
|
||||||
|
})
|
||||||
|
lensViews.set(clusterId, { clusterId, view: iframe });
|
||||||
|
parentElem.appendChild(iframe);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function refreshViews() {
|
||||||
|
const cluster = getMatchedCluster();
|
||||||
|
lensViews.forEach(({ clusterId, view, isLoaded }) => {
|
||||||
|
const isVisible = cluster && cluster.available && cluster.id === clusterId;
|
||||||
|
view.style.display = isLoaded && isVisible ? "flex" : "none"
|
||||||
|
})
|
||||||
|
}
|
||||||
@ -1,14 +1,11 @@
|
|||||||
.FilePicker {
|
.FilePicker {
|
||||||
input[type="file"] {
|
input[type="file"] {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
label {
|
label {
|
||||||
display: inline-block;
|
display: inline-flex;
|
||||||
border: medium solid;
|
cursor: pointer;
|
||||||
padding: 10px;
|
color: var(--blue);
|
||||||
border-radius: 5px;
|
}
|
||||||
cursor: pointer;
|
|
||||||
margin: 5px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@ -43,7 +43,7 @@ export enum OverTotalSizeLimitStyle {
|
|||||||
|
|
||||||
export interface BaseProps {
|
export interface BaseProps {
|
||||||
accept?: string;
|
accept?: string;
|
||||||
labelText: string;
|
label: React.ReactNode;
|
||||||
multiple?: boolean;
|
multiple?: boolean;
|
||||||
|
|
||||||
// limit is the optional maximum number of files to upload
|
// limit is the optional maximum number of files to upload
|
||||||
@ -175,10 +175,10 @@ export class FilePicker extends React.Component<Props> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { accept, labelText, multiple } = this.props;
|
const { accept, label, multiple } = this.props;
|
||||||
|
|
||||||
return <div className="FilePicker">
|
return <div className="FilePicker">
|
||||||
<label htmlFor="file-upload">{labelText} {this.getIconRight()}</label>
|
<label className="flex gaps align-center" htmlFor="file-upload">{label} {this.getIconRight()}</label>
|
||||||
<input
|
<input
|
||||||
id="file-upload"
|
id="file-upload"
|
||||||
name="FilePicker"
|
name="FilePicker"
|
||||||
|
|||||||
@ -74,7 +74,7 @@
|
|||||||
|
|
||||||
.input-info {
|
.input-info {
|
||||||
.errors {
|
.errors {
|
||||||
color: var(color-error);
|
color: var(--colorError);
|
||||||
font-size: $font-size-small;
|
font-size: $font-size-small;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -285,6 +285,7 @@ export class Input extends React.Component<InputProps, State> {
|
|||||||
rows: multiLine ? (rows || 1) : null,
|
rows: multiLine ? (rows || 1) : null,
|
||||||
ref: this.bindRef,
|
ref: this.bindRef,
|
||||||
type: "text",
|
type: "text",
|
||||||
|
spellCheck: "false",
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -38,7 +38,7 @@ export const isNumber: Validator = {
|
|||||||
export const isUrl: Validator = {
|
export const isUrl: Validator = {
|
||||||
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\.-]+)+[\w\-\._~:/?#[\]@!\$&'\(\)\*\+,;=.]+$/),
|
validate: value => !!value.match(/^http(s)?:\/\/\w+(\.\w+)*(:[0-9]+)?\/?(\/[\w\-\._~:/?#[\]@!\$&'\(\)\*\+,;=.]*)*$/),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const minLength: Validator = {
|
export const minLength: Validator = {
|
||||||
@ -53,9 +53,10 @@ export const maxLength: Validator = {
|
|||||||
validate: (value, { maxLength }) => value.length <= maxLength,
|
validate: (value, { maxLength }) => value.length <= maxLength,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const systemNameMatcher = /^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$/;
|
||||||
export const systemName: Validator = {
|
export const systemName: Validator = {
|
||||||
message: () => _i18n._(t`This field must contain only lowercase latin characters, numbers and dash.`),
|
message: () => _i18n._(t`A System Name must be lowercase DNS labels separated by dots. DNS labels are alphanumerics and dashes enclosed by alphanumerics.`),
|
||||||
validate: value => !!value.match(/^[a-z0-9-]+$/),
|
validate: value => !!value.match(systemNameMatcher),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const accountId: Validator = {
|
export const accountId: Validator = {
|
||||||
|
|||||||
48
src/renderer/components/input/input.validators_test.ts
Normal file
48
src/renderer/components/input/input.validators_test.ts
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import { isEmail, systemName } from "./input.validators";
|
||||||
|
|
||||||
|
describe("input validation tests", () => {
|
||||||
|
describe("isEmail tests", () => {
|
||||||
|
it("should be valid", () => {
|
||||||
|
expect(isEmail.validate("abc@news.com")).toBe(true);
|
||||||
|
expect(isEmail.validate("abc@news.co.uk")).toBe(true);
|
||||||
|
expect(isEmail.validate("abc1.3@news.co.uk")).toBe(true);
|
||||||
|
expect(isEmail.validate("abc1.3@news.name")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should be invalid", () => {
|
||||||
|
expect(isEmail.validate("@news.com")).toBe(false);
|
||||||
|
expect(isEmail.validate("abcnews.co.uk")).toBe(false);
|
||||||
|
expect(isEmail.validate("abc1.3@news")).toBe(false);
|
||||||
|
expect(isEmail.validate("abc1.3@news.name.a.b.c.d.d")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("systemName tests", () => {
|
||||||
|
it("should be valid", () => {
|
||||||
|
expect(systemName.validate("a")).toBe(true);
|
||||||
|
expect(systemName.validate("ab")).toBe(true);
|
||||||
|
expect(systemName.validate("abc")).toBe(true);
|
||||||
|
expect(systemName.validate("1")).toBe(true);
|
||||||
|
expect(systemName.validate("12")).toBe(true);
|
||||||
|
expect(systemName.validate("123")).toBe(true);
|
||||||
|
expect(systemName.validate("1a2")).toBe(true);
|
||||||
|
expect(systemName.validate("1-2")).toBe(true);
|
||||||
|
expect(systemName.validate("1---------------2")).toBe(true);
|
||||||
|
expect(systemName.validate("1---------------2.a")).toBe(true);
|
||||||
|
expect(systemName.validate("1---------------2.a.1")).toBe(true);
|
||||||
|
expect(systemName.validate("1---------------2.9-a.1")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should be invalid", () => {
|
||||||
|
expect(systemName.validate("")).toBe(false);
|
||||||
|
expect(systemName.validate("-")).toBe(false);
|
||||||
|
expect(systemName.validate(".")).toBe(false);
|
||||||
|
expect(systemName.validate("as.")).toBe(false);
|
||||||
|
expect(systemName.validate(".asd")).toBe(false);
|
||||||
|
expect(systemName.validate("a.-")).toBe(false);
|
||||||
|
expect(systemName.validate("a.1-")).toBe(false);
|
||||||
|
expect(systemName.validate("o.2-2.")).toBe(false);
|
||||||
|
expect(systemName.validate("o.2-2....")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -48,11 +48,12 @@ export class MainLayout extends React.Component<Props> {
|
|||||||
render() {
|
render() {
|
||||||
const { className, contentClass, headerClass, tabs, footer, footerClass, children } = this.props;
|
const { className, contentClass, headerClass, tabs, footer, footerClass, children } = this.props;
|
||||||
const routePath = navigation.location.pathname;
|
const routePath = navigation.location.pathname;
|
||||||
|
const cluster = getHostedCluster();
|
||||||
return (
|
return (
|
||||||
<div className={cssNames("MainLayout", className, themeStore.activeTheme.type)}>
|
<div className={cssNames("MainLayout", className, themeStore.activeTheme.type)}>
|
||||||
<header className={cssNames("flex gaps align-center", headerClass)}>
|
<header className={cssNames("flex gaps align-center", headerClass)}>
|
||||||
<span className="cluster">
|
<span className="cluster">
|
||||||
{getHostedCluster().contextName}
|
{cluster.preferences?.clusterName || cluster.contextName}
|
||||||
</span>
|
</span>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|||||||
@ -13,11 +13,21 @@
|
|||||||
padding: $spacing;
|
padding: $spacing;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
> .head-col {
|
||||||
|
position: sticky;
|
||||||
|
border-bottom: 1px solid $grey-800;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
> .content-col {
|
> .content-col {
|
||||||
margin-right: $spacing;
|
margin-right: $spacing;
|
||||||
background-color: var(--clusters-menu-bgc);
|
background-color: var(--clusters-menu-bgc);
|
||||||
border-radius: $radius;
|
border-radius: $radius;
|
||||||
|
|
||||||
|
> div {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
> .error {
|
> .error {
|
||||||
border-radius: $radius;
|
border-radius: $radius;
|
||||||
padding: $padding;
|
padding: $padding;
|
||||||
@ -29,7 +39,24 @@
|
|||||||
border-left: 1px solid #353a3e;
|
border-left: 1px solid #353a3e;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
line-height: 140%;
|
||||||
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
color: $colorInfo;
|
color: $colorInfo;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.centered {
|
||||||
|
.content-col {
|
||||||
|
margin: 0;
|
||||||
|
|
||||||
|
> div {
|
||||||
|
margin: 0 auto;
|
||||||
|
width: 60%;
|
||||||
|
min-width: 570px;
|
||||||
|
max-width: 1000px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -5,23 +5,35 @@ import { cssNames, IClassName } from "../../utils";
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
className?: IClassName;
|
className?: IClassName;
|
||||||
|
header?: React.ReactNode;
|
||||||
|
headerClass?: IClassName;
|
||||||
contentClass?: IClassName;
|
contentClass?: IClassName;
|
||||||
infoPanelClass?: IClassName;
|
infoPanelClass?: IClassName;
|
||||||
infoPanel?: React.ReactNode;
|
infoPanel?: React.ReactNode;
|
||||||
|
centered?: boolean; // Centering content horizontally
|
||||||
}
|
}
|
||||||
|
|
||||||
@observer
|
@observer
|
||||||
export class WizardLayout extends React.Component<Props> {
|
export class WizardLayout extends React.Component<Props> {
|
||||||
render() {
|
render() {
|
||||||
const { className, contentClass, infoPanelClass, infoPanel, children: content } = this.props;
|
const { className, contentClass, infoPanelClass, infoPanel, header, headerClass, centered, children: content } = this.props;
|
||||||
return (
|
return (
|
||||||
<div className={cssNames("WizardLayout", className)}>
|
<div className={cssNames("WizardLayout", { centered }, className)}>
|
||||||
|
{header && (
|
||||||
|
<div className={cssNames("head-col flex gaps align-center", headerClass)}>
|
||||||
|
{header}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className={cssNames("content-col flex column gaps", contentClass)}>
|
<div className={cssNames("content-col flex column gaps", contentClass)}>
|
||||||
{content}
|
<div className="flex column gaps">
|
||||||
</div>
|
{content}
|
||||||
<div className={cssNames("info-col flex column gaps", infoPanelClass)}>
|
</div>
|
||||||
{infoPanel}
|
|
||||||
</div>
|
</div>
|
||||||
|
{infoPanel && (
|
||||||
|
<div className={cssNames("info-col flex column gaps", infoPanelClass)}>
|
||||||
|
{infoPanel}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -23,7 +23,7 @@ html {
|
|||||||
&--is-disabled {
|
&--is-disabled {
|
||||||
opacity: .75;
|
opacity: .75;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
pointer-events: auto;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__control {
|
&__control {
|
||||||
@ -42,6 +42,10 @@ html {
|
|||||||
margin-bottom: 1px;
|
margin-bottom: 1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&__single-value {
|
||||||
|
color: var(--textColorSecondary);
|
||||||
|
}
|
||||||
|
|
||||||
&__indicator {
|
&__indicator {
|
||||||
padding: $padding /2;
|
padding: $padding /2;
|
||||||
opacity: .55;
|
opacity: .55;
|
||||||
|
|||||||
@ -9,6 +9,8 @@ import { _i18n } from "./i18n";
|
|||||||
import { ClusterManager } from "./components/cluster-manager";
|
import { ClusterManager } from "./components/cluster-manager";
|
||||||
import { ErrorBoundary } from "./components/error-boundary";
|
import { ErrorBoundary } from "./components/error-boundary";
|
||||||
import { WhatsNew, whatsNewRoute } from "./components/+whats-new";
|
import { WhatsNew, whatsNewRoute } from "./components/+whats-new";
|
||||||
|
import { Notifications } from "./components/notifications";
|
||||||
|
import { ConfirmDialog } from "./components/confirm-dialog";
|
||||||
|
|
||||||
@observer
|
@observer
|
||||||
export class LensApp extends React.Component {
|
export class LensApp extends React.Component {
|
||||||
@ -23,6 +25,8 @@ export class LensApp extends React.Component {
|
|||||||
<Route component={ClusterManager}/>
|
<Route component={ClusterManager}/>
|
||||||
</Switch>
|
</Switch>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
|
<Notifications/>
|
||||||
|
<ConfirmDialog/>
|
||||||
</Router>
|
</Router>
|
||||||
</I18nProvider>
|
</I18nProvider>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -1,14 +1,24 @@
|
|||||||
// Navigation helpers
|
// Navigation helpers
|
||||||
|
|
||||||
|
import { ipcRenderer } from "electron";
|
||||||
import { compile } from "path-to-regexp"
|
import { compile } from "path-to-regexp"
|
||||||
import { createBrowserHistory, createMemoryHistory, Location, LocationDescriptor } from "history";
|
import { createBrowserHistory, createMemoryHistory, LocationDescriptor } from "history";
|
||||||
import { createObservableHistory } from "mobx-observable-history";
|
import { createObservableHistory } from "mobx-observable-history";
|
||||||
|
import logger from "../main/logger";
|
||||||
|
|
||||||
export const history = typeof window !== "undefined" ? createBrowserHistory() : createMemoryHistory();
|
export const history = typeof window !== "undefined" ? createBrowserHistory() : createMemoryHistory();
|
||||||
export const navigation = createObservableHistory(history);
|
export const navigation = createObservableHistory(history);
|
||||||
|
|
||||||
|
// handle navigation from other process (e.g. system menus in main, common->cluster view interactions)
|
||||||
|
if (ipcRenderer) {
|
||||||
|
ipcRenderer.on("menu:navigate", (event, location: LocationDescriptor) => {
|
||||||
|
logger.info(`[IPC]: ${event.type} ${JSON.stringify(location)}`, event);
|
||||||
|
navigate(location);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export function navigate(location: LocationDescriptor) {
|
export function navigate(location: LocationDescriptor) {
|
||||||
navigation.location = location as Location;
|
navigation.push(location);
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IURLParams<P = {}, Q = {}> {
|
export interface IURLParams<P = {}, Q = {}> {
|
||||||
@ -16,6 +26,8 @@ export interface IURLParams<P = {}, Q = {}> {
|
|||||||
query?: IQueryParams & Q;
|
query?: IQueryParams & Q;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// todo: extract building urls to commons (also used in menu.ts)
|
||||||
|
// fixme: missing types validation for params & query
|
||||||
export function buildURL<P extends object, Q = object>(path: string | string[]) {
|
export function buildURL<P extends object, Q = object>(path: string | string[]) {
|
||||||
const pathBuilder = compile(path.toString());
|
const pathBuilder = compile(path.toString());
|
||||||
return function ({ params, query }: IURLParams<P, Q> = {}) {
|
return function ({ params, query }: IURLParams<P, Q> = {}) {
|
||||||
|
|||||||
@ -1,10 +1,13 @@
|
|||||||
// Helper to convert CPU K8S units to numbers
|
// Helper to convert CPU K8S units to numbers
|
||||||
|
|
||||||
|
const thousand = 1000;
|
||||||
|
const million = thousand * thousand;
|
||||||
|
const shortBillion = thousand * million;
|
||||||
|
|
||||||
export function cpuUnitsToNumber(cpu: string) {
|
export function cpuUnitsToNumber(cpu: string) {
|
||||||
const cpuNum = parseInt(cpu)
|
const cpuNum = parseInt(cpu)
|
||||||
const billion = 1000000 * 1000
|
if (cpu.includes("m")) return cpuNum / thousand
|
||||||
if (cpu.includes("m")) return cpuNum / 1000
|
if (cpu.includes("u")) return cpuNum / million
|
||||||
if (cpu.includes("u")) return cpuNum / 1000000
|
if (cpu.includes("n")) return cpuNum / shortBillion
|
||||||
if (cpu.includes("n")) return cpuNum / billion
|
|
||||||
return parseFloat(cpu)
|
return parseFloat(cpu)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,9 +7,9 @@ export function unitsToBytes(value: string) {
|
|||||||
if (!suffixes.some(suffix => value.includes(suffix))) {
|
if (!suffixes.some(suffix => value.includes(suffix))) {
|
||||||
return parseFloat(value)
|
return parseFloat(value)
|
||||||
}
|
}
|
||||||
const index = suffixes.findIndex(suffix =>
|
|
||||||
suffix == value.replace(/[0-9]|i|\./g, '')
|
const suffix = value.replace(/[0-9]|i|\./g, '');
|
||||||
)
|
const index = suffixes.indexOf(suffix);
|
||||||
return parseInt(
|
return parseInt(
|
||||||
(parseFloat(value) * Math.pow(base, index + 1)).toFixed(1)
|
(parseFloat(value) * Math.pow(base, index + 1)).toFixed(1)
|
||||||
)
|
)
|
||||||
@ -21,8 +21,10 @@ export function bytesToUnits(bytes: number, precision = 1) {
|
|||||||
if (!bytes) {
|
if (!bytes) {
|
||||||
return "N/A"
|
return "N/A"
|
||||||
}
|
}
|
||||||
|
|
||||||
if (index === 0) {
|
if (index === 0) {
|
||||||
return `${bytes}${sizes[index]}`
|
return `${bytes}${sizes[index]}`
|
||||||
}
|
}
|
||||||
|
|
||||||
return `${(bytes / (1024 ** index)).toFixed(precision)}${sizes[index]}i`
|
return `${(bytes / (1024 ** index)).toFixed(precision)}${sizes[index]}i`
|
||||||
}
|
}
|
||||||
|
|||||||
@ -20,3 +20,4 @@ export * from './formatDuration'
|
|||||||
export * from './isReactNode'
|
export * from './isReactNode'
|
||||||
export * from './convertMemory'
|
export * from './convertMemory'
|
||||||
export * from './convertCpu'
|
export * from './convertCpu'
|
||||||
|
export * from './metricUnitsToNumber'
|
||||||
|
|||||||
10
src/renderer/utils/metricUnitsToNumber.ts
Normal file
10
src/renderer/utils/metricUnitsToNumber.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
const base = 1000;
|
||||||
|
const suffixes = ["k", "m", "g", "t", "q"];
|
||||||
|
|
||||||
|
export function metricUnitsToNumber(value: string): number {
|
||||||
|
const suffix = value.toLowerCase().slice(-1);
|
||||||
|
const index = suffixes.indexOf(suffix);
|
||||||
|
return parseInt(
|
||||||
|
(parseFloat(value) * Math.pow(base, index + 1)).toFixed(1)
|
||||||
|
)
|
||||||
|
}
|
||||||
15
src/renderer/utils/metricUnitsToNumber_test.ts
Normal file
15
src/renderer/utils/metricUnitsToNumber_test.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import { metricUnitsToNumber } from "./metricUnitsToNumber";
|
||||||
|
|
||||||
|
describe("metricUnitsToNumber tests", () => {
|
||||||
|
test("plain number", () => {
|
||||||
|
expect(metricUnitsToNumber("124")).toStrictEqual(124);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("with k suffix", () => {
|
||||||
|
expect(metricUnitsToNumber("124k")).toStrictEqual(124000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("with m suffix", () => {
|
||||||
|
expect(metricUnitsToNumber("124m")).toStrictEqual(124000000);
|
||||||
|
});
|
||||||
|
});
|
||||||
3
types/mocks.d.ts
vendored
3
types/mocks.d.ts
vendored
@ -4,6 +4,9 @@ declare module "win-ca"
|
|||||||
declare module "@hapi/call"
|
declare module "@hapi/call"
|
||||||
declare module "@hapi/subtext"
|
declare module "@hapi/subtext"
|
||||||
|
|
||||||
|
// Global path to static assets
|
||||||
|
declare const __static: string;
|
||||||
|
|
||||||
// Support import for custom module extensions
|
// Support import for custom module extensions
|
||||||
// https://www.typescriptlang.org/docs/handbook/modules.html#wildcard-module-declarations
|
// https://www.typescriptlang.org/docs/handbook/modules.html#wildcard-module-declarations
|
||||||
declare module "*.scss" {
|
declare module "*.scss" {
|
||||||
|
|||||||
@ -1,10 +1,10 @@
|
|||||||
import path from "path";
|
import path from "path";
|
||||||
import webpack, { LibraryTarget } from "webpack";
|
import webpack, { LibraryTarget } from "webpack";
|
||||||
import { isDevelopment, outDir } from "./src/common/vars";
|
import { isDevelopment, buildDir } from "./src/common/vars";
|
||||||
|
|
||||||
export const library = "dll"
|
export const library = "dll"
|
||||||
export const libraryTarget: LibraryTarget = "commonjs2"
|
export const libraryTarget: LibraryTarget = "commonjs2"
|
||||||
export const manifestPath = path.resolve(outDir, `${library}.manifest.json`);
|
export const manifestPath = path.resolve(buildDir, `${library}.manifest.json`);
|
||||||
|
|
||||||
export const packages = [
|
export const packages = [
|
||||||
"react", "react-dom",
|
"react", "react-dom",
|
||||||
|
|||||||
@ -1,10 +1,11 @@
|
|||||||
import path from "path";
|
import path from "path";
|
||||||
import webpack from "webpack";
|
import webpack from "webpack";
|
||||||
import ForkTsCheckerPlugin from "fork-ts-checker-webpack-plugin"
|
import ForkTsCheckerPlugin from "fork-ts-checker-webpack-plugin"
|
||||||
import { isDevelopment, isProduction, mainDir, outDir } from "./src/common/vars";
|
import { isDevelopment, isProduction, mainDir, buildDir } from "./src/common/vars";
|
||||||
import nodeExternals from "webpack-node-externals";
|
import nodeExternals from "webpack-node-externals";
|
||||||
|
|
||||||
export default function (): webpack.Configuration {
|
export default function (): webpack.Configuration {
|
||||||
|
console.info('WEBPACK:main', require("./src/common/vars"))
|
||||||
return {
|
return {
|
||||||
context: __dirname,
|
context: __dirname,
|
||||||
target: "electron-main",
|
target: "electron-main",
|
||||||
@ -15,7 +16,7 @@ export default function (): webpack.Configuration {
|
|||||||
main: path.resolve(mainDir, "index.ts"),
|
main: path.resolve(mainDir, "index.ts"),
|
||||||
},
|
},
|
||||||
output: {
|
output: {
|
||||||
path: outDir,
|
path: buildDir,
|
||||||
},
|
},
|
||||||
resolve: {
|
resolve: {
|
||||||
extensions: ['.json', '.js', '.ts']
|
extensions: ['.json', '.js', '.ts']
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user