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
|
||||
coverage/
|
||||
tmp/
|
||||
static/build/client/
|
||||
static/build/**
|
||||
binaries/client/
|
||||
binaries/server/
|
||||
locales/**/**.js
|
||||
|
||||
@ -3,13 +3,13 @@ import { Application } from "spectron";
|
||||
let appPath = ""
|
||||
switch(process.platform) {
|
||||
case "win32":
|
||||
appPath = "./dist/win-unpacked/LensDev.exe"
|
||||
appPath = "./dist/win-unpacked/Lens.exe"
|
||||
break
|
||||
case "linux":
|
||||
appPath = "./dist/linux-unpacked/kontena-lens"
|
||||
break
|
||||
case "darwin":
|
||||
appPath = "./dist/mac/LensDev.app/Contents/MacOS/LensDev"
|
||||
appPath = "./dist/mac/Lens.app/Contents/MacOS/Lens"
|
||||
break
|
||||
}
|
||||
|
||||
@ -20,6 +20,10 @@ export function setup(): Application {
|
||||
path: appPath,
|
||||
startTimeout: 30000,
|
||||
waitTimeout: 30000,
|
||||
chromeDriverArgs: ['remote-debugging-port=9222'],
|
||||
env: {
|
||||
CICD: "true"
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
import { Application } from "spectron"
|
||||
import * as util from "../helpers/utils"
|
||||
import { spawnSync } from "child_process"
|
||||
import { stat } from "fs"
|
||||
|
||||
jest.setTimeout(20000)
|
||||
|
||||
@ -11,19 +10,21 @@ describe("app start", () => {
|
||||
let app: Application
|
||||
const clickWhatsNew = async (app: Application) => {
|
||||
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")
|
||||
}
|
||||
|
||||
const addMinikubeCluster = async (app: Application) => {
|
||||
await app.client.click("a#add-cluster")
|
||||
await app.client.waitUntilTextExists("legend", "Choose config:")
|
||||
await app.client.selectByVisibleText("select#kubecontext-select", "minikube (new)")
|
||||
await app.client.click("button.btn-primary")
|
||||
await app.client.click("div.add-cluster")
|
||||
await app.client.waitUntilTextExists("p", "Choose config")
|
||||
await app.client.click("div#kubecontext-select")
|
||||
await app.client.waitUntilTextExists("div", "minikube")
|
||||
await app.client.click("div.minikube")
|
||||
await app.client.click("button.primary")
|
||||
}
|
||||
|
||||
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()
|
||||
// wait for webview to appear on window count
|
||||
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",
|
||||
"description": "Lens - The Kubernetes IDE",
|
||||
"version": "3.6.0-dev",
|
||||
"main": "out/main.js",
|
||||
"main": "static/build/main.js",
|
||||
"copyright": "© 2020, Lakend Labs, Inc.",
|
||||
"license": "MIT",
|
||||
"author": {
|
||||
@ -12,29 +12,28 @@
|
||||
},
|
||||
"scripts": {
|
||||
"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:renderer": "env DEBUG=true yarn compile:renderer --watch $@",
|
||||
"compile": "concurrently \"yarn i18n:compile\" \"yarn compile:main -p\" \"yarn compile:renderer -p\"",
|
||||
"compile:main": "webpack --progress --config webpack.main.ts",
|
||||
"compile:renderer": "webpack --progress --config webpack.renderer.ts",
|
||||
"compile:dll": "webpack --config webpack.dll.ts",
|
||||
"build:linux": "yarn compile && electron-builder --linux --dir -c.productName=LensDev",
|
||||
"build:mac": "yarn compile && electron-builder --mac --dir -c.productName=LensDev",
|
||||
"build:win": "yarn compile && electron-builder --win --dir -c.productName=LensDev",
|
||||
"compile": "env NODE_ENV=production concurrently yarn:compile:*",
|
||||
"compile:main": "webpack --config webpack.main.ts",
|
||||
"compile:renderer": "webpack --config webpack.renderer.ts",
|
||||
"compile:i18n": "lingui compile",
|
||||
"build:linux": "yarn compile && electron-builder --linux --dir -c.productName=Lens",
|
||||
"build:mac": "yarn compile && electron-builder --mac --dir -c.productName=Lens",
|
||||
"build:win": "yarn compile && electron-builder --win --dir -c.productName=Lens",
|
||||
"test": "jest --env=jsdom src $@",
|
||||
"integration": "jest --coverage integration $@",
|
||||
"dist": "yarn compile && electron-builder -p onTag",
|
||||
"dist:win": "yarn compile && electron-builder -p onTag --x64 --ia32",
|
||||
"dist": "yarn compile && electron-builder --publish onTag",
|
||||
"dist:win": "yarn compile && electron-builder --publish onTag --x64 --ia32",
|
||||
"dist:dir": "yarn dist --dir -c.compression=store -c.mac.identity=null",
|
||||
"postinstall": "patch-package",
|
||||
"i18n:extract": "lingui extract",
|
||||
"i18n:compile": "lingui compile",
|
||||
"download-bins": "concurrently yarn:download:*",
|
||||
"download:kubectl": "yarn run ts-node build/download_kubectl.ts",
|
||||
"download:helm": "yarn run ts-node build/download_helm.ts",
|
||||
"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": {
|
||||
"bundledKubectlVersion": "1.17.4",
|
||||
@ -88,7 +87,7 @@
|
||||
{
|
||||
"from": "static/",
|
||||
"to": "static/",
|
||||
"filter": "**/*"
|
||||
"filter": "!**/main.js"
|
||||
},
|
||||
"LICENSE"
|
||||
],
|
||||
@ -187,11 +186,15 @@
|
||||
"mac-ca": "^1.0.4",
|
||||
"marked": "^1.1.0",
|
||||
"md5-file": "^5.0.0",
|
||||
"mobx": "^5.15.5",
|
||||
"mobx-observable-history": "^1.0.3",
|
||||
"mock-fs": "^4.12.0",
|
||||
"node-machine-id": "^1.1.12",
|
||||
"node-pty": "^0.9.0",
|
||||
"openid-client": "^3.15.2",
|
||||
"path-to-regexp": "^6.1.0",
|
||||
"proper-lockfile": "^4.1.1",
|
||||
"react-router": "^5.2.0",
|
||||
"request": "^2.88.2",
|
||||
"request-promise-native": "^1.0.8",
|
||||
"semver": "^7.3.2",
|
||||
@ -233,7 +236,6 @@
|
||||
"@types/md5-file": "^4.0.2",
|
||||
"@types/mini-css-extract-plugin": "^0.9.1",
|
||||
"@types/react": "^16.9.35",
|
||||
"@types/react-dom": "^16.9.8",
|
||||
"@types/react-router-dom": "^5.1.5",
|
||||
"@types/react-select": "^3.0.13",
|
||||
"@types/react-window": "^1.8.2",
|
||||
@ -265,7 +267,7 @@
|
||||
"css-element-queries": "^1.2.3",
|
||||
"css-loader": "^3.5.3",
|
||||
"dompurify": "^2.0.11",
|
||||
"electron": "^9.1.0",
|
||||
"electron": "^9.1.2",
|
||||
"electron-builder": "^22.7.0",
|
||||
"electron-notarize": "^0.3.0",
|
||||
"electron-rebuild": "^1.11.0",
|
||||
@ -281,15 +283,12 @@
|
||||
"make-plural": "^6.2.1",
|
||||
"material-design-icons": "^3.0.1",
|
||||
"mini-css-extract-plugin": "^0.9.0",
|
||||
"mobx": "^5.15.4",
|
||||
"mobx-observable-history": "^1.0.3",
|
||||
"mobx-react": "^6.2.2",
|
||||
"moment": "^2.26.0",
|
||||
"node-loader": "^0.6.0",
|
||||
"node-sass": "^4.14.1",
|
||||
"nodemon": "^2.0.4",
|
||||
"patch-package": "^6.2.2",
|
||||
"path-to-regexp": "^6.1.0",
|
||||
"postinstall-postinstall": "^2.1.0",
|
||||
"raw-loader": "^4.0.1",
|
||||
"react": "^16.13.1",
|
||||
@ -298,7 +297,7 @@
|
||||
"react-select": "^3.1.0",
|
||||
"react-window": "^1.8.5",
|
||||
"sass-loader": "^8.0.2",
|
||||
"spectron": "^8.0.0",
|
||||
"spectron": "11.0.0",
|
||||
"style-loader": "^1.2.1",
|
||||
"terser-webpack-plugin": "^3.0.3",
|
||||
"ts-jest": "^26.1.0",
|
||||
|
||||
@ -135,8 +135,4 @@ export class BaseStore<T = any> extends Singleton {
|
||||
recurseEverything: true,
|
||||
})
|
||||
}
|
||||
|
||||
* [Symbol.iterator]() {
|
||||
yield* Object.entries(this.toJSON());
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,16 +3,26 @@ import { ClusterId, clusterStore } from "./cluster-store";
|
||||
import { tracker } from "./tracker";
|
||||
|
||||
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({
|
||||
channel: "cluster:activate",
|
||||
handle: async (clusterId: ClusterId = clusterStore.activeClusterId) => {
|
||||
handle: (clusterId: ClusterId) => {
|
||||
return clusterStore.getById(clusterId)?.activate();
|
||||
},
|
||||
}),
|
||||
|
||||
disconnect: createIpcChannel({
|
||||
channel: "cluster:disconnect",
|
||||
handle: (clusterId: ClusterId = clusterStore.activeClusterId) => {
|
||||
handle: (clusterId: ClusterId) => {
|
||||
tracker.event("cluster", "stop");
|
||||
return clusterStore.getById(clusterId)?.disconnect();
|
||||
},
|
||||
@ -23,7 +33,6 @@ export const clusterIpc = {
|
||||
handle: async (clusterId: ClusterId, feature: string, config?: any) => {
|
||||
tracker.event("cluster", "install", feature);
|
||||
const cluster = clusterStore.getById(clusterId);
|
||||
|
||||
if (cluster) {
|
||||
await cluster.installFeature(feature, config)
|
||||
} else {
|
||||
|
||||
@ -62,11 +62,10 @@ export class ClusterStore extends BaseStore<ClusterStoreModel> {
|
||||
migrations: migrations,
|
||||
});
|
||||
if (ipcRenderer) {
|
||||
ipcRenderer.on("cluster:state", (event, clusterState: ClusterState) => {
|
||||
ipcRenderer.on("cluster:state", (event, model: ClusterState) => {
|
||||
this.applyWithoutSync(() => {
|
||||
logger.debug(`[CLUSTER-STORE]: received state update for cluster=${clusterState.id}`, clusterState);
|
||||
const cluster = this.getById(clusterState.id);
|
||||
if (cluster) cluster.updateModel(clusterState)
|
||||
logger.debug(`[CLUSTER-STORE]: received push-state at ${location.host}`, model);
|
||||
this.getById(model.id)?.updateModel(model);
|
||||
})
|
||||
})
|
||||
}
|
||||
@ -84,6 +83,14 @@ export class ClusterStore extends BaseStore<ClusterStoreModel> {
|
||||
return Array.from(this.clusters.values());
|
||||
}
|
||||
|
||||
isActive(id: ClusterId) {
|
||||
return this.activeClusterId === id;
|
||||
}
|
||||
|
||||
setActive(id: ClusterId) {
|
||||
this.activeClusterId = id;
|
||||
}
|
||||
|
||||
hasClusters() {
|
||||
return this.clusters.size > 0;
|
||||
}
|
||||
@ -156,11 +163,6 @@ export class ClusterStore extends BaseStore<ClusterStoreModel> {
|
||||
this.activeClusterId = newClusters.has(activeCluster) ? activeCluster : null;
|
||||
this.clusters.replace(newClusters);
|
||||
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 {
|
||||
|
||||
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 interface IpcHandleOpts {
|
||||
timeout?: number;
|
||||
export interface IpcChannelOptions {
|
||||
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> {
|
||||
(...args: T): any;
|
||||
export function createIpcChannel({ autoBind = true, once, timeout = 0, handle, channel }: IpcChannelOptions) {
|
||||
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
|
||||
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
|
||||
timeout?: number; // todo: add support
|
||||
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;
|
||||
let views = singleView ? [singleView] : webContents.getAllWebContents();
|
||||
if (filter) {
|
||||
@ -31,65 +67,10 @@ export function broadcastIpc({ channel, webContentId, filter, args = [] }: IpcMe
|
||||
}
|
||||
views.forEach(webContent => {
|
||||
const type = webContent.getType();
|
||||
logger.debug(`[IPC]: sending message "${channel}" to ${type}=${webContent.id}`, { args });
|
||||
webContent.send(channel, ...[args].flat());
|
||||
logger.debug(`[IPC]: broadcasting "${channel}" to ${type}=${webContent.id}`, { args });
|
||||
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 { 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 os from "os"
|
||||
import yaml from "js-yaml"
|
||||
import logger from "../main/logger";
|
||||
import fse from "fs-extra"
|
||||
|
||||
function resolveTilde(filePath: string) {
|
||||
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) {
|
||||
return node.status.conditions.filter(c =>
|
||||
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 Singleton from "./utils/singleton";
|
||||
import { userStore } from "./user-store"
|
||||
import logger from "../main/logger";
|
||||
|
||||
export class Tracker extends Singleton {
|
||||
static readonly GA_ID = "UA-159377374-1"
|
||||
@ -40,7 +41,7 @@ export class Tracker extends Singleton {
|
||||
...otherParams,
|
||||
}).send()
|
||||
} 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) {
|
||||
this.newContexts.clear();
|
||||
const localContexts = loadConfig(kubeConfig).getContexts();
|
||||
localContexts.forEach(({ cluster, name }) => {
|
||||
if (!cluster) return;
|
||||
if (!this.seenContexts.has(name)) {
|
||||
this.newContexts.add(name)
|
||||
}
|
||||
})
|
||||
localContexts
|
||||
.filter(ctx => ctx.cluster)
|
||||
.filter(ctx => !this.seenContexts.has(ctx.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.)
|
||||
import packageInfo from "../../package.json"
|
||||
import path from "path";
|
||||
import packageInfo from "../../package.json"
|
||||
import { defineGlobal } from "./utils/defineGlobal";
|
||||
|
||||
export const isMac = process.platform === "darwin"
|
||||
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 appName = `${packageInfo.productName}${isDevelopment ? "Dev" : ""}`
|
||||
export const publicPath = "/build/"
|
||||
|
||||
// System paths
|
||||
// Webpack build paths
|
||||
export const contextDir = process.cwd();
|
||||
export const staticDir = path.join(contextDir, "static");
|
||||
export const outDir = path.join(contextDir, "out");
|
||||
export const buildDir = path.join(contextDir, "static", publicPath);
|
||||
export const mainDir = path.join(contextDir, "src/main");
|
||||
export const rendererDir = path.join(contextDir, "src/renderer");
|
||||
export const htmlTemplate = path.resolve(rendererDir, "template.html");
|
||||
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
|
||||
export const apiPrefix = "/api" // local router 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 { clusterStore } from "./cluster-store"
|
||||
|
||||
@ -22,15 +22,6 @@ export class WorkspaceStore extends BaseStore<WorkspaceStoreModel> {
|
||||
super({
|
||||
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;
|
||||
@ -60,6 +51,10 @@ export class WorkspaceStore extends BaseStore<WorkspaceStoreModel> {
|
||||
|
||||
@action
|
||||
setActive(id = WorkspaceStore.defaultId) {
|
||||
if (!this.getById(id)) {
|
||||
throw new Error(`workspace ${id} doesn't exist`);
|
||||
}
|
||||
|
||||
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 { autorun } from "mobx";
|
||||
import { ClusterId, clusterStore } from "../common/cluster-store"
|
||||
import { Cluster } from "./cluster"
|
||||
import { clusterIpc } from "../common/cluster-ipc";
|
||||
import logger from "./logger";
|
||||
import { apiKubePrefix } from "../common/vars";
|
||||
|
||||
export class ClusterManager {
|
||||
constructor(public readonly port: number) {
|
||||
@ -29,13 +30,6 @@ export class ClusterManager {
|
||||
}, {
|
||||
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() {
|
||||
@ -49,8 +43,23 @@ export class ClusterManager {
|
||||
}
|
||||
|
||||
getClusterForRequest(req: http.IncomingMessage): Cluster {
|
||||
logger.info(`getClusterForRequest(): ${req.headers.host}${req.url}`)
|
||||
const clusterId = req.headers.host.split(".")[0]
|
||||
return this.getCluster(clusterId)
|
||||
let cluster: Cluster = null
|
||||
|
||||
// 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 {
|
||||
public id: ClusterId;
|
||||
public frameId: number;
|
||||
public kubeCtl: Kubectl
|
||||
public contextHandler: ContextHandler;
|
||||
protected kubeconfigManager: KubeconfigManager;
|
||||
@ -67,9 +68,8 @@ export class Cluster implements ClusterModel {
|
||||
@observable allowedNamespaces: string[] = [];
|
||||
@observable allowedResources: string[] = [];
|
||||
|
||||
@computed get host() {
|
||||
const proxyHost = new URL(this.kubeProxyUrl).host;
|
||||
return `${this.id}.${proxyHost}`
|
||||
@computed get available() {
|
||||
return this.accessible && !this.disconnected;
|
||||
}
|
||||
|
||||
constructor(model: ClusterModel) {
|
||||
@ -79,7 +79,7 @@ export class Cluster implements ClusterModel {
|
||||
@action
|
||||
updateModel(model: ClusterModel) {
|
||||
Object.assign(this, model);
|
||||
this.apiUrl = this.getKubeconfig().getCurrentCluster().server;
|
||||
this.apiUrl = this.getKubeconfig().getCurrentCluster()?.server;
|
||||
this.contextName = this.contextName || this.preferences.clusterName;
|
||||
}
|
||||
|
||||
@ -222,8 +222,9 @@ export class Cluster implements ClusterModel {
|
||||
return request(apiUrl, {
|
||||
json: true,
|
||||
timeout: 5000,
|
||||
...options,
|
||||
headers: {
|
||||
Host: this.host, // provide cluster-id for ClusterManager.getClusterForRequest()
|
||||
Host: `${this.id}.${new URL(this.kubeProxyUrl).host}`, // required in ClusterManager.getClusterForRequest()
|
||||
...(options.headers || {}),
|
||||
},
|
||||
})
|
||||
@ -233,6 +234,7 @@ export class Cluster implements ClusterModel {
|
||||
const prometheusPrefix = this.preferences.prometheus?.prefix || "";
|
||||
const metricsPath = `/api/v1/namespaces/${prometheusPath}/proxy${prometheusPrefix}/api/v1/query_range`;
|
||||
return this.k8sRequest(metricsPath, {
|
||||
timeout: 0,
|
||||
resolveWithFullResponse: false,
|
||||
json: true,
|
||||
qs: queryParams,
|
||||
@ -388,8 +390,8 @@ export class Cluster implements ClusterModel {
|
||||
pushState = (state = this.getState()): ClusterState => {
|
||||
logger.debug(`[CLUSTER]: push-state`, state);
|
||||
broadcastIpc({
|
||||
// webContentId: viewId, // todo: send to cluster-view only
|
||||
channel: "cluster:state",
|
||||
frameId: this.frameId,
|
||||
args: [state],
|
||||
});
|
||||
return state;
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
import "../common/system-ca"
|
||||
import "../common/prometheus-providers"
|
||||
import { app, dialog } from "electron"
|
||||
import { appName, staticDir } from "../common/vars";
|
||||
import { appName } from "../common/vars";
|
||||
import path from "path"
|
||||
import { LensProxy } from "./lens-proxy"
|
||||
import { WindowManager } from "./window-manager";
|
||||
@ -19,6 +19,12 @@ import { workspaceStore } from "../common/workspace-store";
|
||||
import { tracker } from "../common/tracker";
|
||||
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 clusterManager: ClusterManager;
|
||||
let proxyServer: LensProxy;
|
||||
@ -30,17 +36,13 @@ if (app.commandLine.getSwitchValue("proxy-server") !== "") {
|
||||
|
||||
async function main() {
|
||||
await shellSync();
|
||||
|
||||
const workingDir = path.join(app.getPath("appData"), appName);
|
||||
app.setName(appName);
|
||||
app.setPath("userData", workingDir);
|
||||
logger.info(`🚀 Starting Lens from "${workingDir}"`)
|
||||
|
||||
tracker.event("app", "start");
|
||||
const updater = new AppUpdater()
|
||||
updater.start();
|
||||
|
||||
registerFileProtocol("static", staticDir);
|
||||
registerFileProtocol("static", __static);
|
||||
|
||||
// find free port
|
||||
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 { autorun } from "mobx";
|
||||
import { WindowManager } from "./window-manager";
|
||||
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 { preferencesURL } from "../renderer/components/+preferences/preferences.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";
|
||||
|
||||
export function initMenu(windowManager: WindowManager) {
|
||||
autorun(() => {
|
||||
logger.debug(`[MENU]: building menu, cluster=${clusterStore.activeClusterId}`);
|
||||
buildMenu(windowManager);
|
||||
autorun(() => buildMenu(windowManager), {
|
||||
delay: 100
|
||||
});
|
||||
}
|
||||
|
||||
export function buildMenu(windowManager: WindowManager) {
|
||||
function macOnly(menuItems: MenuItemConstructorOptions[]): MenuItemConstructorOptions[] {
|
||||
if (!isMac) return [];
|
||||
function ignoreOnMac(menuItems: MenuItemConstructorOptions[]) {
|
||||
if (isMac) return [];
|
||||
return menuItems;
|
||||
}
|
||||
|
||||
const fileMenu: MenuItemConstructorOptions = {
|
||||
label: isMac ? app.getName() : "File",
|
||||
function activeClusterOnly(menuItems: MenuItemConstructorOptions[]) {
|
||||
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: [
|
||||
{
|
||||
label: 'Add Cluster',
|
||||
click() {
|
||||
windowManager.navigateMain(addClusterURL())
|
||||
label: "About Lens",
|
||||
click(menuItem: MenuItem, browserWindow: BrowserWindow) {
|
||||
showAbout(browserWindow)
|
||||
}
|
||||
},
|
||||
...(clusterStore.activeCluster ? [{
|
||||
label: 'Cluster Settings',
|
||||
click() {
|
||||
windowManager.navigateMain(clusterSettingsURL())
|
||||
}
|
||||
}] : []),
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: 'Preferences',
|
||||
click() {
|
||||
windowManager.navigateMain(preferencesURL())
|
||||
navigate(preferencesURL())
|
||||
}
|
||||
},
|
||||
...macOnly([
|
||||
{ type: 'separator' },
|
||||
{ role: 'services' },
|
||||
{ type: 'separator' },
|
||||
{ role: 'hide' },
|
||||
{ role: 'hideOthers' },
|
||||
{ role: 'unhide' },
|
||||
]),
|
||||
{ type: 'separator' },
|
||||
{ role: 'services' },
|
||||
{ type: 'separator' },
|
||||
{ role: 'hide' },
|
||||
{ role: 'hideOthers' },
|
||||
{ role: 'unhide' },
|
||||
{ type: 'separator' },
|
||||
{ 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 = {
|
||||
label: 'Edit',
|
||||
submenu: [
|
||||
@ -71,7 +136,7 @@ export function buildMenu(windowManager: WindowManager) {
|
||||
{ role: 'selectAll' },
|
||||
]
|
||||
};
|
||||
|
||||
mt.push(editMenu)
|
||||
const viewMenu: MenuItemConstructorOptions = {
|
||||
label: 'View',
|
||||
submenu: [
|
||||
@ -79,21 +144,21 @@ export function buildMenu(windowManager: WindowManager) {
|
||||
label: 'Back',
|
||||
accelerator: 'CmdOrCtrl+[',
|
||||
click() {
|
||||
webContents.getFocusedWebContents().executeJavaScript('window.history.back()')
|
||||
webContents.getFocusedWebContents()?.goBack();
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Forward',
|
||||
accelerator: 'CmdOrCtrl+]',
|
||||
click() {
|
||||
webContents.getFocusedWebContents().executeJavaScript('window.history.forward()')
|
||||
webContents.getFocusedWebContents()?.goForward();
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Reload',
|
||||
accelerator: 'CmdOrCtrl+R',
|
||||
click() {
|
||||
webContents.getFocusedWebContents().reload()
|
||||
webContents.getFocusedWebContents()?.reload();
|
||||
}
|
||||
},
|
||||
{ role: 'toggleDevTools' },
|
||||
@ -105,16 +170,11 @@ export function buildMenu(windowManager: WindowManager) {
|
||||
{ role: 'togglefullscreen' }
|
||||
]
|
||||
};
|
||||
mt.push(viewMenu)
|
||||
|
||||
const helpMenu: MenuItemConstructorOptions = {
|
||||
role: 'help',
|
||||
submenu: [
|
||||
{
|
||||
label: "What's new?",
|
||||
click() {
|
||||
windowManager.navigateMain(whatsNewURL())
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "License",
|
||||
click: async () => {
|
||||
@ -134,27 +194,23 @@ export function buildMenu(windowManager: WindowManager) {
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "About Lens",
|
||||
click(menuItem: MenuItem, 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")
|
||||
})
|
||||
label: "What's new?",
|
||||
click() {
|
||||
navigate(whatsNewURL())
|
||||
},
|
||||
},
|
||||
...ignoreOnMac([
|
||||
{
|
||||
label: "About Lens",
|
||||
click(menuItem: MenuItem, browserWindow: BrowserWindow) {
|
||||
showAbout(browserWindow)
|
||||
}
|
||||
}
|
||||
}
|
||||
])
|
||||
]
|
||||
};
|
||||
|
||||
Menu.setApplicationMenu(Menu.buildFromTemplate([
|
||||
fileMenu, editMenu, viewMenu, helpMenu
|
||||
]));
|
||||
mt.push(helpMenu)
|
||||
|
||||
Menu.setApplicationMenu(Menu.buildFromTemplate(mt));
|
||||
}
|
||||
|
||||
@ -4,7 +4,7 @@ import http from "http"
|
||||
import path from "path"
|
||||
import { readFile } from "fs-extra"
|
||||
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";
|
||||
|
||||
export interface RouterRequestOpts {
|
||||
@ -95,14 +95,14 @@ export class Router {
|
||||
}
|
||||
|
||||
async handleStaticFile(filePath: string, res: http.ServerResponse) {
|
||||
const asset = path.join(outDir, filePath);
|
||||
const asset = path.join(__static, filePath);
|
||||
try {
|
||||
const data = await readFile(asset);
|
||||
res.setHeader("Content-Type", this.getMimeType(asset));
|
||||
res.write(data)
|
||||
res.end()
|
||||
} 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))
|
||||
|
||||
// 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
|
||||
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 prometheusProvider: PrometheusProvider
|
||||
try {
|
||||
prometheusPath = await cluster.contextHandler.getPrometheusPath()
|
||||
prometheusProvider = await cluster.contextHandler.getPrometheusProvider()
|
||||
[prometheusPath, prometheusProvider] = await Promise.all([
|
||||
cluster.contextHandler.getPrometheusPath(),
|
||||
cluster.contextHandler.getPrometheusProvider()
|
||||
])
|
||||
} catch {
|
||||
this.respondJson(response, {})
|
||||
return
|
||||
|
||||
@ -14,7 +14,7 @@ class PortForward {
|
||||
return PortForward.portForwards.find((pf) => {
|
||||
return (
|
||||
pf.clusterId == forward.clusterId &&
|
||||
pf.kind == "service" &&
|
||||
pf.kind == forward.kind &&
|
||||
pf.name == forward.name &&
|
||||
pf.namespace == forward.namespace &&
|
||||
pf.port == forward.port
|
||||
@ -42,7 +42,7 @@ class PortForward {
|
||||
"--kubeconfig", this.kubeConfig,
|
||||
"port-forward",
|
||||
"-n", this.namespace,
|
||||
`service/${this.name}`,
|
||||
`${this.kind}/${this.name}`,
|
||||
`${this.localPort}:${this.port}`
|
||||
]
|
||||
|
||||
@ -72,21 +72,22 @@ class PortForward {
|
||||
|
||||
class PortForwardRoute extends LensApi {
|
||||
|
||||
public async routeServicePortForward(request: LensApiRequest) {
|
||||
public async routePortForward(request: LensApiRequest) {
|
||||
const { params, response, cluster} = request
|
||||
const { namespace, port, resourceType, resourceName } = params
|
||||
|
||||
let portForward = PortForward.getPortforward({
|
||||
clusterId: cluster.id, kind: "service", name: params.service,
|
||||
namespace: params.namespace, port: params.port
|
||||
clusterId: cluster.id, kind: resourceType, name: resourceName,
|
||||
namespace: namespace, port: port
|
||||
})
|
||||
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({
|
||||
clusterId: cluster.id,
|
||||
kind: "service",
|
||||
namespace: params.namespace,
|
||||
name: params.service,
|
||||
port: params.port,
|
||||
kind: resourceType,
|
||||
namespace: namespace,
|
||||
name: resourceName,
|
||||
port: port,
|
||||
kubeConfig: cluster.getProxyKubeconfigPath()
|
||||
})
|
||||
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 { observable } from "mobx";
|
||||
import { initMenu } from "./menu";
|
||||
|
||||
export class WindowManager {
|
||||
@ -7,9 +9,9 @@ export class WindowManager {
|
||||
protected splashWindow: BrowserWindow;
|
||||
protected windowState: windowStateKeeper.State;
|
||||
|
||||
constructor(protected proxyPort: number) {
|
||||
initMenu(this);
|
||||
@observable activeClusterId: ClusterId;
|
||||
|
||||
constructor(protected proxyPort: number) {
|
||||
// Manage main window size and position with state persistence
|
||||
this.windowState = windowStateKeeper({
|
||||
defaultHeight: 900,
|
||||
@ -26,6 +28,7 @@ export class WindowManager {
|
||||
backgroundColor: "#1e2124",
|
||||
webPreferences: {
|
||||
nodeIntegration: true,
|
||||
nodeIntegrationInSubFrames: true,
|
||||
enableRemoteModule: true,
|
||||
},
|
||||
});
|
||||
@ -37,20 +40,33 @@ export class WindowManager {
|
||||
shell.openExternal(url);
|
||||
});
|
||||
|
||||
// track visible cluster from ui
|
||||
ipcMain.on("cluster-view:change", (event, clusterId: ClusterId) => {
|
||||
this.activeClusterId = clusterId;
|
||||
});
|
||||
|
||||
// load & show app
|
||||
this.showMain();
|
||||
initMenu(this);
|
||||
}
|
||||
|
||||
// fixme
|
||||
navigateMain(url: string) {
|
||||
this.mainView.webContents.executeJavaScript("console.log('implement me!')")
|
||||
navigate({ url, channel, frameId }: { url: string, channel: string, frameId?: number }) {
|
||||
if (frameId) {
|
||||
this.mainView.webContents.sendToFrame(frameId, channel, url);
|
||||
} else {
|
||||
this.mainView.webContents.send(channel, url);
|
||||
}
|
||||
}
|
||||
|
||||
async showMain() {
|
||||
await this.showSplash();
|
||||
await this.mainView.loadURL(`http://localhost:${this.proxyPort}`)
|
||||
this.mainView.show();
|
||||
this.splashWindow.hide();
|
||||
try {
|
||||
await this.showSplash();
|
||||
await this.mainView.loadURL(`http://localhost:${this.proxyPort}`)
|
||||
this.mainView.show();
|
||||
this.splashWindow.close();
|
||||
} catch (err) {
|
||||
dialog.showErrorBox("ERROR!", err.toString())
|
||||
}
|
||||
}
|
||||
|
||||
async showSplash() {
|
||||
@ -63,6 +79,9 @@ export class WindowManager {
|
||||
frame: false,
|
||||
resizable: false,
|
||||
show: false,
|
||||
webPreferences: {
|
||||
nodeIntegration: true
|
||||
}
|
||||
});
|
||||
await this.splashWindow.loadURL("static://splash.html");
|
||||
}
|
||||
|
||||
@ -50,6 +50,9 @@ export function parseKubeApi(path: string): IKubeApiParsed {
|
||||
apiGroup = left.join("/");
|
||||
} else {
|
||||
switch (left.length) {
|
||||
case 4:
|
||||
[apiGroup, apiVersion, resource, name] = left
|
||||
break;
|
||||
case 2:
|
||||
resource = left.pop();
|
||||
// fallthrough
|
||||
@ -66,7 +69,7 @@ export function parseKubeApi(path: string): IKubeApiParsed {
|
||||
* - `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
|
||||
* seperated by '/'
|
||||
* separated by '/'
|
||||
*
|
||||
* Solution is to create a huristic. Namely:
|
||||
* 1. if '.' in left[0] then apiGroup <- left[0]
|
||||
|
||||
@ -6,6 +6,19 @@ interface 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",
|
||||
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 {
|
||||
.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 { workspaceStore } from "../../../common/workspace-store";
|
||||
import { v4 as uuid } from "uuid"
|
||||
import { navigation } from "../../navigation";
|
||||
import { navigate } from "../../navigation";
|
||||
import { userStore } from "../../../common/user-store";
|
||||
import { clusterViewURL } from "../cluster-manager/cluster-view.route";
|
||||
|
||||
@observer
|
||||
export class AddCluster extends React.Component {
|
||||
@ -70,8 +71,9 @@ export class AddCluster extends React.Component {
|
||||
if (value instanceof KubeConfig) {
|
||||
const context = value.currentContext;
|
||||
const isNew = userStore.newContexts.has(context);
|
||||
const className = `${context} kube-context flex gaps align-center`
|
||||
return (
|
||||
<div className="kube-context flex gaps align-center">
|
||||
<div className={className}>
|
||||
<span>{context}</span>
|
||||
{isNew && <Icon material="fiber_new"/>}
|
||||
</div>
|
||||
@ -102,7 +104,7 @@ export class AddCluster extends React.Component {
|
||||
httpsProxy: proxyServer || undefined,
|
||||
},
|
||||
});
|
||||
navigation.goBack(); // return to previous opened page for the cluster view
|
||||
navigate(clusterViewURL({ params: { clusterId } }))
|
||||
} catch (err) {
|
||||
this.error = String(err);
|
||||
} finally {
|
||||
@ -124,7 +126,7 @@ export class AddCluster extends React.Component {
|
||||
to allow you to operate easily on multiple clusters and/or contexts.
|
||||
</p>
|
||||
<p>
|
||||
For more information on kubeconfig see <a href="https://kubernetes.io/docs/concepts/configuration/organize-cluster-access-kubeconfig/" target="_blank">Kubernetes docs</a>
|
||||
For more information on kubeconfig see <a href="https://kubernetes.io/docs/concepts/configuration/organize-cluster-access-kubeconfig/" target="_blank">Kubernetes docs</a>.
|
||||
</p>
|
||||
<p>
|
||||
NOTE: Any manually added cluster is not merged into your kubeconfig file.
|
||||
@ -137,22 +139,20 @@ export class AddCluster extends React.Component {
|
||||
app.
|
||||
</p>
|
||||
<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>
|
||||
<div>
|
||||
<p>
|
||||
When connecting Lens to OIDC enabled cluster, there's few things you as a user need to take into account.
|
||||
</p>
|
||||
<b>Dedicated refresh token</b>
|
||||
<p>
|
||||
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.
|
||||
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
|
||||
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>
|
||||
</div>
|
||||
<h4>Exec auth plugins</h4>
|
||||
<p>
|
||||
When connecting Lens to OIDC enabled cluster, there's few things you as a user need to take into account.
|
||||
</p>
|
||||
<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.
|
||||
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
|
||||
(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.
|
||||
</p>
|
||||
<h3>Exec auth plugins</h3>
|
||||
<p>
|
||||
When using <a href="https://kubernetes.io/docs/reference/access-authn-authz/authentication/#configuration" target="_blank">exec auth</a> plugins make sure the paths that are used to call
|
||||
any binaries
|
||||
@ -167,12 +167,14 @@ export class AddCluster extends React.Component {
|
||||
return (
|
||||
<WizardLayout className="AddCluster" infoPanel={this.renderInfo()}>
|
||||
<h2><Trans>Add Cluster</Trans></h2>
|
||||
<p>Choose config:</p>
|
||||
<Select
|
||||
placeholder={<Trans>Select kubeconfig</Trans>}
|
||||
value={this.clusterConfig}
|
||||
options={this.clusterOptions}
|
||||
onChange={({ value }: SelectOption) => this.clusterConfig = value}
|
||||
formatOptionLabel={this.formatClusterContextLabel}
|
||||
id="kubecontext-select"
|
||||
/>
|
||||
<div className="cluster-settings">
|
||||
<a href="#" onClick={() => this.showSettings = !this.showSettings}>
|
||||
@ -181,14 +183,15 @@ export class AddCluster extends React.Component {
|
||||
</div>
|
||||
{this.showSettings && (
|
||||
<div className="proxy-settings">
|
||||
<p>HTTP Proxy server. Used for communicating with Kubernetes API.</p>
|
||||
<Input
|
||||
autoFocus
|
||||
placeholder={_i18n._(t`A HTTP proxy server URL (format: http://<address>:<port>)`)}
|
||||
value={this.proxyServer}
|
||||
onChange={value => this.proxyServer = value}
|
||||
theme="round-black"
|
||||
/>
|
||||
<small className="hint">
|
||||
<Trans>HTTP Proxy server. Used for communicating with Kubernetes API.</Trans>
|
||||
{'A HTTP proxy server URL (format: http://<address>:<port>).'}
|
||||
</small>
|
||||
</div>
|
||||
)}
|
||||
@ -197,6 +200,7 @@ export class AddCluster extends React.Component {
|
||||
<p>Kubeconfig:</p>
|
||||
<AceEditor
|
||||
autoFocus
|
||||
showGutter={false}
|
||||
mode="yaml"
|
||||
value={this.customConfig}
|
||||
onChange={value => this.customConfig = value}
|
||||
@ -209,7 +213,7 @@ export class AddCluster extends React.Component {
|
||||
<div className="actions-panel">
|
||||
<Button
|
||||
primary
|
||||
label={<Trans>Add cluster</Trans>}
|
||||
label={<Trans>Add cluster(s)</Trans>}
|
||||
onClick={this.addCluster}
|
||||
waiting={this.isWaiting}
|
||||
/>
|
||||
|
||||
@ -1,8 +1,12 @@
|
||||
import type { IClusterViewRouteParams } from "../cluster-manager/cluster-view.route";
|
||||
import { RouteProps } from "react-router";
|
||||
import { buildURL } from "../../navigation";
|
||||
|
||||
export const clusterSettingsRoute: RouteProps = {
|
||||
path: "/cluster-settings"
|
||||
export interface IClusterSettingsRouteParams extends IClusterViewRouteParams {
|
||||
}
|
||||
|
||||
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 {
|
||||
overflow-y: scroll;
|
||||
.WizardLayout {
|
||||
grid-template-columns: unset;
|
||||
grid-template-rows: 76px 1fr;
|
||||
padding: 0;
|
||||
|
||||
.info-col {
|
||||
display: none;
|
||||
.head-col {
|
||||
justify-content: space-between;
|
||||
|
||||
:nth-child(2) {
|
||||
flex: 1 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
.content-col {
|
||||
margin-right: unset;
|
||||
}
|
||||
margin: 0;
|
||||
padding-top: $padding * 3;
|
||||
background-color: transparent;
|
||||
|
||||
* {
|
||||
margin-top: 40px;
|
||||
.SubTitle {
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
&:first-child {
|
||||
margin-top: 0px;
|
||||
}
|
||||
}
|
||||
> div {
|
||||
margin-top: $margin * 5;
|
||||
}
|
||||
|
||||
h4 {
|
||||
margin-top: 20px;
|
||||
.admin-note {
|
||||
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 {
|
||||
margin-top: 20px;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 3fr;
|
||||
grid-gap: 10px;
|
||||
}
|
||||
margin: $margin * 3 0;
|
||||
|
||||
.loading {
|
||||
margin-top: 20px;
|
||||
text-align: center;
|
||||
.Table {
|
||||
border: 1px solid var(--drawerSubtitleBackground);
|
||||
border-radius: $radius;
|
||||
|
||||
.Spinner {
|
||||
display: inline-block;
|
||||
.TableRow {
|
||||
&:not(:last-of-type) {
|
||||
border-bottom: 1px solid var(--drawerSubtitleBackground);
|
||||
}
|
||||
|
||||
.value {
|
||||
flex-grow: 2;
|
||||
word-break: break-word;
|
||||
color: var(--textColorSecondary);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.Input,.Select {
|
||||
margin-top: 10px;
|
||||
.Input, .Select {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.Icon:not(.updated):not(.clean) {
|
||||
color: #ad0000;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
.Select {
|
||||
&__control {
|
||||
box-shadow: 0 0 0 1px $borderFaintColor;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,24 +1,43 @@
|
||||
import "./cluster-settings.scss"
|
||||
import "./cluster-settings.scss";
|
||||
|
||||
import React from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { observer } from "mobx-react";
|
||||
import { Features } from "./features"
|
||||
import { Removal } from "./removal"
|
||||
import { Status } from "./status"
|
||||
import { General } from "./general"
|
||||
import { getHostedCluster } from "../../../common/cluster-store"
|
||||
import { Features } from "./features";
|
||||
import { Removal } from "./removal";
|
||||
import { Status } from "./status";
|
||||
import { General } from "./general";
|
||||
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
|
||||
export class ClusterSettings extends React.Component {
|
||||
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 (
|
||||
<WizardLayout className="ClusterSettings">
|
||||
<Status cluster={cluster}></Status>
|
||||
<General cluster={cluster}></General>
|
||||
<Features cluster={cluster}></Features>
|
||||
<Removal cluster={cluster}></Removal>
|
||||
</WizardLayout>
|
||||
)
|
||||
<div className="ClusterSettings">
|
||||
<WizardLayout header={header} centered>
|
||||
<Status cluster={cluster}></Status>
|
||||
<General cluster={cluster}></General>
|
||||
<Features cluster={cluster}></Features>
|
||||
<Removal cluster={cluster}></Removal>
|
||||
</WizardLayout>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,86 +1,43 @@
|
||||
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 { observer } from "mobx-react";
|
||||
import { Cluster } from "../../../../main/cluster";
|
||||
import { Input } from "../../input";
|
||||
import { SubTitle } from "../../layout/sub-title";
|
||||
|
||||
interface Props {
|
||||
cluster: Cluster;
|
||||
cluster: Cluster;
|
||||
}
|
||||
|
||||
@observer
|
||||
export class ClusterHomeDirSetting extends React.Component<Props> {
|
||||
@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() {
|
||||
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>
|
||||
<Input
|
||||
theme="round-black"
|
||||
className="box grow"
|
||||
value={this.directory}
|
||||
onSubmit={this.onWorkingDirectorySubmit}
|
||||
onChange={this.onWorkingDirectoryChange}
|
||||
iconRight={this.getIconRight()}
|
||||
placeholder="$HOME"
|
||||
/>
|
||||
</>;
|
||||
}
|
||||
|
||||
@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
|
||||
return (
|
||||
<>
|
||||
<SubTitle title="Working Directory"/>
|
||||
<p>Terminal working directory.</p>
|
||||
<Input
|
||||
theme="round-black"
|
||||
value={this.directory}
|
||||
onChange={this.onChange}
|
||||
onBlur={this.save}
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,16 +1,20 @@
|
||||
import React from "react";
|
||||
import { Cluster } from "../../../../main/cluster";
|
||||
import { clusterStore } from "../../../../common/cluster-store"
|
||||
import { Icon } from "../../icon";
|
||||
import { FilePicker, OverSizeLimitStyle } from "../../file-picker";
|
||||
import { autobind } from "../../../utils";
|
||||
import { Button } from "../../button";
|
||||
import { GeneralInputStatus } from "./statuses"
|
||||
import { observable } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import { SubTitle } from "../../layout/sub-title";
|
||||
import { ClusterIcon } from "../../cluster-icon";
|
||||
|
||||
enum GeneralInputStatus {
|
||||
CLEAN = "clean",
|
||||
ERROR = "error",
|
||||
}
|
||||
|
||||
interface Props {
|
||||
cluster: Cluster;
|
||||
cluster: Cluster;
|
||||
}
|
||||
|
||||
@observer
|
||||
@ -21,7 +25,6 @@ export class ClusterIconSetting extends React.Component<Props> {
|
||||
@autobind()
|
||||
async onIconPick([file]: File[]) {
|
||||
const { cluster } = this.props;
|
||||
|
||||
try {
|
||||
if (file) {
|
||||
const buf = Buffer.from(await file.arrayBuffer());
|
||||
@ -38,35 +41,36 @@ export class ClusterIconSetting extends React.Component<Props> {
|
||||
}
|
||||
|
||||
getClearButton() {
|
||||
const { cluster } = this.props;
|
||||
|
||||
if (cluster.preferences.icon) {
|
||||
return <Button accent onClick={() => this.onIconPick([])}>Clear</Button>
|
||||
if (this.props.cluster.preferences.icon) {
|
||||
return <Button tooltip="Revert back to default icon" accent onClick={() => this.onIconPick([])}>Clear</Button>
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
return <>
|
||||
<h4>Cluster Icon</h4>
|
||||
<p>Set cluster icon. By default it is automatically generated. {this.getIconRight()}</p>
|
||||
<div className="center">
|
||||
<FilePicker
|
||||
accept="image/*"
|
||||
labelText="Browse for new icon..."
|
||||
onOverSizeLimit={OverSizeLimitStyle.FILTER}
|
||||
handler={this.onIconPick}
|
||||
const label = (
|
||||
<>
|
||||
<ClusterIcon
|
||||
cluster={this.props.cluster}
|
||||
showErrors={false}
|
||||
showTooltip={false}
|
||||
/>
|
||||
{this.getClearButton()}
|
||||
</div>
|
||||
</>;
|
||||
}
|
||||
|
||||
getIconRight(): React.ReactNode {
|
||||
switch (this.status) {
|
||||
case GeneralInputStatus.CLEAN:
|
||||
return null;
|
||||
case GeneralInputStatus.ERROR:
|
||||
return <Icon size="16px" material="error" title={this.errorText}></Icon>
|
||||
}
|
||||
{"Browse for new icon..."}
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<SubTitle title="Cluster Icon" />
|
||||
<p>Define cluster icon. By default automatically generated.</p>
|
||||
<div className="file-loader">
|
||||
<FilePicker
|
||||
accept="image/*"
|
||||
label={label}
|
||||
onOverSizeLimit={OverSizeLimitStyle.FILTER}
|
||||
handler={this.onIconPick}
|
||||
/>
|
||||
{this.getClearButton()}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,85 +1,40 @@
|
||||
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 { observer } from "mobx-react";
|
||||
import { SubTitle } from "../../layout/sub-title";
|
||||
import { isRequired } from "../../input/input.validators";
|
||||
|
||||
interface Props {
|
||||
cluster: Cluster;
|
||||
cluster: Cluster;
|
||||
}
|
||||
|
||||
@observer
|
||||
export class ClusterNameSetting extends React.Component<Props> {
|
||||
@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() {
|
||||
return <>
|
||||
<h4>Cluster Name</h4>
|
||||
<p>Change cluster name:</p>
|
||||
<Input
|
||||
theme="round-black"
|
||||
className="box grow"
|
||||
value={this.name}
|
||||
onSubmit={this.onClusterNameSubmit}
|
||||
onChange={this.onClusterNameChange}
|
||||
iconRight={this.getIconRight()}
|
||||
/>
|
||||
</>;
|
||||
}
|
||||
|
||||
@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
|
||||
return (
|
||||
<>
|
||||
<SubTitle title="Cluster Name"/>
|
||||
<p>Define cluster name.</p>
|
||||
<Input
|
||||
theme="round-black"
|
||||
validators={isRequired}
|
||||
value={this.name}
|
||||
onChange={this.onChange}
|
||||
onBlur={this.save}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,41 +1,105 @@
|
||||
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 { 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>[] = [
|
||||
{ value: "", label: "Auto detect" },
|
||||
{ value: "", label: "Auto detect" },
|
||||
...prometheusProviders.map(pp => ({value: pp.id, label: pp.name}))
|
||||
];
|
||||
|
||||
interface Props {
|
||||
cluster: Cluster;
|
||||
cluster: Cluster;
|
||||
}
|
||||
|
||||
@observer
|
||||
export class ClusterPrometheusSetting extends React.Component<Props> {
|
||||
@observable prometheusProvider = this.props.cluster.preferences.prometheusProvider?.type || "";
|
||||
|
||||
render() {
|
||||
return <>
|
||||
<h4>Cluster Prometheus</h4>
|
||||
<p>Use pre-installed Prometheus service for metrics. Please refer to <a href={prometheusGuide}>this guide</a> for possible configuration changes.</p>
|
||||
<Select
|
||||
value={this.prometheusProvider}
|
||||
options={options}
|
||||
onChange={this.changePrometheusProvider}
|
||||
/>
|
||||
</>;
|
||||
@observable path = "";
|
||||
@observable provider = "";
|
||||
|
||||
@computed get canEditPrometheusPath() {
|
||||
if (this.provider === "" || this.provider === "lens") return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
@autobind()
|
||||
changePrometheusProvider({ value: prometheusProvider }: SelectProps<string>) {
|
||||
this.prometheusProvider = prometheusProvider;
|
||||
this.props.cluster.preferences.prometheusProvider = { type: prometheusProvider };
|
||||
componentDidMount() {
|
||||
const { prometheus, prometheusProvider } = this.props.cluster.preferences;
|
||||
if (prometheus) {
|
||||
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 { 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 { 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 {
|
||||
cluster: Cluster;
|
||||
cluster: Cluster;
|
||||
}
|
||||
|
||||
@observer
|
||||
export class ClusterProxySetting extends React.Component<Props> {
|
||||
@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() {
|
||||
return <>
|
||||
<h4>HTTPS Proxy</h4>
|
||||
<p>HTTPS Proxy server. Used for communicating with Kubernetes API.</p>
|
||||
<Input
|
||||
theme="round-black"
|
||||
className="box grow"
|
||||
value={this.proxy}
|
||||
onSubmit={this.updateClusterProxy}
|
||||
onChange={this.changeProxyState}
|
||||
iconRight={this.getIconRight()}
|
||||
placeholder="https://<address>:<port>"
|
||||
/>
|
||||
</>;
|
||||
}
|
||||
|
||||
@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
|
||||
return (
|
||||
<>
|
||||
<SubTitle title="HTTP Proxy"/>
|
||||
<p>HTTP Proxy server. Used for communicating with Kubernetes API.</p>
|
||||
<Input
|
||||
theme="round-black"
|
||||
value={this.proxy}
|
||||
onChange={this.onChange}
|
||||
onBlur={this.save}
|
||||
placeholder="http://<address>:<port>"
|
||||
validators={isUrl}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,36 +1,36 @@
|
||||
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 { 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 {
|
||||
cluster: Cluster;
|
||||
cluster: Cluster;
|
||||
}
|
||||
|
||||
@observer
|
||||
export class ClusterWorkspaceSetting extends React.Component<Props> {
|
||||
@observable workspace = this.props.cluster.workspace;
|
||||
|
||||
render() {
|
||||
return <>
|
||||
<h4>Cluster Workspace</h4>
|
||||
<p>Change cluster workspace:</p>
|
||||
<Select
|
||||
value={workspaceStore.currentWorkspaceId}
|
||||
options={workspaceStore.workspacesList.map(w => ({value: w.id, label: <span>{w.name}</span>}))}
|
||||
onChange={this.changeWorkspace}
|
||||
/>
|
||||
</>;
|
||||
}
|
||||
|
||||
@autobind()
|
||||
changeWorkspace({ value: workspace }: SelectOption<string>) {
|
||||
this.workspace = workspace;
|
||||
this.props.cluster.workspace = workspace;
|
||||
return (
|
||||
<>
|
||||
<SubTitle title="Cluster Workspace"/>
|
||||
<p>
|
||||
Define cluster{" "}
|
||||
<Link to={workspacesURL()}>
|
||||
workspace
|
||||
</Link>.
|
||||
</p>
|
||||
<Select
|
||||
value={this.props.cluster.workspace}
|
||||
onChange={({value}) => this.props.cluster.workspace = value}
|
||||
options={workspaceStore.workspacesList.map(w =>
|
||||
({value: w.id, label: w.name})
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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 { 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 { observer } from "mobx-react";
|
||||
import { clusterIpc } from "../../../../common/cluster-ipc";
|
||||
import { clusterStore } from "../../../../common/cluster-store";
|
||||
import { observable } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import { RemovalStatus } from "./statuses"
|
||||
import { Cluster } from "../../../../main/cluster";
|
||||
import { autobind } from "../../../utils";
|
||||
import { Button } from "../../button";
|
||||
import { ConfirmDialog } from "../../confirm-dialog";
|
||||
|
||||
interface Props {
|
||||
cluster: Cluster;
|
||||
cluster: Cluster;
|
||||
}
|
||||
|
||||
@observer
|
||||
export class RemoveClusterButton extends React.Component<Props> {
|
||||
@observable status = RemovalStatus.PRESENT;
|
||||
@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()
|
||||
@autobind()
|
||||
confirmRemoveCluster() {
|
||||
const { cluster } = this.props;
|
||||
|
||||
ConfirmDialog.open({
|
||||
message: <p>Are you sure you want to remove <b>{cluster.preferences.clusterName}</b> from Lens?</p>,
|
||||
labelOk: <Trans>Yes</Trans>,
|
||||
labelCancel: <Trans>No</Trans>,
|
||||
ok: async () => {
|
||||
try {
|
||||
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();
|
||||
}
|
||||
await clusterStore.removeById(cluster.id);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
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 { Cluster } from "../../../main/cluster";
|
||||
import { InstallMetrics } from "./components/install-metrics";
|
||||
import { InstallUserMode } from "./components/install-user-mode";
|
||||
import { InstallFeature } from "./components/install-feature";
|
||||
import { SubTitle } from "../layout/sub-title";
|
||||
import { MetricsFeature } from "../../../features/metrics";
|
||||
import { UserModeFeature } from "../../../features/user-mode";
|
||||
|
||||
interface Props {
|
||||
cluster: Cluster;
|
||||
@ -11,10 +13,30 @@ export class Features extends React.Component<Props> {
|
||||
render() {
|
||||
const { cluster } = this.props;
|
||||
|
||||
return <div>
|
||||
<h2>Features</h2>
|
||||
<InstallMetrics cluster={cluster}/>
|
||||
<InstallUserMode cluster={cluster}/>
|
||||
</div>;
|
||||
return (
|
||||
<div>
|
||||
<h2>Features</h2>
|
||||
<InstallFeature cluster={cluster} feature={MetricsFeature.id}>
|
||||
<>
|
||||
<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() {
|
||||
return <div>
|
||||
<h2>General</h2>
|
||||
<hr/>
|
||||
|
||||
<ClusterNameSetting cluster={this.props.cluster} />
|
||||
<ClusterWorkspaceSetting 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";
|
||||
|
||||
interface Props {
|
||||
cluster: Cluster;
|
||||
cluster: Cluster;
|
||||
}
|
||||
|
||||
export class Removal extends React.Component<Props> {
|
||||
render() {
|
||||
const { cluster } = this.props;
|
||||
|
||||
return <div>
|
||||
<h2>Removal</h2>
|
||||
<RemoveClusterButton cluster={cluster} />
|
||||
</div>;
|
||||
return (
|
||||
<div>
|
||||
<h2>Removal</h2>
|
||||
<RemoveClusterButton cluster={cluster} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,41 +1,40 @@
|
||||
import React from "react";
|
||||
import { Spinner } from "../spinner";
|
||||
import { Cluster } from "../../../main/cluster";
|
||||
import { SubTitle } from "../layout/sub-title";
|
||||
import { Table, TableCell, TableRow } from "../table";
|
||||
|
||||
interface Props {
|
||||
cluster: Cluster;
|
||||
}
|
||||
|
||||
export class Status extends React.Component<Props> {
|
||||
renderStatusRows(): JSX.Element[] {
|
||||
renderStatusRows() {
|
||||
const { cluster } = this.props;
|
||||
|
||||
const rows: [string, React.ReactNode][] = [
|
||||
["Online Status", cluster.online ? "online" : `offline (${cluster.failureReason || "unknown reason"}`],
|
||||
const rows = [
|
||||
["Online Status", cluster.online ? "online" : `offline (${cluster.failureReason || "unknown reason"})`],
|
||||
["Distribution", cluster.distribution],
|
||||
["Kerbel Version", cluster.version],
|
||||
["API Address", cluster.apiUrl],
|
||||
["Nodes Count", cluster.nodes || "0"]
|
||||
];
|
||||
|
||||
if (cluster.nodes > 0) {
|
||||
rows.push(["Nodes Count", cluster.nodes]);
|
||||
}
|
||||
|
||||
return rows
|
||||
.map(([header, value]) => [
|
||||
<h5 key={header+"-header"}>{header}</h5>,
|
||||
<span key={header + "-value"}>{value}</span>
|
||||
])
|
||||
.flat();
|
||||
return (
|
||||
<Table scrollable={false}>
|
||||
{rows.map(([name, value]) => {
|
||||
return (
|
||||
<TableRow key={name}>
|
||||
<TableCell>{name}</TableCell>
|
||||
<TableCell className="value">{value}</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { cluster } = this.props;
|
||||
|
||||
return <div>
|
||||
<h2>Status</h2>
|
||||
<hr/>
|
||||
<h4>Cluster status</h4>
|
||||
<SubTitle title="Cluster Status"/>
|
||||
<p>
|
||||
Cluster status information including: detected distribution, kernel version, and online status.
|
||||
</p>
|
||||
|
||||
@ -4,7 +4,7 @@ import kebabCase from "lodash/kebabCase";
|
||||
import { observer } from "mobx-react";
|
||||
import { Trans } from "@lingui/macro";
|
||||
import { DrawerItem, DrawerTitle } from "../drawer";
|
||||
import { cpuUnitsToNumber, cssNames, unitsToBytes } from "../../utils";
|
||||
import { cpuUnitsToNumber, cssNames, unitsToBytes, metricUnitsToNumber } from "../../utils";
|
||||
import { KubeObjectDetailsProps } from "../kube-object";
|
||||
import { ResourceQuota, resourceQuotaApi } from "../../api/endpoints/resource-quota.api";
|
||||
import { LineProgress } from "../line-progress";
|
||||
@ -15,24 +15,30 @@ import { KubeObjectMeta } from "../kube-object/kube-object-meta";
|
||||
interface Props extends KubeObjectDetailsProps<ResourceQuota> {
|
||||
}
|
||||
|
||||
@observer
|
||||
export class ResourceQuotaDetails extends React.Component<Props> {
|
||||
renderQuotas = (quota: ResourceQuota) => {
|
||||
const { hard, used } = quota.status
|
||||
if (!hard || !used) return null
|
||||
const transformUnit = (name: string, value: string) => {
|
||||
if (name.includes("memory") || name.includes("storage")) {
|
||||
return unitsToBytes(value)
|
||||
}
|
||||
if (name.includes("cpu")) {
|
||||
return cpuUnitsToNumber(value)
|
||||
}
|
||||
return parseInt(value)
|
||||
}
|
||||
return Object.entries(hard).map(([name, value]) => {
|
||||
if (!used[name]) return null
|
||||
const onlyNumbers = /$[0-9]*^/g;
|
||||
|
||||
function transformUnit(name: string, value: string): number {
|
||||
if (name.includes("memory") || name.includes("storage")) {
|
||||
return unitsToBytes(value)
|
||||
}
|
||||
|
||||
if (name.includes("cpu")) {
|
||||
return cpuUnitsToNumber(value)
|
||||
}
|
||||
|
||||
return metricUnitsToNumber(value);
|
||||
}
|
||||
|
||||
function renderQuotas(quota: ResourceQuota): JSX.Element[] {
|
||||
const { hard = {}, used = {} } = quota.status
|
||||
|
||||
return Object.entries(hard)
|
||||
.filter(([name]) => used[name])
|
||||
.map(([name, value]) => {
|
||||
const current = transformUnit(name, used[name])
|
||||
const max = transformUnit(name, value)
|
||||
const usage = max === 0 ? 100 : Math.ceil(current / max * 100); // special case 0 max as always 100% usage
|
||||
|
||||
return (
|
||||
<div key={name} className={cssNames("param", kebabCase(name))}>
|
||||
<span className="title">{name}</span>
|
||||
@ -41,14 +47,16 @@ export class ResourceQuotaDetails extends React.Component<Props> {
|
||||
max={max}
|
||||
value={current}
|
||||
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>
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@observer
|
||||
export class ResourceQuotaDetails extends React.Component<Props> {
|
||||
render() {
|
||||
const { object: quota } = this.props;
|
||||
if (!quota) return null;
|
||||
@ -57,7 +65,7 @@ export class ResourceQuotaDetails extends React.Component<Props> {
|
||||
<KubeObjectMeta object={quota}/>
|
||||
|
||||
<DrawerItem name={<Trans>Quotas</Trans>} className="quota-list">
|
||||
{this.renderQuotas(quota)}
|
||||
{renderQuotas(quota)}
|
||||
</DrawerItem>
|
||||
|
||||
{quota.getScopeSelector().length > 0 && (
|
||||
|
||||
@ -11,7 +11,7 @@ import { Service, serviceApi, endpointApi } from "../../api/endpoints";
|
||||
import { _i18n } from "../../i18n";
|
||||
import { apiManager } from "../../api/api-manager";
|
||||
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 { ServiceDetailsEndpoint } from "./service-details-endpoint";
|
||||
|
||||
@ -61,7 +61,13 @@ export class ServiceDetails extends React.Component<Props> {
|
||||
)}
|
||||
|
||||
<DrawerItem name={<Trans>Ports</Trans>}>
|
||||
<ServicePorts service={service}/>
|
||||
<div>
|
||||
{
|
||||
service.getPorts().map((port) => (
|
||||
<ServicePortComponent service={service} port={port} key={port.toString()}/>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</DrawerItem>
|
||||
|
||||
{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 {
|
||||
h2 {
|
||||
&:not(:first-child) {
|
||||
margin-top: $padding * 3;
|
||||
position: fixed!important; // Allows to cover ClustersMenu
|
||||
z-index: 1;
|
||||
|
||||
.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 {
|
||||
--flex-gap: #{$padding};
|
||||
.is-mac & {
|
||||
.WizardLayout .head-col {
|
||||
padding-top: 32px;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.repos {
|
||||
--flex-gap: #{$padding};
|
||||
|
||||
> .title {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.Badge {
|
||||
margin: $padding / 2;
|
||||
.Select {
|
||||
&__control {
|
||||
box-shadow: 0 0 0 1px $borderFaintColor;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,5 @@
|
||||
import "./preferences.scss"
|
||||
import React, { Fragment } from "react";
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { action, computed, observable } from "mobx";
|
||||
import { t, Trans } from "@lingui/macro";
|
||||
@ -15,11 +15,12 @@ import { Notifications } from "../notifications";
|
||||
import { Badge } from "../badge";
|
||||
import { Spinner } from "../spinner";
|
||||
import { themeStore } from "../../theme.store";
|
||||
import { history } from "../../navigation";
|
||||
import { Tooltip } from "../tooltip";
|
||||
|
||||
@observer
|
||||
export class Preferences extends React.Component {
|
||||
@observable helmLoading = false;
|
||||
@observable helmUpdating = false;
|
||||
@observable helmRepos: 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>)
|
||||
return;
|
||||
}
|
||||
this.helmUpdating = false;
|
||||
this.helmLoading = true;
|
||||
await this.addRepo(repo);
|
||||
this.helmUpdating = false;
|
||||
this.helmLoading = false;
|
||||
}
|
||||
|
||||
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() {
|
||||
const { preferences } = userStore;
|
||||
const header = (
|
||||
<>
|
||||
<h2>Preferences</h2>
|
||||
<Icon material="close" big onClick={history.goBack}/>
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<WizardLayout className="Preferences" infoPanel={this.renderInfo()}>
|
||||
<h2><Trans>Color Theme</Trans></h2>
|
||||
<Select
|
||||
options={this.themeOptions}
|
||||
value={preferences.colorTheme}
|
||||
onChange={({ value }: SelectOption) => preferences.colorTheme = value}
|
||||
/>
|
||||
<div className="Preferences">
|
||||
<WizardLayout header={header} centered>
|
||||
<h2><Trans>Color Theme</Trans></h2>
|
||||
<Select
|
||||
options={this.themeOptions}
|
||||
value={preferences.colorTheme}
|
||||
onChange={({ value }: SelectOption) => preferences.colorTheme = value}
|
||||
/>
|
||||
|
||||
<h2><Trans>Download Mirror</Trans></h2>
|
||||
<Select
|
||||
placeholder={<Trans>Download mirror for kubectl</Trans>}
|
||||
options={this.downloadMirrorOptions}
|
||||
value={preferences.downloadMirror}
|
||||
onChange={({ value }: SelectOption) => preferences.downloadMirror = value}
|
||||
/>
|
||||
<h2><Trans>Download Mirror</Trans></h2>
|
||||
<Select
|
||||
placeholder={<Trans>Download mirror for kubectl</Trans>}
|
||||
options={this.downloadMirrorOptions}
|
||||
value={preferences.downloadMirror}
|
||||
onChange={({ value }: SelectOption) => preferences.downloadMirror = value}
|
||||
/>
|
||||
|
||||
<h2><Trans>Helm</Trans></h2>
|
||||
<Select
|
||||
placeholder={<Trans>Repositories</Trans>}
|
||||
isLoading={this.helmLoading}
|
||||
isDisabled={this.helmUpdating}
|
||||
options={this.helmOptions}
|
||||
onChange={this.onRepoSelect}
|
||||
formatOptionLabel={this.formatHelmOptionLabel}
|
||||
controlShouldRenderValue={false}
|
||||
/>
|
||||
<div className="repos flex gaps align-center">
|
||||
<div className="title">
|
||||
<Trans>Added repos:</Trans>
|
||||
</div>
|
||||
<div className="repos-list">
|
||||
{this.helmLoading && <Spinner/>}
|
||||
<h2><Trans>Helm</Trans></h2>
|
||||
<Select
|
||||
placeholder={<Trans>Repositories</Trans>}
|
||||
isLoading={this.helmLoading}
|
||||
isDisabled={this.helmLoading}
|
||||
options={this.helmOptions}
|
||||
onChange={this.onRepoSelect}
|
||||
formatOptionLabel={this.formatHelmOptionLabel}
|
||||
controlShouldRenderValue={false}
|
||||
/>
|
||||
<div className="repos flex gaps column">
|
||||
{Array.from(this.helmAddedRepos).map(([name, repo]) => {
|
||||
const tooltipId = `message-${name}`;
|
||||
return (
|
||||
<Badge key={name} className="added-repo flex gaps align-center" title={repo.url}>
|
||||
<span className="repo">{name}</span>
|
||||
<Badge key={name} className="added-repo flex gaps align-center justify-space-between">
|
||||
<span id={tooltipId} className="repo">{name}</span>
|
||||
<Icon
|
||||
material="remove_circle_outline"
|
||||
material="delete"
|
||||
onClick={() => this.removeRepo(repo)}
|
||||
tooltip={<Trans>Remove</Trans>}
|
||||
/>
|
||||
<Tooltip targetId={tooltipId} formatters={{ narrow: true }}>
|
||||
{repo.url}
|
||||
</Tooltip>
|
||||
</Badge>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2><Trans>HTTP Proxy</Trans></h2>
|
||||
<Input
|
||||
placeholder={_i18n._(t`Type HTTP proxy url (example: http://proxy.acme.org:8080)`)}
|
||||
value={preferences.httpsProxy || ""}
|
||||
onChange={v => preferences.httpsProxy = v}
|
||||
/>
|
||||
<small className="hint">
|
||||
<Trans>Proxy is used only for non-cluster communication.</Trans>
|
||||
</small>
|
||||
<h2><Trans>HTTP Proxy</Trans></h2>
|
||||
<Input
|
||||
theme="round-black"
|
||||
placeholder={_i18n._(t`Type HTTP proxy url (example: http://proxy.acme.org:8080)`)}
|
||||
value={preferences.httpsProxy || ""}
|
||||
onChange={v => preferences.httpsProxy = v}
|
||||
/>
|
||||
<small className="hint">
|
||||
<Trans>Proxy is used only for non-cluster communication.</Trans>
|
||||
</small>
|
||||
|
||||
<h2><Trans>Certificate Trust</Trans></h2>
|
||||
<Checkbox
|
||||
label={<Trans>Allow untrusted Certificate Authorities</Trans>}
|
||||
value={preferences.allowUntrustedCAs}
|
||||
onChange={v => preferences.allowUntrustedCAs = v}
|
||||
/>
|
||||
<small className="hint">
|
||||
<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>Does not affect cluster communications!</Trans>
|
||||
</small>
|
||||
<h2><Trans>Certificate Trust</Trans></h2>
|
||||
<Checkbox
|
||||
label={<Trans>Allow untrusted Certificate Authorities</Trans>}
|
||||
value={preferences.allowUntrustedCAs}
|
||||
onChange={v => preferences.allowUntrustedCAs = v}
|
||||
/>
|
||||
<small className="hint">
|
||||
<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>Does not affect cluster communications!</Trans>
|
||||
</small>
|
||||
|
||||
<h2><Trans>Telemetry & Usage Tracking</Trans></h2>
|
||||
<Checkbox
|
||||
label={<Trans>Allow telemetry & usage tracking</Trans>}
|
||||
value={preferences.allowTelemetry}
|
||||
onChange={v => preferences.allowTelemetry = v}
|
||||
/>
|
||||
<small className="hint">
|
||||
<Trans>Telemetry & usage data is collected to continuously improve the Lens experience.</Trans>
|
||||
</small>
|
||||
</WizardLayout>
|
||||
)
|
||||
<h2><Trans>Telemetry & Usage Tracking</Trans></h2>
|
||||
<Checkbox
|
||||
label={<Trans>Allow telemetry & usage tracking</Trans>}
|
||||
value={preferences.allowTelemetry}
|
||||
onChange={v => preferences.allowTelemetry = v}
|
||||
/>
|
||||
<small className="hint">
|
||||
<Trans>Telemetry & usage data is collected to continuously improve the Lens experience.</Trans>
|
||||
</small>
|
||||
</WizardLayout>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -7,12 +7,11 @@ import { userStore } from "../../../common/user-store"
|
||||
import { navigate } from "../../navigation";
|
||||
import { Button } from "../button";
|
||||
import { Trans } from "@lingui/macro";
|
||||
import { staticDir } from "../../../common/vars";
|
||||
import marked from "marked"
|
||||
|
||||
@observer
|
||||
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 = () => {
|
||||
navigate("/");
|
||||
|
||||
@ -98,7 +98,7 @@ export function DeploymentMenu(props: KubeObjectMenuProps<Deployment>) {
|
||||
return (
|
||||
<KubeObjectMenu {...props}>
|
||||
<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>
|
||||
</MenuItem>
|
||||
</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 { Badge } from "../badge";
|
||||
import { ContainerEnvironment } from "./pod-container-env";
|
||||
import { PodContainerPort } from "./pod-container-port";
|
||||
import { ResourceMetrics } from "../resource-metrics";
|
||||
import { IMetrics } from "../../api/endpoints/metrics.api";
|
||||
import { ContainerCharts } from "./container-charts";
|
||||
@ -64,13 +65,10 @@ export class PodDetailsContainer extends React.Component<Props> {
|
||||
{ports && ports.length > 0 &&
|
||||
<DrawerItem name={<Trans>Ports</Trans>}>
|
||||
{
|
||||
ports.map(port => {
|
||||
const { name, containerPort, protocol } = port;
|
||||
const key = `${container.name}-port-${containerPort}-${protocol}`
|
||||
return (
|
||||
<div key={key}>
|
||||
{name ? name + ': ' : ''}{containerPort}/{protocol}
|
||||
</div>
|
||||
ports.map((port) => {
|
||||
const key = `${container.name}-port-${port.containerPort}-${port.protocol}`
|
||||
return(
|
||||
<PodContainerPort pod={pod} port={port} key={key}/>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@ -226,7 +226,7 @@ export class PodLogsDialog extends React.Component<Props> {
|
||||
tooltip={(showTimestamps ? _i18n._(t`Hide`) : _i18n._(t`Show`)) + " " + _i18n._(t`timestamps`)}
|
||||
/>
|
||||
<Icon
|
||||
material="save_alt"
|
||||
material="get_app"
|
||||
onClick={this.downloadLogs}
|
||||
tooltip={_i18n._(t`Save`)}
|
||||
/>
|
||||
|
||||
@ -13,7 +13,6 @@ interface Props {
|
||||
|
||||
export class AppInit extends React.Component<Props> {
|
||||
static async start(rootElem: HTMLElement) {
|
||||
|
||||
render(<AppInit/>, rootElem); // show loading indicator asap
|
||||
await AppInit.readyStateCheck(rootElem); // wait while all good to run
|
||||
}
|
||||
|
||||
@ -82,7 +82,7 @@ hr {
|
||||
h1 {
|
||||
color: white;
|
||||
font-size: 28px;
|
||||
font-weight: 300;
|
||||
font-weight: normal;
|
||||
letter-spacing: -.010em;
|
||||
margin: 0;
|
||||
}
|
||||
@ -99,13 +99,13 @@ h3 {
|
||||
|
||||
h4 {
|
||||
@extend h3;
|
||||
font-size: 16px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
h5 {
|
||||
@extend h4;
|
||||
padding: $padding / 2 0;
|
||||
font-size: 14px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
h6 {
|
||||
|
||||
@ -28,14 +28,21 @@ import { DeploymentScaleDialog } from "./+workloads-deployments/deployment-scale
|
||||
import { CustomResources } from "./+custom-resources/custom-resources";
|
||||
import { crdRoute } from "./+custom-resources";
|
||||
import { isAllowedResource } from "../../common/rbac";
|
||||
import { ClusterSettings, clusterSettingsRoute } from "./+cluster-settings";
|
||||
import { ErrorBoundary } from "./error-boundary";
|
||||
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
|
||||
export class App extends React.Component {
|
||||
static async init() {
|
||||
const clusterId = getHostedClusterId();
|
||||
logger.info(`[APP]: Init dashboard, clusterId=${clusterId}`)
|
||||
await Terminal.preloadFonts()
|
||||
await clusterIpc.init.invokeFromRenderer(clusterId, webFrame.routingId);
|
||||
await getHostedCluster().whenInitialized;
|
||||
}
|
||||
|
||||
get startURL() {
|
||||
@ -52,7 +59,6 @@ export class App extends React.Component {
|
||||
<ErrorBoundary>
|
||||
<Switch>
|
||||
<Route component={Cluster} {...clusterRoute}/>
|
||||
<Route component={ClusterSettings} {...clusterSettingsRoute}/>
|
||||
<Route component={Nodes} {...nodesRoute}/>
|
||||
<Route component={Workloads} {...workloadsRoute}/>
|
||||
<Route component={Config} {...configRoute}/>
|
||||
@ -66,9 +72,9 @@ export class App extends React.Component {
|
||||
<Redirect exact from="/" to={this.startURL}/>
|
||||
<Route component={NotFound}/>
|
||||
</Switch>
|
||||
<KubeObjectDetails/>
|
||||
<Notifications/>
|
||||
<ConfirmDialog/>
|
||||
<KubeObjectDetails/>
|
||||
<KubeConfigDialog/>
|
||||
<AddRoleBindingDialog/>
|
||||
<PodLogsDialog/>
|
||||
|
||||
@ -7,6 +7,12 @@
|
||||
user-select: none;
|
||||
cursor: pointer;
|
||||
|
||||
&.interactive {
|
||||
img {
|
||||
opacity: .55;
|
||||
}
|
||||
}
|
||||
|
||||
&.active, &.interactive:hover {
|
||||
background-color: #fff;
|
||||
|
||||
@ -16,7 +22,6 @@
|
||||
}
|
||||
|
||||
img {
|
||||
opacity: .55;
|
||||
width: var(--size);
|
||||
height: var(--size);
|
||||
}
|
||||
|
||||
@ -42,12 +42,12 @@ export class ClusterIcon extends React.Component<Props> {
|
||||
active: isActive,
|
||||
});
|
||||
return (
|
||||
<div {...elemProps} className={className} id={clusterIconId}>
|
||||
<div {...elemProps} className={className} id={showTooltip ? clusterIconId : null}>
|
||||
{showTooltip && (
|
||||
<Tooltip targetId={clusterIconId}>{clusterName}</Tooltip>
|
||||
)}
|
||||
{icon && <img src={icon} alt={clusterName}/>}
|
||||
{!icon && <Hashicon value={clusterName} options={options}/>}
|
||||
{!icon && <Hashicon value={clusterId} options={options}/>}
|
||||
{showErrors && isAdmin && eventCount > 0 && (
|
||||
<Badge
|
||||
className={cssNames("events-count", errorClass)}
|
||||
|
||||
@ -1,13 +1,28 @@
|
||||
.ClusterManager {
|
||||
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-columns: min-content 1fr;
|
||||
height: 100%;
|
||||
|
||||
#lens-view {
|
||||
main {
|
||||
grid-area: main;
|
||||
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 {
|
||||
|
||||
@ -1,43 +1,68 @@
|
||||
import "./cluster-manager.scss"
|
||||
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 { BottomBar } from "./bottom-bar";
|
||||
import { cssNames, IClassName } from "../../utils";
|
||||
import { ClusterId } from "../../../common/cluster-store";
|
||||
import { Route, Switch } from "react-router";
|
||||
import { LandingPage, landingRoute } from "../+landing-page";
|
||||
import { LandingPage, landingRoute, landingURL } from "../+landing-page";
|
||||
import { Preferences, preferencesRoute } from "../+preferences";
|
||||
import { Workspaces, workspacesRoute } from "../+workspaces";
|
||||
import { AddCluster, addClusterRoute } from "../+add-cluster";
|
||||
import { ClusterStatus } from "./cluster-status";
|
||||
import { clusterStatusRoute } from "./cluster-status.route";
|
||||
|
||||
interface Props {
|
||||
className?: IClassName;
|
||||
contentClass?: IClassName;
|
||||
}
|
||||
import { ClusterView } from "./cluster-view";
|
||||
import { ClusterSettings, clusterSettingsRoute } from "../+cluster-settings";
|
||||
import { clusterViewRoute, clusterViewURL, getMatchedCluster, getMatchedClusterId } from "./cluster-view.route";
|
||||
import { clusterStore } from "../../../common/cluster-store";
|
||||
import { hasLoadedView, initView, lensViews, refreshViews } from "./lens-views";
|
||||
|
||||
@observer
|
||||
export class ClusterManager extends React.Component<Props> {
|
||||
activateView(clusterId: ClusterId) {
|
||||
export class ClusterManager extends React.Component {
|
||||
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() {
|
||||
const { className } = this.props;
|
||||
return (
|
||||
<div className={cssNames("ClusterManager", className)}>
|
||||
<div className="ClusterManager">
|
||||
<div id="draggable-top"/>
|
||||
<div id="lens-view">
|
||||
<main>
|
||||
<div id="lens-views"/>
|
||||
<Switch>
|
||||
<Route component={LandingPage} {...landingRoute}/>
|
||||
<Route component={Preferences} {...preferencesRoute}/>
|
||||
<Route component={Workspaces} {...workspacesRoute}/>
|
||||
<Route component={AddCluster} {...addClusterRoute}/>
|
||||
<Route component={ClusterStatus} {...clusterStatusRoute}/>
|
||||
<Route render={() => <p>Lens</p>}/>
|
||||
<Route component={ClusterView} {...clusterViewRoute}/>
|
||||
<Route component={ClusterSettings} {...clusterSettingsRoute}/>
|
||||
<Redirect exact to={this.startUrl}/>
|
||||
</Switch>
|
||||
</div>
|
||||
</main>
|
||||
<ClustersMenu/>
|
||||
<BottomBar/>
|
||||
</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 {
|
||||
--flex-gap: #{$padding * 2};
|
||||
|
||||
position: relative;
|
||||
min-width: 350px;
|
||||
margin: auto;
|
||||
text-align: center;
|
||||
z-index: 1;
|
||||
|
||||
pre {
|
||||
@include hidden-scrollbar;
|
||||
max-width: 70vw;
|
||||
max-height: 40vh;
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
.Icon {
|
||||
|
||||
@ -2,102 +2,107 @@ import type { KubeAuthProxyLog } from "../../../main/kube-auth-proxy";
|
||||
|
||||
import "./cluster-status.scss"
|
||||
import React from "react";
|
||||
import { disposeOnUnmount, observer } from "mobx-react";
|
||||
import { observer } from "mobx-react";
|
||||
import { ipcRenderer } from "electron";
|
||||
import { autorun, computed, observable } from "mobx";
|
||||
import { computed, observable } from "mobx";
|
||||
import { clusterIpc } from "../../../common/cluster-ipc";
|
||||
import { Icon } from "../icon";
|
||||
import { Button } from "../button";
|
||||
import { cssNames } from "../../utils";
|
||||
import { navigate } from "../../navigation";
|
||||
import { cssNames, IClassName } from "../../utils";
|
||||
import { Cluster } from "../../../main/cluster";
|
||||
import { ClusterId, clusterStore } from "../../../common/cluster-store";
|
||||
import { CubeSpinner } from "../spinner";
|
||||
|
||||
interface Props {
|
||||
className?: IClassName;
|
||||
clusterId: ClusterId;
|
||||
}
|
||||
|
||||
@observer
|
||||
export class ClusterStatus extends React.Component {
|
||||
export class ClusterStatus extends React.Component<Props> {
|
||||
@observable authOutput: KubeAuthProxyLog[] = [];
|
||||
@observable isReconnecting = false;
|
||||
|
||||
// fixme
|
||||
@computed get cluster(): Cluster {
|
||||
return null;
|
||||
get cluster(): Cluster {
|
||||
return clusterStore.getById(this.props.clusterId);
|
||||
}
|
||||
|
||||
@computed get hasErrors(): boolean {
|
||||
return this.authOutput.some(({ error }) => error) || !!this.cluster.failureReason;
|
||||
}
|
||||
|
||||
@disposeOnUnmount
|
||||
autoRedirectToMain = autorun(() => {
|
||||
if (this.cluster.accessible && !this.hasErrors) {
|
||||
navigate("/");
|
||||
}
|
||||
})
|
||||
|
||||
async componentDidMount() {
|
||||
if (this.cluster.disconnected) {
|
||||
return;
|
||||
}
|
||||
this.authOutput = [{ data: "Connecting..." }];
|
||||
ipcRenderer.on(`kube-auth:${this.cluster.id}`, (evt, res: KubeAuthProxyLog) => {
|
||||
this.authOutput.push({
|
||||
data: res.data.trimRight(),
|
||||
error: res.error,
|
||||
});
|
||||
})
|
||||
await this.refreshClusterState();
|
||||
if (!this.cluster.initialized || this.cluster.disconnected) {
|
||||
await this.refreshCluster();
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
ipcRenderer.removeAllListeners(`kube-auth:${this.cluster.id}`);
|
||||
ipcRenderer.removeAllListeners(`kube-auth:${this.props.clusterId}`);
|
||||
}
|
||||
|
||||
async refreshClusterState() {
|
||||
return clusterIpc.activate.invokeFromRenderer();
|
||||
refreshCluster = async () => {
|
||||
await clusterIpc.activate.invokeFromRenderer(this.props.clusterId);
|
||||
}
|
||||
|
||||
reconnect = async () => {
|
||||
this.authOutput = [{ data: "Reconnecting..." }];
|
||||
this.isReconnecting = true;
|
||||
await this.refreshClusterState();
|
||||
await this.refreshCluster();
|
||||
this.isReconnecting = false;
|
||||
}
|
||||
|
||||
render() {
|
||||
renderContent() {
|
||||
const { authOutput, cluster, hasErrors } = this;
|
||||
const isDisconnected = !!cluster.disconnected;
|
||||
const failureReason = cluster.failureReason;
|
||||
const isError = hasErrors || isDisconnected;
|
||||
return (
|
||||
<div className="ClusterStatus flex column gaps">
|
||||
{isError && (
|
||||
<Icon
|
||||
material="cloud_off"
|
||||
className={cssNames({ error: hasErrors })}
|
||||
/>
|
||||
)}
|
||||
<h2>
|
||||
{cluster.contextName}
|
||||
</h2>
|
||||
{!isDisconnected && (
|
||||
if (!hasErrors || this.isReconnecting) {
|
||||
return (
|
||||
<>
|
||||
<CubeSpinner />
|
||||
<pre className="kube-auth-out">
|
||||
<p>{this.isReconnecting ? "Reconnecting..." : "Connecting..."}</p>
|
||||
{authOutput.map(({ data, error }, index) => {
|
||||
return <p key={index} className={cssNames({ error })}>{data}</p>
|
||||
})}
|
||||
</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 && (
|
||||
<div className="failure-reason error">{failureReason}</div>
|
||||
)}
|
||||
{isError && (
|
||||
<Button
|
||||
primary
|
||||
label="Reconnect"
|
||||
className="box center"
|
||||
onClick={this.reconnect}
|
||||
waiting={this.isReconnecting}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
primary
|
||||
label="Reconnect"
|
||||
className="box center"
|
||||
onClick={this.reconnect}
|
||||
waiting={this.isReconnecting}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div className={cssNames("ClusterStatus flex column gaps box center align-center justify-center", this.props.className)}>
|
||||
{this.renderContent()}
|
||||
</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 { ConfirmDialog } from "../confirm-dialog";
|
||||
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
|
||||
|
||||
@ -33,11 +33,8 @@ export class ClustersMenu extends React.Component<Props> {
|
||||
@observable showHint = true;
|
||||
|
||||
showCluster = (clusterId: ClusterId) => {
|
||||
if (clusterStore.activeClusterId === clusterId) {
|
||||
navigate("/"); // redirect to index
|
||||
} else {
|
||||
clusterStore.activeClusterId = clusterId;
|
||||
}
|
||||
clusterStore.setActive(clusterId);
|
||||
navigate(clusterViewURL({ params: { clusterId } }));
|
||||
}
|
||||
|
||||
addCluster = () => {
|
||||
@ -50,16 +47,22 @@ export class ClustersMenu extends React.Component<Props> {
|
||||
|
||||
menu.append(new MenuItem({
|
||||
label: _i18n._(t`Settings`),
|
||||
click: () => navigate(clusterSettingsURL())
|
||||
click: () => {
|
||||
navigate(clusterSettingsURL({
|
||||
params: {
|
||||
clusterId: cluster.id
|
||||
}
|
||||
}))
|
||||
}
|
||||
}));
|
||||
if (cluster.online) {
|
||||
menu.append(new MenuItem({
|
||||
label: _i18n._(t`Disconnect`),
|
||||
click: async () => {
|
||||
await clusterIpc.disconnect.invokeFromRenderer(cluster.id);
|
||||
if (cluster.id === clusterStore.activeClusterId) {
|
||||
navigate(clusterStatusURL());
|
||||
if (clusterStore.isActive(cluster.id)) {
|
||||
navigate(landingURL());
|
||||
}
|
||||
await clusterIpc.disconnect.invokeFromRenderer(cluster.id);
|
||||
}
|
||||
}))
|
||||
}
|
||||
@ -72,7 +75,10 @@ export class ClustersMenu extends React.Component<Props> {
|
||||
accent: true,
|
||||
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>,
|
||||
})
|
||||
}
|
||||
@ -110,7 +116,7 @@ export class ClustersMenu extends React.Component<Props> {
|
||||
key={cluster.id}
|
||||
showErrors={true}
|
||||
cluster={cluster}
|
||||
isActive={cluster.id === clusterStore.activeClusterId}
|
||||
isActive={cluster.id === getMatchedClusterId()}
|
||||
onClick={() => this.showCluster(cluster.id)}
|
||||
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 {
|
||||
input[type="file"] {
|
||||
display: none;
|
||||
}
|
||||
input[type="file"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
label {
|
||||
display: inline-block;
|
||||
border: medium solid;
|
||||
padding: 10px;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
margin: 5px;
|
||||
}
|
||||
label {
|
||||
display: inline-flex;
|
||||
cursor: pointer;
|
||||
color: var(--blue);
|
||||
}
|
||||
}
|
||||
@ -43,7 +43,7 @@ export enum OverTotalSizeLimitStyle {
|
||||
|
||||
export interface BaseProps {
|
||||
accept?: string;
|
||||
labelText: string;
|
||||
label: React.ReactNode;
|
||||
multiple?: boolean;
|
||||
|
||||
// limit is the optional maximum number of files to upload
|
||||
@ -175,10 +175,10 @@ export class FilePicker extends React.Component<Props> {
|
||||
}
|
||||
|
||||
render() {
|
||||
const { accept, labelText, multiple } = this.props;
|
||||
const { accept, label, multiple } = this.props;
|
||||
|
||||
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
|
||||
id="file-upload"
|
||||
name="FilePicker"
|
||||
|
||||
@ -74,7 +74,7 @@
|
||||
|
||||
.input-info {
|
||||
.errors {
|
||||
color: var(color-error);
|
||||
color: var(--colorError);
|
||||
font-size: $font-size-small;
|
||||
}
|
||||
|
||||
|
||||
@ -285,6 +285,7 @@ export class Input extends React.Component<InputProps, State> {
|
||||
rows: multiLine ? (rows || 1) : null,
|
||||
ref: this.bindRef,
|
||||
type: "text",
|
||||
spellCheck: "false",
|
||||
});
|
||||
|
||||
return (
|
||||
|
||||
@ -38,7 +38,7 @@ export const isNumber: Validator = {
|
||||
export const isUrl: Validator = {
|
||||
condition: ({ type }) => type === "url",
|
||||
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 = {
|
||||
@ -53,9 +53,10 @@ export const maxLength: Validator = {
|
||||
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 = {
|
||||
message: () => _i18n._(t`This field must contain only lowercase latin characters, numbers and dash.`),
|
||||
validate: value => !!value.match(/^[a-z0-9-]+$/),
|
||||
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(systemNameMatcher),
|
||||
};
|
||||
|
||||
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() {
|
||||
const { className, contentClass, headerClass, tabs, footer, footerClass, children } = this.props;
|
||||
const routePath = navigation.location.pathname;
|
||||
const cluster = getHostedCluster();
|
||||
return (
|
||||
<div className={cssNames("MainLayout", className, themeStore.activeTheme.type)}>
|
||||
<header className={cssNames("flex gaps align-center", headerClass)}>
|
||||
<span className="cluster">
|
||||
{getHostedCluster().contextName}
|
||||
{cluster.preferences?.clusterName || cluster.contextName}
|
||||
</span>
|
||||
</header>
|
||||
|
||||
|
||||
@ -13,11 +13,21 @@
|
||||
padding: $spacing;
|
||||
}
|
||||
|
||||
> .head-col {
|
||||
position: sticky;
|
||||
border-bottom: 1px solid $grey-800;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
> .content-col {
|
||||
margin-right: $spacing;
|
||||
background-color: var(--clusters-menu-bgc);
|
||||
border-radius: $radius;
|
||||
|
||||
> div {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
> .error {
|
||||
border-radius: $radius;
|
||||
padding: $padding;
|
||||
@ -29,7 +39,24 @@
|
||||
border-left: 1px solid #353a3e;
|
||||
}
|
||||
|
||||
p {
|
||||
line-height: 140%;
|
||||
}
|
||||
|
||||
a {
|
||||
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 {
|
||||
className?: IClassName;
|
||||
header?: React.ReactNode;
|
||||
headerClass?: IClassName;
|
||||
contentClass?: IClassName;
|
||||
infoPanelClass?: IClassName;
|
||||
infoPanel?: React.ReactNode;
|
||||
centered?: boolean; // Centering content horizontally
|
||||
}
|
||||
|
||||
@observer
|
||||
export class WizardLayout extends React.Component<Props> {
|
||||
render() {
|
||||
const { className, contentClass, infoPanelClass, infoPanel, children: content } = this.props;
|
||||
const { className, contentClass, infoPanelClass, infoPanel, header, headerClass, centered, children: content } = this.props;
|
||||
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)}>
|
||||
{content}
|
||||
</div>
|
||||
<div className={cssNames("info-col flex column gaps", infoPanelClass)}>
|
||||
{infoPanel}
|
||||
<div className="flex column gaps">
|
||||
{content}
|
||||
</div>
|
||||
</div>
|
||||
{infoPanel && (
|
||||
<div className={cssNames("info-col flex column gaps", infoPanelClass)}>
|
||||
{infoPanel}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -23,7 +23,7 @@ html {
|
||||
&--is-disabled {
|
||||
opacity: .75;
|
||||
cursor: not-allowed;
|
||||
pointer-events: auto;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&__control {
|
||||
@ -42,6 +42,10 @@ html {
|
||||
margin-bottom: 1px;
|
||||
}
|
||||
|
||||
&__single-value {
|
||||
color: var(--textColorSecondary);
|
||||
}
|
||||
|
||||
&__indicator {
|
||||
padding: $padding /2;
|
||||
opacity: .55;
|
||||
|
||||
@ -9,6 +9,8 @@ import { _i18n } from "./i18n";
|
||||
import { ClusterManager } from "./components/cluster-manager";
|
||||
import { ErrorBoundary } from "./components/error-boundary";
|
||||
import { WhatsNew, whatsNewRoute } from "./components/+whats-new";
|
||||
import { Notifications } from "./components/notifications";
|
||||
import { ConfirmDialog } from "./components/confirm-dialog";
|
||||
|
||||
@observer
|
||||
export class LensApp extends React.Component {
|
||||
@ -23,6 +25,8 @@ export class LensApp extends React.Component {
|
||||
<Route component={ClusterManager}/>
|
||||
</Switch>
|
||||
</ErrorBoundary>
|
||||
<Notifications/>
|
||||
<ConfirmDialog/>
|
||||
</Router>
|
||||
</I18nProvider>
|
||||
)
|
||||
|
||||
@ -1,14 +1,24 @@
|
||||
// Navigation helpers
|
||||
|
||||
import { ipcRenderer } from "electron";
|
||||
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 logger from "../main/logger";
|
||||
|
||||
export const history = typeof window !== "undefined" ? createBrowserHistory() : createMemoryHistory();
|
||||
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) {
|
||||
navigation.location = location as Location;
|
||||
navigation.push(location);
|
||||
}
|
||||
|
||||
export interface IURLParams<P = {}, Q = {}> {
|
||||
@ -16,6 +26,8 @@ export interface IURLParams<P = {}, 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[]) {
|
||||
const pathBuilder = compile(path.toString());
|
||||
return function ({ params, query }: IURLParams<P, Q> = {}) {
|
||||
|
||||
@ -1,10 +1,13 @@
|
||||
// Helper to convert CPU K8S units to numbers
|
||||
|
||||
const thousand = 1000;
|
||||
const million = thousand * thousand;
|
||||
const shortBillion = thousand * million;
|
||||
|
||||
export function cpuUnitsToNumber(cpu: string) {
|
||||
const cpuNum = parseInt(cpu)
|
||||
const billion = 1000000 * 1000
|
||||
if (cpu.includes("m")) return cpuNum / 1000
|
||||
if (cpu.includes("u")) return cpuNum / 1000000
|
||||
if (cpu.includes("n")) return cpuNum / billion
|
||||
if (cpu.includes("m")) return cpuNum / thousand
|
||||
if (cpu.includes("u")) return cpuNum / million
|
||||
if (cpu.includes("n")) return cpuNum / shortBillion
|
||||
return parseFloat(cpu)
|
||||
}
|
||||
}
|
||||
|
||||
@ -7,9 +7,9 @@ export function unitsToBytes(value: string) {
|
||||
if (!suffixes.some(suffix => value.includes(suffix))) {
|
||||
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(
|
||||
(parseFloat(value) * Math.pow(base, index + 1)).toFixed(1)
|
||||
)
|
||||
@ -21,8 +21,10 @@ export function bytesToUnits(bytes: number, precision = 1) {
|
||||
if (!bytes) {
|
||||
return "N/A"
|
||||
}
|
||||
|
||||
if (index === 0) {
|
||||
return `${bytes}${sizes[index]}`
|
||||
}
|
||||
|
||||
return `${(bytes / (1024 ** index)).toFixed(precision)}${sizes[index]}i`
|
||||
}
|
||||
}
|
||||
|
||||
@ -20,3 +20,4 @@ export * from './formatDuration'
|
||||
export * from './isReactNode'
|
||||
export * from './convertMemory'
|
||||
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/subtext"
|
||||
|
||||
// Global path to static assets
|
||||
declare const __static: string;
|
||||
|
||||
// Support import for custom module extensions
|
||||
// https://www.typescriptlang.org/docs/handbook/modules.html#wildcard-module-declarations
|
||||
declare module "*.scss" {
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
import path from "path";
|
||||
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 libraryTarget: LibraryTarget = "commonjs2"
|
||||
export const manifestPath = path.resolve(outDir, `${library}.manifest.json`);
|
||||
export const manifestPath = path.resolve(buildDir, `${library}.manifest.json`);
|
||||
|
||||
export const packages = [
|
||||
"react", "react-dom",
|
||||
|
||||
@ -1,10 +1,11 @@
|
||||
import path from "path";
|
||||
import webpack from "webpack";
|
||||
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";
|
||||
|
||||
export default function (): webpack.Configuration {
|
||||
console.info('WEBPACK:main', require("./src/common/vars"))
|
||||
return {
|
||||
context: __dirname,
|
||||
target: "electron-main",
|
||||
@ -15,7 +16,7 @@ export default function (): webpack.Configuration {
|
||||
main: path.resolve(mainDir, "index.ts"),
|
||||
},
|
||||
output: {
|
||||
path: outDir,
|
||||
path: buildDir,
|
||||
},
|
||||
resolve: {
|
||||
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