1
0
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:
Lauri Nevala 2020-08-18 15:25:49 +03:00
parent 7d3e87685b
commit 73cb86583a
102 changed files with 2845 additions and 2015 deletions

2
.gitignore vendored
View File

@ -5,7 +5,7 @@ node_modules/
yarn-error.log yarn-error.log
coverage/ coverage/
tmp/ tmp/
static/build/client/ static/build/**
binaries/client/ binaries/client/
binaries/server/ binaries/server/
locales/**/**.js locales/**/**.js

View File

@ -3,13 +3,13 @@ import { Application } from "spectron";
let appPath = "" let appPath = ""
switch(process.platform) { switch(process.platform) {
case "win32": case "win32":
appPath = "./dist/win-unpacked/LensDev.exe" appPath = "./dist/win-unpacked/Lens.exe"
break break
case "linux": case "linux":
appPath = "./dist/linux-unpacked/kontena-lens" appPath = "./dist/linux-unpacked/kontena-lens"
break break
case "darwin": case "darwin":
appPath = "./dist/mac/LensDev.app/Contents/MacOS/LensDev" appPath = "./dist/mac/Lens.app/Contents/MacOS/Lens"
break break
} }
@ -20,6 +20,10 @@ export function setup(): Application {
path: appPath, path: appPath,
startTimeout: 30000, startTimeout: 30000,
waitTimeout: 30000, waitTimeout: 30000,
chromeDriverArgs: ['remote-debugging-port=9222'],
env: {
CICD: "true"
}
}) })
} }

View File

@ -1,7 +1,6 @@
import { Application } from "spectron" import { Application } from "spectron"
import * as util from "../helpers/utils" import * as util from "../helpers/utils"
import { spawnSync } from "child_process" import { spawnSync } from "child_process"
import { stat } from "fs"
jest.setTimeout(20000) jest.setTimeout(20000)
@ -11,19 +10,21 @@ describe("app start", () => {
let app: Application let app: Application
const clickWhatsNew = async (app: Application) => { const clickWhatsNew = async (app: Application) => {
await app.client.waitUntilTextExists("h1", "What's new") await app.client.waitUntilTextExists("h1", "What's new")
await app.client.click("button.btn-primary") await app.client.click("button.primary")
await app.client.waitUntilTextExists("h1", "Welcome") await app.client.waitUntilTextExists("h1", "Welcome")
} }
const addMinikubeCluster = async (app: Application) => { const addMinikubeCluster = async (app: Application) => {
await app.client.click("a#add-cluster") await app.client.click("div.add-cluster")
await app.client.waitUntilTextExists("legend", "Choose config:") await app.client.waitUntilTextExists("p", "Choose config")
await app.client.selectByVisibleText("select#kubecontext-select", "minikube (new)") await app.client.click("div#kubecontext-select")
await app.client.click("button.btn-primary") await app.client.waitUntilTextExists("div", "minikube")
await app.client.click("div.minikube")
await app.client.click("button.primary")
} }
const waitForMinikubeDashboard = async (app: Application) => { const waitForMinikubeDashboard = async (app: Application) => {
await app.client.waitUntilTextExists("pre.auth-output", "Authentication proxy started") await app.client.waitUntilTextExists("pre.kube-auth-out", "Authentication proxy started")
let windowCount = await app.client.getWindowCount() let windowCount = await app.client.getWindowCount()
// wait for webview to appear on window count // wait for webview to appear on window count
while (windowCount == 1) { while (windowCount == 1) {

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -3,7 +3,7 @@
"productName": "Lens", "productName": "Lens",
"description": "Lens - The Kubernetes IDE", "description": "Lens - The Kubernetes IDE",
"version": "3.6.0-dev", "version": "3.6.0-dev",
"main": "out/main.js", "main": "static/build/main.js",
"copyright": "© 2020, Lakend Labs, Inc.", "copyright": "© 2020, Lakend Labs, Inc.",
"license": "MIT", "license": "MIT",
"author": { "author": {
@ -12,29 +12,28 @@
}, },
"scripts": { "scripts": {
"dev": "concurrently -k \"yarn dev-run -C\" \"yarn dev:main\" \"yarn dev:renderer\"", "dev": "concurrently -k \"yarn dev-run -C\" \"yarn dev:main\" \"yarn dev:renderer\"",
"dev-run": "nodemon --watch out/main.* --exec \"electron --inspect .\" $@", "dev-run": "nodemon --watch static/build/main.js --exec \"electron --inspect .\" $@",
"dev:main": "env DEBUG=true yarn compile:main --watch $@", "dev:main": "env DEBUG=true yarn compile:main --watch $@",
"dev:renderer": "env DEBUG=true yarn compile:renderer --watch $@", "dev:renderer": "env DEBUG=true yarn compile:renderer --watch $@",
"compile": "concurrently \"yarn i18n:compile\" \"yarn compile:main -p\" \"yarn compile:renderer -p\"", "compile": "env NODE_ENV=production concurrently yarn:compile:*",
"compile:main": "webpack --progress --config webpack.main.ts", "compile:main": "webpack --config webpack.main.ts",
"compile:renderer": "webpack --progress --config webpack.renderer.ts", "compile:renderer": "webpack --config webpack.renderer.ts",
"compile:dll": "webpack --config webpack.dll.ts", "compile:i18n": "lingui compile",
"build:linux": "yarn compile && electron-builder --linux --dir -c.productName=LensDev", "build:linux": "yarn compile && electron-builder --linux --dir -c.productName=Lens",
"build:mac": "yarn compile && electron-builder --mac --dir -c.productName=LensDev", "build:mac": "yarn compile && electron-builder --mac --dir -c.productName=Lens",
"build:win": "yarn compile && electron-builder --win --dir -c.productName=LensDev", "build:win": "yarn compile && electron-builder --win --dir -c.productName=Lens",
"test": "jest --env=jsdom src $@", "test": "jest --env=jsdom src $@",
"integration": "jest --coverage integration $@", "integration": "jest --coverage integration $@",
"dist": "yarn compile && electron-builder -p onTag", "dist": "yarn compile && electron-builder --publish onTag",
"dist:win": "yarn compile && electron-builder -p onTag --x64 --ia32", "dist:win": "yarn compile && electron-builder --publish onTag --x64 --ia32",
"dist:dir": "yarn dist --dir -c.compression=store -c.mac.identity=null", "dist:dir": "yarn dist --dir -c.compression=store -c.mac.identity=null",
"postinstall": "patch-package", "postinstall": "patch-package",
"i18n:extract": "lingui extract", "i18n:extract": "lingui extract",
"i18n:compile": "lingui compile",
"download-bins": "concurrently yarn:download:*", "download-bins": "concurrently yarn:download:*",
"download:kubectl": "yarn run ts-node build/download_kubectl.ts", "download:kubectl": "yarn run ts-node build/download_kubectl.ts",
"download:helm": "yarn run ts-node build/download_helm.ts", "download:helm": "yarn run ts-node build/download_helm.ts",
"lint": "eslint $@ --ext js,ts,tsx --max-warnings=0 src/", "lint": "eslint $@ --ext js,ts,tsx --max-warnings=0 src/",
"rebuild-pty": "electron-rebuild -f -w node-pty" "rebuild-pty": "yarn run electron-rebuild -f -w node-pty"
}, },
"config": { "config": {
"bundledKubectlVersion": "1.17.4", "bundledKubectlVersion": "1.17.4",
@ -88,7 +87,7 @@
{ {
"from": "static/", "from": "static/",
"to": "static/", "to": "static/",
"filter": "**/*" "filter": "!**/main.js"
}, },
"LICENSE" "LICENSE"
], ],
@ -187,11 +186,15 @@
"mac-ca": "^1.0.4", "mac-ca": "^1.0.4",
"marked": "^1.1.0", "marked": "^1.1.0",
"md5-file": "^5.0.0", "md5-file": "^5.0.0",
"mobx": "^5.15.5",
"mobx-observable-history": "^1.0.3",
"mock-fs": "^4.12.0", "mock-fs": "^4.12.0",
"node-machine-id": "^1.1.12", "node-machine-id": "^1.1.12",
"node-pty": "^0.9.0", "node-pty": "^0.9.0",
"openid-client": "^3.15.2", "openid-client": "^3.15.2",
"path-to-regexp": "^6.1.0",
"proper-lockfile": "^4.1.1", "proper-lockfile": "^4.1.1",
"react-router": "^5.2.0",
"request": "^2.88.2", "request": "^2.88.2",
"request-promise-native": "^1.0.8", "request-promise-native": "^1.0.8",
"semver": "^7.3.2", "semver": "^7.3.2",
@ -233,7 +236,6 @@
"@types/md5-file": "^4.0.2", "@types/md5-file": "^4.0.2",
"@types/mini-css-extract-plugin": "^0.9.1", "@types/mini-css-extract-plugin": "^0.9.1",
"@types/react": "^16.9.35", "@types/react": "^16.9.35",
"@types/react-dom": "^16.9.8",
"@types/react-router-dom": "^5.1.5", "@types/react-router-dom": "^5.1.5",
"@types/react-select": "^3.0.13", "@types/react-select": "^3.0.13",
"@types/react-window": "^1.8.2", "@types/react-window": "^1.8.2",
@ -265,7 +267,7 @@
"css-element-queries": "^1.2.3", "css-element-queries": "^1.2.3",
"css-loader": "^3.5.3", "css-loader": "^3.5.3",
"dompurify": "^2.0.11", "dompurify": "^2.0.11",
"electron": "^9.1.0", "electron": "^9.1.2",
"electron-builder": "^22.7.0", "electron-builder": "^22.7.0",
"electron-notarize": "^0.3.0", "electron-notarize": "^0.3.0",
"electron-rebuild": "^1.11.0", "electron-rebuild": "^1.11.0",
@ -281,15 +283,12 @@
"make-plural": "^6.2.1", "make-plural": "^6.2.1",
"material-design-icons": "^3.0.1", "material-design-icons": "^3.0.1",
"mini-css-extract-plugin": "^0.9.0", "mini-css-extract-plugin": "^0.9.0",
"mobx": "^5.15.4",
"mobx-observable-history": "^1.0.3",
"mobx-react": "^6.2.2", "mobx-react": "^6.2.2",
"moment": "^2.26.0", "moment": "^2.26.0",
"node-loader": "^0.6.0", "node-loader": "^0.6.0",
"node-sass": "^4.14.1", "node-sass": "^4.14.1",
"nodemon": "^2.0.4", "nodemon": "^2.0.4",
"patch-package": "^6.2.2", "patch-package": "^6.2.2",
"path-to-regexp": "^6.1.0",
"postinstall-postinstall": "^2.1.0", "postinstall-postinstall": "^2.1.0",
"raw-loader": "^4.0.1", "raw-loader": "^4.0.1",
"react": "^16.13.1", "react": "^16.13.1",
@ -298,7 +297,7 @@
"react-select": "^3.1.0", "react-select": "^3.1.0",
"react-window": "^1.8.5", "react-window": "^1.8.5",
"sass-loader": "^8.0.2", "sass-loader": "^8.0.2",
"spectron": "^8.0.0", "spectron": "11.0.0",
"style-loader": "^1.2.1", "style-loader": "^1.2.1",
"terser-webpack-plugin": "^3.0.3", "terser-webpack-plugin": "^3.0.3",
"ts-jest": "^26.1.0", "ts-jest": "^26.1.0",

View File

@ -135,8 +135,4 @@ export class BaseStore<T = any> extends Singleton {
recurseEverything: true, recurseEverything: true,
}) })
} }
* [Symbol.iterator]() {
yield* Object.entries(this.toJSON());
}
} }

View File

@ -3,16 +3,26 @@ import { ClusterId, clusterStore } from "./cluster-store";
import { tracker } from "./tracker"; import { tracker } from "./tracker";
export const clusterIpc = { export const clusterIpc = {
init: createIpcChannel({
channel: "cluster:init",
handle: async (clusterId: ClusterId, frameId: number) => {
const cluster = clusterStore.getById(clusterId);
if (cluster) {
cluster.frameId = frameId; // save cluster's webFrame.routingId to be able to send push-updates
return cluster.pushState();
}
},
}),
activate: createIpcChannel({ activate: createIpcChannel({
channel: "cluster:activate", channel: "cluster:activate",
handle: async (clusterId: ClusterId = clusterStore.activeClusterId) => { handle: (clusterId: ClusterId) => {
return clusterStore.getById(clusterId)?.activate(); return clusterStore.getById(clusterId)?.activate();
}, },
}), }),
disconnect: createIpcChannel({ disconnect: createIpcChannel({
channel: "cluster:disconnect", channel: "cluster:disconnect",
handle: (clusterId: ClusterId = clusterStore.activeClusterId) => { handle: (clusterId: ClusterId) => {
tracker.event("cluster", "stop"); tracker.event("cluster", "stop");
return clusterStore.getById(clusterId)?.disconnect(); return clusterStore.getById(clusterId)?.disconnect();
}, },
@ -23,7 +33,6 @@ export const clusterIpc = {
handle: async (clusterId: ClusterId, feature: string, config?: any) => { handle: async (clusterId: ClusterId, feature: string, config?: any) => {
tracker.event("cluster", "install", feature); tracker.event("cluster", "install", feature);
const cluster = clusterStore.getById(clusterId); const cluster = clusterStore.getById(clusterId);
if (cluster) { if (cluster) {
await cluster.installFeature(feature, config) await cluster.installFeature(feature, config)
} else { } else {

View File

@ -62,11 +62,10 @@ export class ClusterStore extends BaseStore<ClusterStoreModel> {
migrations: migrations, migrations: migrations,
}); });
if (ipcRenderer) { if (ipcRenderer) {
ipcRenderer.on("cluster:state", (event, clusterState: ClusterState) => { ipcRenderer.on("cluster:state", (event, model: ClusterState) => {
this.applyWithoutSync(() => { this.applyWithoutSync(() => {
logger.debug(`[CLUSTER-STORE]: received state update for cluster=${clusterState.id}`, clusterState); logger.debug(`[CLUSTER-STORE]: received push-state at ${location.host}`, model);
const cluster = this.getById(clusterState.id); this.getById(model.id)?.updateModel(model);
if (cluster) cluster.updateModel(clusterState)
}) })
}) })
} }
@ -84,6 +83,14 @@ export class ClusterStore extends BaseStore<ClusterStoreModel> {
return Array.from(this.clusters.values()); return Array.from(this.clusters.values());
} }
isActive(id: ClusterId) {
return this.activeClusterId === id;
}
setActive(id: ClusterId) {
this.activeClusterId = id;
}
hasClusters() { hasClusters() {
return this.clusters.size > 0; return this.clusters.size > 0;
} }
@ -156,11 +163,6 @@ export class ClusterStore extends BaseStore<ClusterStoreModel> {
this.activeClusterId = newClusters.has(activeCluster) ? activeCluster : null; this.activeClusterId = newClusters.has(activeCluster) ? activeCluster : null;
this.clusters.replace(newClusters); this.clusters.replace(newClusters);
this.removedClusters.replace(removedClusters); this.removedClusters.replace(removedClusters);
// "auto-select" first cluster if available
if (!this.activeClusterId && newClusters.size) {
this.activeClusterId = Array.from(newClusters.values())[0].id;
}
} }
toJSON(): ClusterStoreModel { toJSON(): ClusterStoreModel {

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

View File

@ -7,23 +7,59 @@ import logger from "../main/logger";
export type IpcChannel = string; export type IpcChannel = string;
export interface IpcHandleOpts { export interface IpcChannelOptions {
timeout?: number; channel: IpcChannel; // main <-> renderer communication channel name
handle?: (...args: any[]) => Promise<any> | any; // message handler
autoBind?: boolean; // auto-bind message handler in main-process, default: true
timeout?: number; // timeout for waiting response from the sender
once?: boolean; // one-time event
} }
export interface IpcMessageHandler<T extends any[] = any> { export function createIpcChannel({ autoBind = true, once, timeout = 0, handle, channel }: IpcChannelOptions) {
(...args: T): any; const ipcChannel = {
channel: channel,
handleInMain: () => {
logger.info(`[IPC]: setup channel "${channel}"`);
const ipcHandler = once ? ipcMain.handleOnce : ipcMain.handle;
ipcHandler(channel, async (event, ...args) => {
let timerId: any;
try {
if (timeout > 0) {
timerId = setTimeout(() => {
throw new Error(`[IPC]: response timeout in ${timeout}ms`)
}, timeout);
}
return await handle(...args); // todo: maybe exec in separate thread/worker
} catch (error) {
throw error
} finally {
clearTimeout(timerId);
}
})
},
removeHandler() {
ipcMain.removeHandler(channel);
},
invokeFromRenderer: async <T>(...args: any[]): Promise<T> => {
return ipcRenderer.invoke(channel, ...args);
},
}
if (autoBind && ipcMain) {
ipcChannel.handleInMain();
}
return ipcChannel;
} }
export interface IpcMessageOpts<A extends any[] = any> { export interface IpcBroadcastParams<A extends any[] = any> {
channel: IpcChannel channel: IpcChannel
webContentId?: number; // sends to single webContents view webContentId?: number; // send to single webContents view
frameId?: number; // send to inner frame of webContents
filter?: (webContent: WebContents) => boolean filter?: (webContent: WebContents) => boolean
timeout?: number; // todo: add support timeout?: number; // todo: add support
args?: A; args?: A;
} }
export function broadcastIpc({ channel, webContentId, filter, args = [] }: IpcMessageOpts) { export function broadcastIpc({ channel, frameId, webContentId, filter, args = [] }: IpcBroadcastParams) {
const singleView = webContentId ? webContents.fromId(webContentId) : null; const singleView = webContentId ? webContents.fromId(webContentId) : null;
let views = singleView ? [singleView] : webContents.getAllWebContents(); let views = singleView ? [singleView] : webContents.getAllWebContents();
if (filter) { if (filter) {
@ -31,65 +67,10 @@ export function broadcastIpc({ channel, webContentId, filter, args = [] }: IpcMe
} }
views.forEach(webContent => { views.forEach(webContent => {
const type = webContent.getType(); const type = webContent.getType();
logger.debug(`[IPC]: sending message "${channel}" to ${type}=${webContent.id}`, { args }); logger.debug(`[IPC]: broadcasting "${channel}" to ${type}=${webContent.id}`, { args });
webContent.send(channel, ...[args].flat()); webContent.send(channel, ...args);
if (frameId) {
webContent.sendToFrame(frameId, channel, ...args)
}
}) })
} }
// todo: support timeout + merge with sendMessage?
export async function invokeIpc<R = any>(channel: IpcChannel, ...args: any[]): Promise<R> {
logger.info(`[IPC]: invoke channel "${channel}"`, { args });
return ipcRenderer.invoke(channel, ...args);
}
// todo: make isomorphic api
export function handleIpc(channel: IpcChannel, handler: IpcMessageHandler, options: IpcHandleOpts = {}) {
const { timeout = 0 } = options;
logger.info(`[IPC]: setup to handle "${channel}"`);
ipcMain.handle(channel, async (event, ...args) => {
logger.info(`[IPC]: handle "${channel}"`, { args });
return new Promise(async (resolve, reject) => {
let timerId;
if (timeout) {
timerId = setTimeout(() => {
const timeoutError = new Error("[IPC]: response timeout");
reject(timeoutError);
}, timeout);
}
try {
const result = await handler(...args); // todo: maybe exec in separate thread/worker
resolve(result);
clearTimeout(timerId);
} catch (err) {
reject(err);
}
})
})
}
export interface IpcPairOptions {
channel: IpcChannel
handle?: IpcMessageHandler
autoBind?: boolean;
timeout?: number;
}
// todo: improve api
export function createIpcChannel({ channel, autoBind, ...initOpts }: IpcPairOptions) {
const bindHandler = (opts: { handler?: IpcMessageHandler, options?: IpcHandleOpts } = {}) => {
const handler = opts.handler || initOpts.handle || Function;
const options = opts.options || { timeout: initOpts.timeout };
handleIpc(channel, handler, options);
};
if (autoBind) {
bindHandler();
}
return {
channel: channel,
handleInMain: bindHandler,
invokeFromRenderer(...args: any[]) {
return invokeIpc(channel, ...args);
},
}
}

View File

@ -1,11 +1,10 @@
import { app, remote } from "electron"; import { app, remote } from "electron";
import { KubeConfig, V1Node, V1Pod } from "@kubernetes/client-node" import { KubeConfig, V1Node, V1Pod } from "@kubernetes/client-node"
import { ensureDirSync, readFile, writeFileSync } from "fs-extra"; import fse, { ensureDirSync, readFile, writeFileSync } from "fs-extra";
import path from "path" import path from "path"
import os from "os" import os from "os"
import yaml from "js-yaml" import yaml from "js-yaml"
import logger from "../main/logger"; import logger from "../main/logger";
import fse from "fs-extra"
function resolveTilde(filePath: string) { function resolveTilde(filePath: string) {
if (filePath[0] === "~" && (filePath[1] === "/" || filePath.length === 1)) { if (filePath[0] === "~" && (filePath[1] === "/" || filePath.length === 1)) {
@ -135,8 +134,6 @@ export function podHasIssues(pod: V1Pod) {
) )
} }
// Logic adapted from dashboard
// see: https://github.com/kontena/kontena-k8s-dashboard/blob/7d8f9cb678cc817a22dd1886c5e79415b212b9bf/client/api/endpoints/nodes.api.ts#L147
export function getNodeWarningConditions(node: V1Node) { export function getNodeWarningConditions(node: V1Node) {
return node.status.conditions.filter(c => return node.status.conditions.filter(c =>
c.status.toLowerCase() === "true" && c.type !== "Ready" && c.type !== "HostUpgrades" c.status.toLowerCase() === "true" && c.type !== "Ready" && c.type !== "HostUpgrades"

View File

@ -3,6 +3,7 @@ import ua from "universal-analytics"
import { machineIdSync } from "node-machine-id" import { machineIdSync } from "node-machine-id"
import Singleton from "./utils/singleton"; import Singleton from "./utils/singleton";
import { userStore } from "./user-store" import { userStore } from "./user-store"
import logger from "../main/logger";
export class Tracker extends Singleton { export class Tracker extends Singleton {
static readonly GA_ID = "UA-159377374-1" static readonly GA_ID = "UA-159377374-1"
@ -40,7 +41,7 @@ export class Tracker extends Singleton {
...otherParams, ...otherParams,
}).send() }).send()
} catch (err) { } catch (err) {
console.error(`Failed to track "${eventCategory}:${eventAction}"`, err) logger.error(`Failed to track "${eventCategory}:${eventAction}"`, err)
} }
} }
} }

View File

@ -71,12 +71,10 @@ export class UserStore extends BaseStore<UserStoreModel> {
if (kubeConfig) { if (kubeConfig) {
this.newContexts.clear(); this.newContexts.clear();
const localContexts = loadConfig(kubeConfig).getContexts(); const localContexts = loadConfig(kubeConfig).getContexts();
localContexts.forEach(({ cluster, name }) => { localContexts
if (!cluster) return; .filter(ctx => ctx.cluster)
if (!this.seenContexts.has(name)) { .filter(ctx => !this.seenContexts.has(ctx.name))
this.newContexts.add(name) .forEach(ctx => this.newContexts.add(ctx.name));
}
})
} }
} }

View 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')
})
})
})

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

View File

@ -1,6 +1,7 @@
// App's common configuration for any process (main, renderer, build pipeline, etc.) // App's common configuration for any process (main, renderer, build pipeline, etc.)
import packageInfo from "../../package.json"
import path from "path"; import path from "path";
import packageInfo from "../../package.json"
import { defineGlobal } from "./utils/defineGlobal";
export const isMac = process.platform === "darwin" export const isMac = process.platform === "darwin"
export const isWindows = process.platform === "win32" export const isWindows = process.platform === "win32"
@ -10,16 +11,26 @@ export const isDevelopment = isDebugging || !isProduction;
export const isTestEnv = !!process.env.JEST_WORKER_ID; export const isTestEnv = !!process.env.JEST_WORKER_ID;
export const appName = `${packageInfo.productName}${isDevelopment ? "Dev" : ""}` export const appName = `${packageInfo.productName}${isDevelopment ? "Dev" : ""}`
export const publicPath = "/build/"
// System paths // Webpack build paths
export const contextDir = process.cwd(); export const contextDir = process.cwd();
export const staticDir = path.join(contextDir, "static"); export const buildDir = path.join(contextDir, "static", publicPath);
export const outDir = path.join(contextDir, "out");
export const mainDir = path.join(contextDir, "src/main"); export const mainDir = path.join(contextDir, "src/main");
export const rendererDir = path.join(contextDir, "src/renderer"); export const rendererDir = path.join(contextDir, "src/renderer");
export const htmlTemplate = path.resolve(rendererDir, "template.html"); export const htmlTemplate = path.resolve(rendererDir, "template.html");
export const sassCommonVars = path.resolve(rendererDir, "components/vars.scss"); export const sassCommonVars = path.resolve(rendererDir, "components/vars.scss");
// Special runtime paths
defineGlobal("__static", {
get() {
if (isDevelopment) {
return path.resolve(contextDir, "static");
}
return path.resolve(process.resourcesPath, "static")
}
})
// Apis // Apis
export const apiPrefix = "/api" // local router apis export const apiPrefix = "/api" // local router apis
export const apiKubePrefix = "/api-kube" // k8s cluster apis export const apiKubePrefix = "/api-kube" // k8s cluster apis

View File

@ -1,4 +1,4 @@
import { action, computed, observable, reaction, toJS } from "mobx"; import { action, computed, observable, toJS } from "mobx";
import { BaseStore } from "./base-store"; import { BaseStore } from "./base-store";
import { clusterStore } from "./cluster-store" import { clusterStore } from "./cluster-store"
@ -22,15 +22,6 @@ export class WorkspaceStore extends BaseStore<WorkspaceStoreModel> {
super({ super({
configName: "lens-workspace-store", configName: "lens-workspace-store",
}); });
// switch to first available cluster in current workspace
reaction(() => this.currentWorkspaceId, workspaceId => {
const clusters = clusterStore.getByWorkspaceId(workspaceId);
const activeClusterInWorkspace = clusters.some(cluster => cluster.id === clusterStore.activeClusterId);
if (!activeClusterInWorkspace) {
clusterStore.activeClusterId = clusters.length ? clusters[0].id : null;
}
})
} }
@observable currentWorkspaceId = WorkspaceStore.defaultId; @observable currentWorkspaceId = WorkspaceStore.defaultId;
@ -60,6 +51,10 @@ export class WorkspaceStore extends BaseStore<WorkspaceStoreModel> {
@action @action
setActive(id = WorkspaceStore.defaultId) { setActive(id = WorkspaceStore.defaultId) {
if (!this.getById(id)) {
throw new Error(`workspace ${id} doesn't exist`);
}
this.currentWorkspaceId = id; this.currentWorkspaceId = id;
} }

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

View File

@ -1,9 +1,10 @@
import "../common/cluster-ipc";
import type http from "http" import type http from "http"
import { autorun } from "mobx"; import { autorun } from "mobx";
import { ClusterId, clusterStore } from "../common/cluster-store" import { ClusterId, clusterStore } from "../common/cluster-store"
import { Cluster } from "./cluster" import { Cluster } from "./cluster"
import { clusterIpc } from "../common/cluster-ipc";
import logger from "./logger"; import logger from "./logger";
import { apiKubePrefix } from "../common/vars";
export class ClusterManager { export class ClusterManager {
constructor(public readonly port: number) { constructor(public readonly port: number) {
@ -29,13 +30,6 @@ export class ClusterManager {
}, { }, {
delay: 250 delay: 250
}); });
// listen for ipc-events that must/can be handled *only* in main-process (nodeIntegration=true)
clusterIpc.activate.handleInMain();
clusterIpc.disconnect.handleInMain();
clusterIpc.installFeature.handleInMain();
clusterIpc.uninstallFeature.handleInMain();
clusterIpc.upgradeFeature.handleInMain();
} }
stop() { stop() {
@ -49,8 +43,23 @@ export class ClusterManager {
} }
getClusterForRequest(req: http.IncomingMessage): Cluster { getClusterForRequest(req: http.IncomingMessage): Cluster {
logger.info(`getClusterForRequest(): ${req.headers.host}${req.url}`) let cluster: Cluster = null
const clusterId = req.headers.host.split(".")[0]
return this.getCluster(clusterId) // lens-server is connecting to 127.0.0.1:<port>/<uid>
if (req.headers.host.startsWith("127.0.0.1")) {
const clusterId = req.url.split("/")[1]
if (clusterId) {
cluster = this.getCluster(clusterId)
if (cluster) {
// we need to swap path prefix so that request is proxied to kube api
req.url = req.url.replace(`/${clusterId}`, apiKubePrefix)
}
}
} else {
const id = req.headers.host.split(".")[0]
cluster = this.getCluster(id)
}
return cluster;
} }
} }

View File

@ -40,6 +40,7 @@ export interface ClusterState extends ClusterModel {
export class Cluster implements ClusterModel { export class Cluster implements ClusterModel {
public id: ClusterId; public id: ClusterId;
public frameId: number;
public kubeCtl: Kubectl public kubeCtl: Kubectl
public contextHandler: ContextHandler; public contextHandler: ContextHandler;
protected kubeconfigManager: KubeconfigManager; protected kubeconfigManager: KubeconfigManager;
@ -67,9 +68,8 @@ export class Cluster implements ClusterModel {
@observable allowedNamespaces: string[] = []; @observable allowedNamespaces: string[] = [];
@observable allowedResources: string[] = []; @observable allowedResources: string[] = [];
@computed get host() { @computed get available() {
const proxyHost = new URL(this.kubeProxyUrl).host; return this.accessible && !this.disconnected;
return `${this.id}.${proxyHost}`
} }
constructor(model: ClusterModel) { constructor(model: ClusterModel) {
@ -79,7 +79,7 @@ export class Cluster implements ClusterModel {
@action @action
updateModel(model: ClusterModel) { updateModel(model: ClusterModel) {
Object.assign(this, model); Object.assign(this, model);
this.apiUrl = this.getKubeconfig().getCurrentCluster().server; this.apiUrl = this.getKubeconfig().getCurrentCluster()?.server;
this.contextName = this.contextName || this.preferences.clusterName; this.contextName = this.contextName || this.preferences.clusterName;
} }
@ -222,8 +222,9 @@ export class Cluster implements ClusterModel {
return request(apiUrl, { return request(apiUrl, {
json: true, json: true,
timeout: 5000, timeout: 5000,
...options,
headers: { headers: {
Host: this.host, // provide cluster-id for ClusterManager.getClusterForRequest() Host: `${this.id}.${new URL(this.kubeProxyUrl).host}`, // required in ClusterManager.getClusterForRequest()
...(options.headers || {}), ...(options.headers || {}),
}, },
}) })
@ -233,6 +234,7 @@ export class Cluster implements ClusterModel {
const prometheusPrefix = this.preferences.prometheus?.prefix || ""; const prometheusPrefix = this.preferences.prometheus?.prefix || "";
const metricsPath = `/api/v1/namespaces/${prometheusPath}/proxy${prometheusPrefix}/api/v1/query_range`; const metricsPath = `/api/v1/namespaces/${prometheusPath}/proxy${prometheusPrefix}/api/v1/query_range`;
return this.k8sRequest(metricsPath, { return this.k8sRequest(metricsPath, {
timeout: 0,
resolveWithFullResponse: false, resolveWithFullResponse: false,
json: true, json: true,
qs: queryParams, qs: queryParams,
@ -388,8 +390,8 @@ export class Cluster implements ClusterModel {
pushState = (state = this.getState()): ClusterState => { pushState = (state = this.getState()): ClusterState => {
logger.debug(`[CLUSTER]: push-state`, state); logger.debug(`[CLUSTER]: push-state`, state);
broadcastIpc({ broadcastIpc({
// webContentId: viewId, // todo: send to cluster-view only
channel: "cluster:state", channel: "cluster:state",
frameId: this.frameId,
args: [state], args: [state],
}); });
return state; return state;

View File

@ -3,7 +3,7 @@
import "../common/system-ca" import "../common/system-ca"
import "../common/prometheus-providers" import "../common/prometheus-providers"
import { app, dialog } from "electron" import { app, dialog } from "electron"
import { appName, staticDir } from "../common/vars"; import { appName } from "../common/vars";
import path from "path" import path from "path"
import { LensProxy } from "./lens-proxy" import { LensProxy } from "./lens-proxy"
import { WindowManager } from "./window-manager"; import { WindowManager } from "./window-manager";
@ -19,6 +19,12 @@ import { workspaceStore } from "../common/workspace-store";
import { tracker } from "../common/tracker"; import { tracker } from "../common/tracker";
import logger from "./logger" import logger from "./logger"
const workingDir = path.join(app.getPath("appData"), appName);
app.setName(appName);
if(!process.env.CICD) {
app.setPath("userData", workingDir);
}
let windowManager: WindowManager; let windowManager: WindowManager;
let clusterManager: ClusterManager; let clusterManager: ClusterManager;
let proxyServer: LensProxy; let proxyServer: LensProxy;
@ -30,17 +36,13 @@ if (app.commandLine.getSwitchValue("proxy-server") !== "") {
async function main() { async function main() {
await shellSync(); await shellSync();
const workingDir = path.join(app.getPath("appData"), appName);
app.setName(appName);
app.setPath("userData", workingDir);
logger.info(`🚀 Starting Lens from "${workingDir}"`) logger.info(`🚀 Starting Lens from "${workingDir}"`)
tracker.event("app", "start"); tracker.event("app", "start");
const updater = new AppUpdater() const updater = new AppUpdater()
updater.start(); updater.start();
registerFileProtocol("static", staticDir); registerFileProtocol("static", __static);
// find free port // find free port
let proxyPort: number let proxyPort: number

View File

@ -1,8 +1,7 @@
import type { WindowManager } from "./window-manager";
import { app, BrowserWindow, dialog, Menu, MenuItem, MenuItemConstructorOptions, shell, webContents } from "electron" import { app, BrowserWindow, dialog, Menu, MenuItem, MenuItemConstructorOptions, shell, webContents } from "electron"
import { autorun } from "mobx"; import { autorun } from "mobx";
import { WindowManager } from "./window-manager";
import { appName, isMac, issuesTrackerUrl, isWindows, slackUrl } from "../common/vars"; import { appName, isMac, issuesTrackerUrl, isWindows, slackUrl } from "../common/vars";
import { clusterStore } from "../common/cluster-store";
import { addClusterURL } from "../renderer/components/+add-cluster/add-cluster.route"; import { addClusterURL } from "../renderer/components/+add-cluster/add-cluster.route";
import { preferencesURL } from "../renderer/components/+preferences/preferences.route"; import { preferencesURL } from "../renderer/components/+preferences/preferences.route";
import { whatsNewURL } from "../renderer/components/+whats-new/whats-new.route"; import { whatsNewURL } from "../renderer/components/+whats-new/whats-new.route";
@ -10,53 +9,119 @@ import { clusterSettingsURL } from "../renderer/components/+cluster-settings/clu
import logger from "./logger"; import logger from "./logger";
export function initMenu(windowManager: WindowManager) { export function initMenu(windowManager: WindowManager) {
autorun(() => { autorun(() => buildMenu(windowManager), {
logger.debug(`[MENU]: building menu, cluster=${clusterStore.activeClusterId}`); delay: 100
buildMenu(windowManager);
}); });
} }
export function buildMenu(windowManager: WindowManager) { export function buildMenu(windowManager: WindowManager) {
function macOnly(menuItems: MenuItemConstructorOptions[]): MenuItemConstructorOptions[] { function ignoreOnMac(menuItems: MenuItemConstructorOptions[]) {
if (!isMac) return []; if (isMac) return [];
return menuItems; return menuItems;
} }
const fileMenu: MenuItemConstructorOptions = { function activeClusterOnly(menuItems: MenuItemConstructorOptions[]) {
label: isMac ? app.getName() : "File", if (!windowManager.activeClusterId) {
menuItems.forEach(item => {
item.enabled = false
});
}
return menuItems;
}
function navigate(url: string) {
logger.info(`[MENU]: navigating to ${url}`);
windowManager.navigate({
channel: "menu:navigate",
url: url,
})
}
function showAbout(browserWindow: BrowserWindow) {
const appInfo = [
`${appName}: ${app.getVersion()}`,
`Electron: ${process.versions.electron}`,
`Chrome: ${process.versions.chrome}`,
`Copyright 2020 Lakend Labs, Inc.`,
]
dialog.showMessageBoxSync(browserWindow, {
title: `${isWindows ? " ".repeat(2) : ""}${appName}`,
type: "info",
buttons: ["Close"],
message: `Lens`,
detail: appInfo.join("\r\n")
})
}
const mt: MenuItemConstructorOptions[] = [];
const macAppMenu: MenuItemConstructorOptions = {
label: app.getName(),
submenu: [ submenu: [
{ {
label: 'Add Cluster', label: "About Lens",
click() { click(menuItem: MenuItem, browserWindow: BrowserWindow) {
windowManager.navigateMain(addClusterURL()) showAbout(browserWindow)
} }
}, },
...(clusterStore.activeCluster ? [{
label: 'Cluster Settings',
click() {
windowManager.navigateMain(clusterSettingsURL())
}
}] : []),
{ type: 'separator' }, { type: 'separator' },
{ {
label: 'Preferences', label: 'Preferences',
click() { click() {
windowManager.navigateMain(preferencesURL()) navigate(preferencesURL())
} }
}, },
...macOnly([ { type: 'separator' },
{ type: 'separator' }, { role: 'services' },
{ role: 'services' }, { type: 'separator' },
{ type: 'separator' }, { role: 'hide' },
{ role: 'hide' }, { role: 'hideOthers' },
{ role: 'hideOthers' }, { role: 'unhide' },
{ role: 'unhide' },
]),
{ type: 'separator' }, { type: 'separator' },
{ role: 'quit' } { role: 'quit' }
] ]
}; };
if (isMac) {
mt.push(macAppMenu);
}
const fileMenu: MenuItemConstructorOptions = {
label: "File",
submenu: [
{
label: 'Add Cluster',
click() {
navigate(addClusterURL())
}
},
...activeClusterOnly([
{
label: 'Cluster Settings',
click() {
navigate(clusterSettingsURL({
params: {
clusterId: windowManager.activeClusterId
}
}))
}
}
]),
...ignoreOnMac([
{ type: 'separator' },
{
label: 'Preferences',
click() {
navigate(preferencesURL())
}
},
{ type: 'separator' },
{ role: 'quit' }
])
]
};
mt.push(fileMenu)
const editMenu: MenuItemConstructorOptions = { const editMenu: MenuItemConstructorOptions = {
label: 'Edit', label: 'Edit',
submenu: [ submenu: [
@ -71,7 +136,7 @@ export function buildMenu(windowManager: WindowManager) {
{ role: 'selectAll' }, { role: 'selectAll' },
] ]
}; };
mt.push(editMenu)
const viewMenu: MenuItemConstructorOptions = { const viewMenu: MenuItemConstructorOptions = {
label: 'View', label: 'View',
submenu: [ submenu: [
@ -79,21 +144,21 @@ export function buildMenu(windowManager: WindowManager) {
label: 'Back', label: 'Back',
accelerator: 'CmdOrCtrl+[', accelerator: 'CmdOrCtrl+[',
click() { click() {
webContents.getFocusedWebContents().executeJavaScript('window.history.back()') webContents.getFocusedWebContents()?.goBack();
} }
}, },
{ {
label: 'Forward', label: 'Forward',
accelerator: 'CmdOrCtrl+]', accelerator: 'CmdOrCtrl+]',
click() { click() {
webContents.getFocusedWebContents().executeJavaScript('window.history.forward()') webContents.getFocusedWebContents()?.goForward();
} }
}, },
{ {
label: 'Reload', label: 'Reload',
accelerator: 'CmdOrCtrl+R', accelerator: 'CmdOrCtrl+R',
click() { click() {
webContents.getFocusedWebContents().reload() webContents.getFocusedWebContents()?.reload();
} }
}, },
{ role: 'toggleDevTools' }, { role: 'toggleDevTools' },
@ -105,16 +170,11 @@ export function buildMenu(windowManager: WindowManager) {
{ role: 'togglefullscreen' } { role: 'togglefullscreen' }
] ]
}; };
mt.push(viewMenu)
const helpMenu: MenuItemConstructorOptions = { const helpMenu: MenuItemConstructorOptions = {
role: 'help', role: 'help',
submenu: [ submenu: [
{
label: "What's new?",
click() {
windowManager.navigateMain(whatsNewURL())
},
},
{ {
label: "License", label: "License",
click: async () => { click: async () => {
@ -134,27 +194,23 @@ export function buildMenu(windowManager: WindowManager) {
}, },
}, },
{ {
label: "About Lens", label: "What's new?",
click(menuItem: MenuItem, browserWindow: BrowserWindow) { click() {
const appInfo = [ navigate(whatsNewURL())
`${appName}: ${app.getVersion()}`, },
`Electron: ${process.versions.electron}`, },
`Chrome: ${process.versions.chrome}`, ...ignoreOnMac([
`Copyright 2020 Lakend Labs, Inc.`, {
] label: "About Lens",
dialog.showMessageBoxSync(browserWindow, { click(menuItem: MenuItem, browserWindow: BrowserWindow) {
title: `${isWindows ? " ".repeat(2) : ""}${appName}`, showAbout(browserWindow)
type: "info", }
buttons: ["Close"],
message: `Lens`,
detail: appInfo.join("\r\n")
})
} }
} ])
] ]
}; };
Menu.setApplicationMenu(Menu.buildFromTemplate([ mt.push(helpMenu)
fileMenu, editMenu, viewMenu, helpMenu
])); Menu.setApplicationMenu(Menu.buildFromTemplate(mt));
} }

View File

@ -4,7 +4,7 @@ import http from "http"
import path from "path" import path from "path"
import { readFile } from "fs-extra" import { readFile } from "fs-extra"
import { Cluster } from "./cluster" import { Cluster } from "./cluster"
import { apiPrefix, appName, outDir } from "../common/vars"; import { apiPrefix, appName, publicPath } from "../common/vars";
import { helmRoute, kubeconfigRoute, metricsRoute, portForwardRoute, resourceApplierRoute, watchRoute } from "./routes"; import { helmRoute, kubeconfigRoute, metricsRoute, portForwardRoute, resourceApplierRoute, watchRoute } from "./routes";
export interface RouterRequestOpts { export interface RouterRequestOpts {
@ -95,14 +95,14 @@ export class Router {
} }
async handleStaticFile(filePath: string, res: http.ServerResponse) { async handleStaticFile(filePath: string, res: http.ServerResponse) {
const asset = path.join(outDir, filePath); const asset = path.join(__static, filePath);
try { try {
const data = await readFile(asset); const data = await readFile(asset);
res.setHeader("Content-Type", this.getMimeType(asset)); res.setHeader("Content-Type", this.getMimeType(asset));
res.write(data) res.write(data)
res.end() res.end()
} catch (err) { } catch (err) {
this.handleStaticFile(`${appName}.html`, res); this.handleStaticFile(`${publicPath}/${appName}.html`, res);
} }
} }
@ -121,7 +121,7 @@ export class Router {
this.router.add({ method: "post", path: `${apiPrefix}/metrics` }, metricsRoute.routeMetrics.bind(metricsRoute)) this.router.add({ method: "post", path: `${apiPrefix}/metrics` }, metricsRoute.routeMetrics.bind(metricsRoute))
// Port-forward API // Port-forward API
this.router.add({ method: "post", path: `${apiPrefix}/services/{namespace}/{service}/port-forward/{port}` }, portForwardRoute.routeServicePortForward.bind(portForwardRoute)) this.router.add({ method: "post", path: `${apiPrefix}/pods/{namespace}/{resourceType}/{resourceName}/port-forward/{port}` }, portForwardRoute.routePortForward.bind(portForwardRoute))
// Helm API // Helm API
this.router.add({ method: "get", path: `${apiPrefix}/v2/charts` }, helmRoute.listCharts.bind(helmRoute)) this.router.add({ method: "get", path: `${apiPrefix}/v2/charts` }, helmRoute.listCharts.bind(helmRoute))

View File

@ -16,8 +16,10 @@ class MetricsRoute extends LensApi {
let prometheusPath: string let prometheusPath: string
let prometheusProvider: PrometheusProvider let prometheusProvider: PrometheusProvider
try { try {
prometheusPath = await cluster.contextHandler.getPrometheusPath() [prometheusPath, prometheusProvider] = await Promise.all([
prometheusProvider = await cluster.contextHandler.getPrometheusProvider() cluster.contextHandler.getPrometheusPath(),
cluster.contextHandler.getPrometheusProvider()
])
} catch { } catch {
this.respondJson(response, {}) this.respondJson(response, {})
return return

View File

@ -14,7 +14,7 @@ class PortForward {
return PortForward.portForwards.find((pf) => { return PortForward.portForwards.find((pf) => {
return ( return (
pf.clusterId == forward.clusterId && pf.clusterId == forward.clusterId &&
pf.kind == "service" && pf.kind == forward.kind &&
pf.name == forward.name && pf.name == forward.name &&
pf.namespace == forward.namespace && pf.namespace == forward.namespace &&
pf.port == forward.port pf.port == forward.port
@ -42,7 +42,7 @@ class PortForward {
"--kubeconfig", this.kubeConfig, "--kubeconfig", this.kubeConfig,
"port-forward", "port-forward",
"-n", this.namespace, "-n", this.namespace,
`service/${this.name}`, `${this.kind}/${this.name}`,
`${this.localPort}:${this.port}` `${this.localPort}:${this.port}`
] ]
@ -72,21 +72,22 @@ class PortForward {
class PortForwardRoute extends LensApi { class PortForwardRoute extends LensApi {
public async routeServicePortForward(request: LensApiRequest) { public async routePortForward(request: LensApiRequest) {
const { params, response, cluster} = request const { params, response, cluster} = request
const { namespace, port, resourceType, resourceName } = params
let portForward = PortForward.getPortforward({ let portForward = PortForward.getPortforward({
clusterId: cluster.id, kind: "service", name: params.service, clusterId: cluster.id, kind: resourceType, name: resourceName,
namespace: params.namespace, port: params.port namespace: namespace, port: port
}) })
if (!portForward) { if (!portForward) {
logger.info(`Creating a new port-forward ${params.namespace}/${params.service}:${params.port}`) logger.info(`Creating a new port-forward ${namespace}/${resourceType}/${resourceName}:${port}`)
portForward = new PortForward({ portForward = new PortForward({
clusterId: cluster.id, clusterId: cluster.id,
kind: "service", kind: resourceType,
namespace: params.namespace, namespace: namespace,
name: params.service, name: resourceName,
port: params.port, port: port,
kubeConfig: cluster.getProxyKubeconfigPath() kubeConfig: cluster.getProxyKubeconfigPath()
}) })
const started = await portForward.start() const started = await portForward.start()

View File

@ -1,5 +1,7 @@
import { BrowserWindow, shell } from "electron" import type { ClusterId } from "../common/cluster-store";
import { BrowserWindow, dialog, ipcMain, shell, WebContents, webContents } from "electron"
import windowStateKeeper from "electron-window-state" import windowStateKeeper from "electron-window-state"
import { observable } from "mobx";
import { initMenu } from "./menu"; import { initMenu } from "./menu";
export class WindowManager { export class WindowManager {
@ -7,9 +9,9 @@ export class WindowManager {
protected splashWindow: BrowserWindow; protected splashWindow: BrowserWindow;
protected windowState: windowStateKeeper.State; protected windowState: windowStateKeeper.State;
constructor(protected proxyPort: number) { @observable activeClusterId: ClusterId;
initMenu(this);
constructor(protected proxyPort: number) {
// Manage main window size and position with state persistence // Manage main window size and position with state persistence
this.windowState = windowStateKeeper({ this.windowState = windowStateKeeper({
defaultHeight: 900, defaultHeight: 900,
@ -26,6 +28,7 @@ export class WindowManager {
backgroundColor: "#1e2124", backgroundColor: "#1e2124",
webPreferences: { webPreferences: {
nodeIntegration: true, nodeIntegration: true,
nodeIntegrationInSubFrames: true,
enableRemoteModule: true, enableRemoteModule: true,
}, },
}); });
@ -37,20 +40,33 @@ export class WindowManager {
shell.openExternal(url); shell.openExternal(url);
}); });
// track visible cluster from ui
ipcMain.on("cluster-view:change", (event, clusterId: ClusterId) => {
this.activeClusterId = clusterId;
});
// load & show app // load & show app
this.showMain(); this.showMain();
initMenu(this);
} }
// fixme navigate({ url, channel, frameId }: { url: string, channel: string, frameId?: number }) {
navigateMain(url: string) { if (frameId) {
this.mainView.webContents.executeJavaScript("console.log('implement me!')") this.mainView.webContents.sendToFrame(frameId, channel, url);
} else {
this.mainView.webContents.send(channel, url);
}
} }
async showMain() { async showMain() {
await this.showSplash(); try {
await this.mainView.loadURL(`http://localhost:${this.proxyPort}`) await this.showSplash();
this.mainView.show(); await this.mainView.loadURL(`http://localhost:${this.proxyPort}`)
this.splashWindow.hide(); this.mainView.show();
this.splashWindow.close();
} catch (err) {
dialog.showErrorBox("ERROR!", err.toString())
}
} }
async showSplash() { async showSplash() {
@ -63,6 +79,9 @@ export class WindowManager {
frame: false, frame: false,
resizable: false, resizable: false,
show: false, show: false,
webPreferences: {
nodeIntegration: true
}
}); });
await this.splashWindow.loadURL("static://splash.html"); await this.splashWindow.loadURL("static://splash.html");
} }

View File

@ -50,6 +50,9 @@ export function parseKubeApi(path: string): IKubeApiParsed {
apiGroup = left.join("/"); apiGroup = left.join("/");
} else { } else {
switch (left.length) { switch (left.length) {
case 4:
[apiGroup, apiVersion, resource, name] = left
break;
case 2: case 2:
resource = left.pop(); resource = left.pop();
// fallthrough // fallthrough
@ -66,7 +69,7 @@ export function parseKubeApi(path: string): IKubeApiParsed {
* - `GROUP` is /^D(\.D)*$/ where D is `DNS_LABEL` and length <= 253 * - `GROUP` is /^D(\.D)*$/ where D is `DNS_LABEL` and length <= 253
* *
* There is no well defined selection from an array of items that were * There is no well defined selection from an array of items that were
* seperated by '/' * separated by '/'
* *
* Solution is to create a huristic. Namely: * Solution is to create a huristic. Namely:
* 1. if '.' in left[0] then apiGroup <- left[0] * 1. if '.' in left[0] then apiGroup <- left[0]

View File

@ -6,6 +6,19 @@ interface KubeApi_Parse_Test {
} }
const tests: KubeApi_Parse_Test[] = [ const tests: KubeApi_Parse_Test[] = [
{
url: "/apis/apiextensions.k8s.io/v1beta1/customresourcedefinitions/prometheuses.monitoring.coreos.com",
expected: {
apiBase: "/apis/apiextensions.k8s.io/v1beta1/customresourcedefinitions",
apiPrefix: "/apis",
apiGroup: "apiextensions.k8s.io",
apiVersion: "v1beta1",
apiVersionWithGroup: "apiextensions.k8s.io/v1beta1",
namespace: undefined,
resource: "customresourcedefinitions",
name: "prometheuses.monitoring.coreos.com"
},
},
{ {
url: "/api/v1/namespaces/kube-system/pods/coredns-6955765f44-v8p27", url: "/api/v1/namespaces/kube-system/pods/coredns-6955765f44-v8p27",
expected: { expected: {

View File

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

View File

@ -1,2 +1,11 @@
.AddCluster { .AddCluster {
.Select {
&__control {
box-shadow: 0 0 0 1px $borderFaintColor;
}
}
code {
color: $pink-400;
}
} }

View File

@ -15,8 +15,9 @@ import { getKubeConfigLocal, loadConfig, saveConfigToAppFiles, splitConfig, vali
import { clusterStore } from "../../../common/cluster-store"; import { clusterStore } from "../../../common/cluster-store";
import { workspaceStore } from "../../../common/workspace-store"; import { workspaceStore } from "../../../common/workspace-store";
import { v4 as uuid } from "uuid" import { v4 as uuid } from "uuid"
import { navigation } from "../../navigation"; import { navigate } from "../../navigation";
import { userStore } from "../../../common/user-store"; import { userStore } from "../../../common/user-store";
import { clusterViewURL } from "../cluster-manager/cluster-view.route";
@observer @observer
export class AddCluster extends React.Component { export class AddCluster extends React.Component {
@ -70,8 +71,9 @@ export class AddCluster extends React.Component {
if (value instanceof KubeConfig) { if (value instanceof KubeConfig) {
const context = value.currentContext; const context = value.currentContext;
const isNew = userStore.newContexts.has(context); const isNew = userStore.newContexts.has(context);
const className = `${context} kube-context flex gaps align-center`
return ( return (
<div className="kube-context flex gaps align-center"> <div className={className}>
<span>{context}</span> <span>{context}</span>
{isNew && <Icon material="fiber_new"/>} {isNew && <Icon material="fiber_new"/>}
</div> </div>
@ -102,7 +104,7 @@ export class AddCluster extends React.Component {
httpsProxy: proxyServer || undefined, httpsProxy: proxyServer || undefined,
}, },
}); });
navigation.goBack(); // return to previous opened page for the cluster view navigate(clusterViewURL({ params: { clusterId } }))
} catch (err) { } catch (err) {
this.error = String(err); this.error = String(err);
} finally { } finally {
@ -124,7 +126,7 @@ export class AddCluster extends React.Component {
to allow you to operate easily on multiple clusters and/or contexts. to allow you to operate easily on multiple clusters and/or contexts.
</p> </p>
<p> <p>
For more information on kubeconfig see <a href="https://kubernetes.io/docs/concepts/configuration/organize-cluster-access-kubeconfig/" target="_blank">Kubernetes docs</a> For more information on kubeconfig see <a href="https://kubernetes.io/docs/concepts/configuration/organize-cluster-access-kubeconfig/" target="_blank">Kubernetes docs</a>.
</p> </p>
<p> <p>
NOTE: Any manually added cluster is not merged into your kubeconfig file. NOTE: Any manually added cluster is not merged into your kubeconfig file.
@ -137,22 +139,20 @@ export class AddCluster extends React.Component {
app. app.
</p> </p>
<a href="https://kubernetes.io/docs/reference/access-authn-authz/authentication/#option-1-oidc-authenticator" target="_blank"> <a href="https://kubernetes.io/docs/reference/access-authn-authz/authentication/#option-1-oidc-authenticator" target="_blank">
<h4>OIDC (OpenID Connect)</h4> <h3>OIDC (OpenID Connect)</h3>
</a> </a>
<div> <p>
<p> When connecting Lens to OIDC enabled cluster, there's few things you as a user need to take into account.
When connecting Lens to OIDC enabled cluster, there's few things you as a user need to take into account. </p>
</p> <p><b>Dedicated refresh token</b></p>
<b>Dedicated refresh token</b> <p>
<p> As Lens app utilized kubeconfig is "disconnected" from your main kubeconfig Lens needs to have it's own refresh token it utilizes.
As Lens app utilized kubeconfig is "disconnected" from your main kubeconfig Lens needs to have it's own refresh token it utilizes. If you share the refresh token with e.g. <code>kubectl</code> who ever uses the token first will invalidate it for the next user.
If you share the refresh token with e.g. <code>kubectl</code> who ever uses the token first will invalidate it for the next user. One way to achieve this is with <a href="https://github.com/int128/kubelogin" target="_blank">kubelogin</a> tool by removing the tokens
One way to achieve this is with <a href="https://github.com/int128/kubelogin" target="_blank">kubelogin</a> tool by removing the tokens (both <code>id_token</code> and <code>refresh_token</code>) from
(both <code>id_token</code> and <code>refresh_token</code>) from the config and issuing <code>kubelogin</code> command. That'll take you through the login process and will result you having "dedicated" refresh token.
the config and issuing <code>kubelogin</code> command. That'll take you through the login process and will result you having "dedicated" refresh token. </p>
</p> <h3>Exec auth plugins</h3>
</div>
<h4>Exec auth plugins</h4>
<p> <p>
When using <a href="https://kubernetes.io/docs/reference/access-authn-authz/authentication/#configuration" target="_blank">exec auth</a> plugins make sure the paths that are used to call When using <a href="https://kubernetes.io/docs/reference/access-authn-authz/authentication/#configuration" target="_blank">exec auth</a> plugins make sure the paths that are used to call
any binaries any binaries
@ -167,12 +167,14 @@ export class AddCluster extends React.Component {
return ( return (
<WizardLayout className="AddCluster" infoPanel={this.renderInfo()}> <WizardLayout className="AddCluster" infoPanel={this.renderInfo()}>
<h2><Trans>Add Cluster</Trans></h2> <h2><Trans>Add Cluster</Trans></h2>
<p>Choose config:</p>
<Select <Select
placeholder={<Trans>Select kubeconfig</Trans>} placeholder={<Trans>Select kubeconfig</Trans>}
value={this.clusterConfig} value={this.clusterConfig}
options={this.clusterOptions} options={this.clusterOptions}
onChange={({ value }: SelectOption) => this.clusterConfig = value} onChange={({ value }: SelectOption) => this.clusterConfig = value}
formatOptionLabel={this.formatClusterContextLabel} formatOptionLabel={this.formatClusterContextLabel}
id="kubecontext-select"
/> />
<div className="cluster-settings"> <div className="cluster-settings">
<a href="#" onClick={() => this.showSettings = !this.showSettings}> <a href="#" onClick={() => this.showSettings = !this.showSettings}>
@ -181,14 +183,15 @@ export class AddCluster extends React.Component {
</div> </div>
{this.showSettings && ( {this.showSettings && (
<div className="proxy-settings"> <div className="proxy-settings">
<p>HTTP Proxy server. Used for communicating with Kubernetes API.</p>
<Input <Input
autoFocus autoFocus
placeholder={_i18n._(t`A HTTP proxy server URL (format: http://<address>:<port>)`)}
value={this.proxyServer} value={this.proxyServer}
onChange={value => this.proxyServer = value} onChange={value => this.proxyServer = value}
theme="round-black"
/> />
<small className="hint"> <small className="hint">
<Trans>HTTP Proxy server. Used for communicating with Kubernetes API.</Trans> {'A HTTP proxy server URL (format: http://<address>:<port>).'}
</small> </small>
</div> </div>
)} )}
@ -197,6 +200,7 @@ export class AddCluster extends React.Component {
<p>Kubeconfig:</p> <p>Kubeconfig:</p>
<AceEditor <AceEditor
autoFocus autoFocus
showGutter={false}
mode="yaml" mode="yaml"
value={this.customConfig} value={this.customConfig}
onChange={value => this.customConfig = value} onChange={value => this.customConfig = value}
@ -209,7 +213,7 @@ export class AddCluster extends React.Component {
<div className="actions-panel"> <div className="actions-panel">
<Button <Button
primary primary
label={<Trans>Add cluster</Trans>} label={<Trans>Add cluster(s)</Trans>}
onClick={this.addCluster} onClick={this.addCluster}
waiting={this.isWaiting} waiting={this.isWaiting}
/> />

View File

@ -1,8 +1,12 @@
import type { IClusterViewRouteParams } from "../cluster-manager/cluster-view.route";
import { RouteProps } from "react-router"; import { RouteProps } from "react-router";
import { buildURL } from "../../navigation"; import { buildURL } from "../../navigation";
export const clusterSettingsRoute: RouteProps = { export interface IClusterSettingsRouteParams extends IClusterViewRouteParams {
path: "/cluster-settings"
} }
export const clusterSettingsURL = buildURL(clusterSettingsRoute.path) export const clusterSettingsRoute: RouteProps = {
path: `/cluster/:clusterId/settings`,
}
export const clusterSettingsURL = buildURL<IClusterSettingsRouteParams>(clusterSettingsRoute.path)

View File

@ -1,86 +1,83 @@
.ClusterSettings { .ClusterSettings {
overflow-y: scroll; .WizardLayout {
grid-template-columns: unset; grid-template-columns: unset;
grid-template-rows: 76px 1fr;
padding: 0;
.info-col { .head-col {
display: none; justify-content: space-between;
:nth-child(2) {
flex: 1 0 0;
}
} }
.content-col { .content-col {
margin-right: unset; margin: 0;
} padding-top: $padding * 3;
background-color: transparent;
* { .SubTitle {
margin-top: 40px; text-transform: none;
}
&:first-child { > div {
margin-top: 0px; margin-top: $margin * 5;
} }
}
h4 { .admin-note {
margin-top: 20px; font-size: small;
opacity: 0.5;
margin-left: $margin;
}
.button-area {
margin-top: $margin * 2;
}
.file-loader {
margin-top: $margin * 2;
}
.hint {
font-size: smaller;
opacity: 0.8;
}
p + p, .hint + p {
padding-top: $padding;
}
} }
.status-table { .status-table {
margin-top: 20px; margin: $margin * 3 0;
display: grid;
grid-template-columns: 1fr 3fr;
grid-gap: 10px;
}
.loading { .Table {
margin-top: 20px; border: 1px solid var(--drawerSubtitleBackground);
text-align: center; border-radius: $radius;
.Spinner { .TableRow {
display: inline-block; &:not(:last-of-type) {
border-bottom: 1px solid var(--drawerSubtitleBackground);
}
.value {
flex-grow: 2;
word-break: break-word;
color: var(--textColorSecondary);
}
} }
}
} }
.Input,.Select { .Input, .Select {
margin-top: 10px; margin-top: 10px;
} }
.Icon:not(.updated):not(.clean) { .Select {
color: #ad0000; &__control {
} box-shadow: 0 0 0 1px $borderFaintColor;
}
.Icon.updated {
color: #00dd1d;
}
.updated {
animation: updated-name 1s 1;
animation-fill-mode: forwards;
animation-delay: 3s;
}
@keyframes updated-name {
from {opacity :1;}
to {opacity :0;}
}
.center {
text-align: center;
}
input[type="text"]::placeholder {
font-size: small;
color: #707070;
}
input[type="text"] {
color: white;
}
button {
margin-top: 5px;
.Spinner {
width: 10px;
height: 10px;
border-color: transparent black;
}
} }
}
} }

View File

@ -1,24 +1,43 @@
import "./cluster-settings.scss" import "./cluster-settings.scss";
import React from "react"; import React from "react";
import { Link } from "react-router-dom";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { Features } from "./features" import { Features } from "./features";
import { Removal } from "./removal" import { Removal } from "./removal";
import { Status } from "./status" import { Status } from "./status";
import { General } from "./general" import { General } from "./general";
import { getHostedCluster } from "../../../common/cluster-store"
import { WizardLayout } from "../layout/wizard-layout"; import { WizardLayout } from "../layout/wizard-layout";
import { ClusterIcon } from "../cluster-icon";
import { Icon } from "../icon";
import { getMatchedCluster } from "../cluster-manager/cluster-view.route";
import { navigate } from "../../navigation";
@observer @observer
export class ClusterSettings extends React.Component { export class ClusterSettings extends React.Component {
render() { render() {
const cluster = getHostedCluster(); const cluster = getMatchedCluster();
if (!cluster) return null;
const header = (
<>
<ClusterIcon
cluster={cluster}
showErrors={false}
showTooltip={false}
/>
<h2>{cluster.preferences.clusterName}</h2>
<Icon material="close" onClick={() => navigate("/")} big/>
</>
);
return ( return (
<WizardLayout className="ClusterSettings"> <div className="ClusterSettings">
<Status cluster={cluster}></Status> <WizardLayout header={header} centered>
<General cluster={cluster}></General> <Status cluster={cluster}></Status>
<Features cluster={cluster}></Features> <General cluster={cluster}></General>
<Removal cluster={cluster}></Removal> <Features cluster={cluster}></Features>
</WizardLayout> <Removal cluster={cluster}></Removal>
) </WizardLayout>
</div>
);
} }
} }

View File

@ -1,86 +1,43 @@
import React from "react"; import React from "react";
import { Cluster } from "../../../../main/cluster";
import { Input } from "../../input";
import { Spinner } from "../../spinner";
import { clusterStore } from "../../../../common/cluster-store"
import { Icon } from "../../icon";
import { Tooltip, TooltipPosition } from "../../tooltip";
import { autobind } from "../../../utils";
import { TextInputStatus } from "./statuses"
import { observable } from "mobx"; import { observable } from "mobx";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { Cluster } from "../../../../main/cluster";
import { Input } from "../../input";
import { SubTitle } from "../../layout/sub-title";
interface Props { interface Props {
cluster: Cluster; cluster: Cluster;
} }
@observer @observer
export class ClusterHomeDirSetting extends React.Component<Props> { export class ClusterHomeDirSetting extends React.Component<Props> {
@observable directory = this.props.cluster.preferences.terminalCWD || ""; @observable directory = this.props.cluster.preferences.terminalCWD || "";
@observable status = TextInputStatus.CLEAN;
@observable errorText?: string; save = () => {
this.props.cluster.preferences.terminalCWD = this.directory;
};
onChange = (value: string) => {
this.directory = value;
}
render() { render() {
return <> return (
<h4>Working Directory</h4> <>
<p>Set initial working directory for terminals. When set it will the `pwd` when a new terminal instance is opened for this cluster.</p> <SubTitle title="Working Directory"/>
<Input <p>Terminal working directory.</p>
theme="round-black" <Input
className="box grow" theme="round-black"
value={this.directory} value={this.directory}
onSubmit={this.onWorkingDirectorySubmit} onChange={this.onChange}
onChange={this.onWorkingDirectoryChange} onBlur={this.save}
iconRight={this.getIconRight()} placeholder="$HOME"
placeholder="$HOME" />
/> <span className="hint">
</>; An explicit start path where the terminal will be launched,{" "}
} this is used as the current working directory (cwd) for the shell process.
</span>
@autobind() </>
onWorkingDirectoryChange(directory: string, _e: React.ChangeEvent) { );
if (this.status === TextInputStatus.UPDATING) {
console.log("prevent changing cluster directory while updating");
return;
}
this.status = this.dirDiffers(directory);
this.directory = directory;
}
dirDiffers(directory: string): TextInputStatus {
const { terminalCWD = "" } = this.props.cluster.preferences;
return directory === terminalCWD ? TextInputStatus.CLEAN : TextInputStatus.DIRTY;
}
getIconRight(): React.ReactNode {
switch (this.status) {
case TextInputStatus.CLEAN:
return null;
case TextInputStatus.DIRTY:
return <Icon size="16px" material="fiber_manual_record"/>;
case TextInputStatus.UPDATED:
return <Icon size="16px" className="updated" material="done"/>;
case TextInputStatus.UPDATING:
return <Spinner />;
case TextInputStatus.ERROR:
return <Icon id="cluster-directory-setting-error-icon" size="16px" material="error">
<Tooltip targetId="cluster-directory-setting-error-icon" position={TooltipPosition.TOP}>
{this.errorText}
</Tooltip>
</Icon>
}
}
@autobind()
onWorkingDirectorySubmit(directory: string) {
if (this.dirDiffers(directory) !== TextInputStatus.DIRTY) {
return;
}
this.status = TextInputStatus.UPDATING
this.props.cluster.preferences.terminalCWD = directory;
this.directory = directory;
this.status = TextInputStatus.UPDATED
} }
} }

View File

@ -1,16 +1,20 @@
import React from "react"; import React from "react";
import { Cluster } from "../../../../main/cluster"; import { Cluster } from "../../../../main/cluster";
import { clusterStore } from "../../../../common/cluster-store"
import { Icon } from "../../icon";
import { FilePicker, OverSizeLimitStyle } from "../../file-picker"; import { FilePicker, OverSizeLimitStyle } from "../../file-picker";
import { autobind } from "../../../utils"; import { autobind } from "../../../utils";
import { Button } from "../../button"; import { Button } from "../../button";
import { GeneralInputStatus } from "./statuses"
import { observable } from "mobx"; import { observable } from "mobx";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { SubTitle } from "../../layout/sub-title";
import { ClusterIcon } from "../../cluster-icon";
enum GeneralInputStatus {
CLEAN = "clean",
ERROR = "error",
}
interface Props { interface Props {
cluster: Cluster; cluster: Cluster;
} }
@observer @observer
@ -21,7 +25,6 @@ export class ClusterIconSetting extends React.Component<Props> {
@autobind() @autobind()
async onIconPick([file]: File[]) { async onIconPick([file]: File[]) {
const { cluster } = this.props; const { cluster } = this.props;
try { try {
if (file) { if (file) {
const buf = Buffer.from(await file.arrayBuffer()); const buf = Buffer.from(await file.arrayBuffer());
@ -38,35 +41,36 @@ export class ClusterIconSetting extends React.Component<Props> {
} }
getClearButton() { getClearButton() {
const { cluster } = this.props; if (this.props.cluster.preferences.icon) {
return <Button tooltip="Revert back to default icon" accent onClick={() => this.onIconPick([])}>Clear</Button>
if (cluster.preferences.icon) {
return <Button accent onClick={() => this.onIconPick([])}>Clear</Button>
} }
} }
render() { render() {
return <> const label = (
<h4>Cluster Icon</h4> <>
<p>Set cluster icon. By default it is automatically generated. {this.getIconRight()}</p> <ClusterIcon
<div className="center"> cluster={this.props.cluster}
<FilePicker showErrors={false}
accept="image/*" showTooltip={false}
labelText="Browse for new icon..."
onOverSizeLimit={OverSizeLimitStyle.FILTER}
handler={this.onIconPick}
/> />
{this.getClearButton()} {"Browse for new icon..."}
</div> </>
</>; );
} return (
<>
getIconRight(): React.ReactNode { <SubTitle title="Cluster Icon" />
switch (this.status) { <p>Define cluster icon. By default automatically generated.</p>
case GeneralInputStatus.CLEAN: <div className="file-loader">
return null; <FilePicker
case GeneralInputStatus.ERROR: accept="image/*"
return <Icon size="16px" material="error" title={this.errorText}></Icon> label={label}
} onOverSizeLimit={OverSizeLimitStyle.FILTER}
handler={this.onIconPick}
/>
{this.getClearButton()}
</div>
</>
);
} }
} }

View File

@ -1,85 +1,40 @@
import React from "react"; import React from "react";
import { Cluster } from "../../../../main/cluster"; import { Cluster } from "../../../../main/cluster";
import { Input } from "../../input"; import { Input } from "../../input";
import { Spinner } from "../../spinner";
import { clusterStore } from "../../../../common/cluster-store"
import { Icon } from "../../icon";
import { Tooltip, TooltipPosition } from "../../tooltip";
import { autobind } from "../../../utils";
import { TextInputStatus } from "./statuses"
import { observable } from "mobx"; import { observable } from "mobx";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { SubTitle } from "../../layout/sub-title";
import { isRequired } from "../../input/input.validators";
interface Props { interface Props {
cluster: Cluster; cluster: Cluster;
} }
@observer @observer
export class ClusterNameSetting extends React.Component<Props> { export class ClusterNameSetting extends React.Component<Props> {
@observable name = this.props.cluster.preferences.clusterName || ""; @observable name = this.props.cluster.preferences.clusterName || "";
@observable status = TextInputStatus.CLEAN;
@observable errorText?: string; save = () => {
this.props.cluster.preferences.clusterName = this.name;
};
onChange = (value: string) => {
this.name = value;
}
render() { render() {
return <> return (
<h4>Cluster Name</h4> <>
<p>Change cluster name:</p> <SubTitle title="Cluster Name"/>
<Input <p>Define cluster name.</p>
theme="round-black" <Input
className="box grow" theme="round-black"
value={this.name} validators={isRequired}
onSubmit={this.onClusterNameSubmit} value={this.name}
onChange={this.onClusterNameChange} onChange={this.onChange}
iconRight={this.getIconRight()} onBlur={this.save}
/> />
</>; </>
} );
@autobind()
onClusterNameChange(name: string, _e: React.ChangeEvent) {
if (this.status === TextInputStatus.UPDATING) {
console.log("prevent changing cluster name while updating");
return;
}
this.status = this.nameDiffers(name)
this.name = name;
}
nameDiffers(name: string): TextInputStatus {
const { clusterName } = this.props.cluster.preferences;
return name === clusterName ? TextInputStatus.CLEAN : TextInputStatus.DIRTY;
}
getIconRight(): React.ReactNode {
switch (this.status) {
case TextInputStatus.CLEAN:
return null;
case TextInputStatus.DIRTY:
return <Icon size="16px" material="fiber_manual_record"/>;
case TextInputStatus.UPDATED:
return <Icon size="16px" className="updated" material="done"/>;
case TextInputStatus.UPDATING:
return <Spinner/>;
case TextInputStatus.ERROR:
return <Icon id="cluster-name-setting-error-icon" size="16px" material="error">
<Tooltip targetId="cluster-name-setting-error-icon" position={TooltipPosition.TOP}>
{this.errorText}
</Tooltip>
</Icon>
}
}
@autobind()
onClusterNameSubmit(name: string) {
if (this.nameDiffers(name) !== TextInputStatus.DIRTY) {
return;
}
this.status = TextInputStatus.UPDATING
this.props.cluster.preferences.clusterName = name;
this.name = name;
this.status = TextInputStatus.UPDATED
} }
} }

View File

@ -1,41 +1,105 @@
import React from "react"; import React from "react";
import { Cluster } from "../../../../main/cluster";
import { clusterStore } from "../../../../common/cluster-store"
import { Select, SelectOption, SelectProps } from "../../select";
import { prometheusProviders } from "../../../../common/prometheus-providers";
import { autobind } from "../../../utils";
import { observable } from "mobx";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { prometheusProviders } from "../../../../common/prometheus-providers";
import { Cluster } from "../../../../main/cluster";
import { SubTitle } from "../../layout/sub-title";
import { Select, SelectOption } from "../../select";
import { Input } from "../../input";
import { observable, computed } from "mobx";
const prometheusGuide = "https://github.com/lensapp/lens/blob/master/troubleshooting/custom-prometheus.md";
const options: SelectOption<string>[] = [ const options: SelectOption<string>[] = [
{ value: "", label: "Auto detect" }, { value: "", label: "Auto detect" },
...prometheusProviders.map(pp => ({value: pp.id, label: pp.name})) ...prometheusProviders.map(pp => ({value: pp.id, label: pp.name}))
]; ];
interface Props { interface Props {
cluster: Cluster; cluster: Cluster;
} }
@observer @observer
export class ClusterPrometheusSetting extends React.Component<Props> { export class ClusterPrometheusSetting extends React.Component<Props> {
@observable prometheusProvider = this.props.cluster.preferences.prometheusProvider?.type || ""; @observable path = "";
@observable provider = "";
render() {
return <> @computed get canEditPrometheusPath() {
<h4>Cluster Prometheus</h4> if (this.provider === "" || this.provider === "lens") return false;
<p>Use pre-installed Prometheus service for metrics. Please refer to <a href={prometheusGuide}>this guide</a> for possible configuration changes.</p> return true;
<Select
value={this.prometheusProvider}
options={options}
onChange={this.changePrometheusProvider}
/>
</>;
} }
@autobind() componentDidMount() {
changePrometheusProvider({ value: prometheusProvider }: SelectProps<string>) { const { prometheus, prometheusProvider } = this.props.cluster.preferences;
this.prometheusProvider = prometheusProvider; if (prometheus) {
this.props.cluster.preferences.prometheusProvider = { type: prometheusProvider }; const prefix = prometheus.prefix || "";
this.path = `${prometheus.namespace}/${prometheus.service}:${prometheus.port}${prefix}`;
}
if (prometheusProvider) {
this.provider = prometheusProvider.type;
}
}
parsePrometheusPath = () => {
if (!this.provider || !this.path) {
return null;
}
const parsed = this.path.split(/\/|:/, 3);
const apiPrefix = this.path.substring(parsed.join("/").length);
if (!parsed[0] || !parsed[1] || !parsed[2]) {
return null;
}
return {
namespace: parsed[0],
service: parsed[1],
port: parseInt(parsed[2]),
prefix: apiPrefix
}
}
onSaveProvider = () => {
this.props.cluster.preferences.prometheusProvider = this.provider ?
{ type: this.provider } :
null;
}
onSavePath = () => {
this.props.cluster.preferences.prometheus = this.parsePrometheusPath();
};
render() {
return (
<>
<SubTitle title="Prometheus"/>
<p>
Use pre-installed Prometheus service for metrics. Please refer to the{" "}
<a href="https://github.com/lensapp/lens/blob/master/troubleshooting/custom-prometheus.md" target="_blank">guide</a>{" "}
for possible configuration changes.
</p>
<p>Prometheus installation method.</p>
<Select
value={this.provider}
onChange={({value}) => {
this.provider = value;
this.onSaveProvider();
}}
options={options}
/>
<span className="hint">What query format is used to fetch metrics from Prometheus</span>
{this.canEditPrometheusPath && (
<>
<p>Prometheus service address.</p>
<Input
theme="round-black"
value={this.path}
onChange={(value) => this.path = value}
onBlur={this.onSavePath}
placeholder="<namespace>/<service>:<port>"
/>
<span className="hint">
An address to an existing Prometheus installation{" "}
({'<namespace>/<service>:<port>'}). Lens tries to auto-detect address if left empty.
</span>
</>
)}
</>
);
} }
} }

View File

@ -1,105 +1,41 @@
import React from "react"; import React from "react";
import { Cluster } from "../../../../main/cluster";
import { Input } from "../../input";
import { Spinner } from "../../spinner";
import { clusterStore } from "../../../../common/cluster-store"
import { Icon } from "../../icon";
import { Tooltip, TooltipPosition } from "../../tooltip";
import { autobind } from "../../../utils";
import { TextInputStatus } from "./statuses"
import { observable } from "mobx"; import { observable } from "mobx";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { Cluster } from "../../../../main/cluster";
import { Input } from "../../input";
import { isUrl } from "../../input/input.validators";
import { SubTitle } from "../../layout/sub-title";
interface Props { interface Props {
cluster: Cluster; cluster: Cluster;
} }
@observer @observer
export class ClusterProxySetting extends React.Component<Props> { export class ClusterProxySetting extends React.Component<Props> {
@observable proxy = this.props.cluster.preferences.httpsProxy || ""; @observable proxy = this.props.cluster.preferences.httpsProxy || "";
@observable status = TextInputStatus.CLEAN;
@observable errorText?: string; save = () => {
this.props.cluster.preferences.httpsProxy = this.proxy;
};
onChange = (value: string) => {
this.proxy = value;
}
render() { render() {
return <> return (
<h4>HTTPS Proxy</h4> <>
<p>HTTPS Proxy server. Used for communicating with Kubernetes API.</p> <SubTitle title="HTTP Proxy"/>
<Input <p>HTTP Proxy server. Used for communicating with Kubernetes API.</p>
theme="round-black" <Input
className="box grow" theme="round-black"
value={this.proxy} value={this.proxy}
onSubmit={this.updateClusterProxy} onChange={this.onChange}
onChange={this.changeProxyState} onBlur={this.save}
iconRight={this.getIconRight()} placeholder="http://<address>:<port>"
placeholder="https://<address>:<port>" validators={isUrl}
/> />
</>; </>
} );
@autobind()
changeProxyState(proxy: string, _e: React.ChangeEvent) {
if (this.status === TextInputStatus.UPDATING) {
console.log("prevent changing cluster proxy while updating");
return;
}
this.status = this.proxyDiffers(proxy);
this.proxy = proxy;
}
proxyDiffers(proxy: string): TextInputStatus {
const { httpsProxy = "" } = this.props.cluster.preferences;
return proxy === httpsProxy ? TextInputStatus.CLEAN : TextInputStatus.DIRTY;
}
getIconRight(): React.ReactNode {
switch (this.status) {
case TextInputStatus.CLEAN:
return null;
case TextInputStatus.DIRTY:
return <Icon size="16px" material="fiber_manual_record"/>;
case TextInputStatus.UPDATED:
return <Icon size="16px" className="updated" material="done"/>;
case TextInputStatus.UPDATING:
return <Spinner />;
case TextInputStatus.ERROR:
return <Icon id="cluster-proxy-setting-error-icon" size="16px" material="error">
<Tooltip targetId="cluster-proxy-setting-error-icon" position={TooltipPosition.TOP}>
{this.errorText}
</Tooltip>
</Icon>
}
}
@autobind()
updateClusterProxy(proxy: string) {
if (this.proxyDiffers(proxy) !== TextInputStatus.DIRTY) {
return;
}
try {
const url = new URL(proxy);
if (url.protocol !== "https") {
this.status = TextInputStatus.ERROR
this.errorText= `Proxy's protocol should be "https"`
return
}
if (url.port === "") {
this.status = TextInputStatus.ERROR
this.errorText= "Proxy should include a port"
return
}
} catch (e) {
this.status = TextInputStatus.ERROR
this.errorText= "Invalid URL"
return
}
this.status = TextInputStatus.UPDATING
this.props.cluster.preferences.httpsProxy = proxy;
this.proxy = proxy;
this.status = TextInputStatus.UPDATED
} }
} }

View File

@ -1,36 +1,36 @@
import React from "react"; import React from "react";
import { Cluster } from "../../../../main/cluster";
import { clusterStore } from "../../../../common/cluster-store"
import { workspaceStore } from "../../../../common/workspace-store"
import { Select, SelectOption } from "../../../components/select";
import { GeneralInputStatus } from "./statuses"
import { observable } from "mobx";
import { autobind } from "../../../utils";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { Link } from "react-router-dom";
import { workspacesURL } from "../../+workspaces";
import { workspaceStore } from "../../../../common/workspace-store";
import { Cluster } from "../../../../main/cluster";
import { Select } from "../../../components/select";
import { SubTitle } from "../../layout/sub-title";
interface Props { interface Props {
cluster: Cluster; cluster: Cluster;
} }
@observer @observer
export class ClusterWorkspaceSetting extends React.Component<Props> { export class ClusterWorkspaceSetting extends React.Component<Props> {
@observable workspace = this.props.cluster.workspace;
render() { render() {
return <> return (
<h4>Cluster Workspace</h4> <>
<p>Change cluster workspace:</p> <SubTitle title="Cluster Workspace"/>
<Select <p>
value={workspaceStore.currentWorkspaceId} Define cluster{" "}
options={workspaceStore.workspacesList.map(w => ({value: w.id, label: <span>{w.name}</span>}))} <Link to={workspacesURL()}>
onChange={this.changeWorkspace} workspace
/> </Link>.
</>; </p>
} <Select
value={this.props.cluster.workspace}
@autobind() onChange={({value}) => this.props.cluster.workspace = value}
changeWorkspace({ value: workspace }: SelectOption<string>) { options={workspaceStore.workspacesList.map(w =>
this.workspace = workspace; ({value: w.id, label: w.name})
this.props.cluster.workspace = workspace; )}
/>
</>
);
} }
} }

View File

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

View File

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

View File

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

View File

@ -1,63 +1,37 @@
import React from "react"; import React from "react";
import { Cluster } from "../../../../main/cluster";
import { Button } from "../../button";
import { autobind } from "../../../utils";
import { Spinner } from "../../spinner";
import { Icon } from "../../icon";
import { ConfirmDialog } from "../../confirm-dialog";
import { Trans } from "@lingui/macro"; import { Trans } from "@lingui/macro";
import { observer } from "mobx-react";
import { clusterIpc } from "../../../../common/cluster-ipc"; import { clusterIpc } from "../../../../common/cluster-ipc";
import { clusterStore } from "../../../../common/cluster-store"; import { clusterStore } from "../../../../common/cluster-store";
import { observable } from "mobx"; import { Cluster } from "../../../../main/cluster";
import { observer } from "mobx-react"; import { autobind } from "../../../utils";
import { RemovalStatus } from "./statuses" import { Button } from "../../button";
import { ConfirmDialog } from "../../confirm-dialog";
interface Props { interface Props {
cluster: Cluster; cluster: Cluster;
} }
@observer @observer
export class RemoveClusterButton extends React.Component<Props> { export class RemoveClusterButton extends React.Component<Props> {
@observable status = RemovalStatus.PRESENT; @autobind()
@observable errorText?: string;
render() {
return (
<div className="center">
<Button accent onClick={this.confirmRemoveCluster}>Remove Cluster {this.getStatusIcon()}</Button>
</div>
);
}
getStatusIcon(): React.ReactNode {
switch (this.status) {
case RemovalStatus.PRESENT:
return null;
case RemovalStatus.PROCESSING:
return <Spinner />;
case RemovalStatus.ERROR:
return <Icon size="16px" material="error" title={this.errorText}></Icon>;
}
}
@autobind()
confirmRemoveCluster() { confirmRemoveCluster() {
const { cluster } = this.props; const { cluster } = this.props;
ConfirmDialog.open({ ConfirmDialog.open({
message: <p>Are you sure you want to remove <b>{cluster.preferences.clusterName}</b> from Lens?</p>, message: <p>Are you sure you want to remove <b>{cluster.preferences.clusterName}</b> from Lens?</p>,
labelOk: <Trans>Yes</Trans>, labelOk: <Trans>Yes</Trans>,
labelCancel: <Trans>No</Trans>, labelCancel: <Trans>No</Trans>,
ok: async () => { ok: async () => {
try { await clusterStore.removeById(cluster.id);
this.status = RemovalStatus.PROCESSING;
await clusterIpc.disconnect.invokeFromRenderer(cluster.id);
await clusterStore.removeById(cluster.id);
} catch (err) {
this.status = RemovalStatus.ERROR;
this.errorText = err.toString();
}
} }
}) })
} }
render() {
return (
<Button accent onClick={this.confirmRemoveCluster} className="button-area">
Remove Cluster
</Button>
);
}
} }

View File

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

View File

@ -1,7 +1,9 @@
import React from "react"; import React from "react";
import { Cluster } from "../../../main/cluster"; import { Cluster } from "../../../main/cluster";
import { InstallMetrics } from "./components/install-metrics"; import { InstallFeature } from "./components/install-feature";
import { InstallUserMode } from "./components/install-user-mode"; import { SubTitle } from "../layout/sub-title";
import { MetricsFeature } from "../../../features/metrics";
import { UserModeFeature } from "../../../features/user-mode";
interface Props { interface Props {
cluster: Cluster; cluster: Cluster;
@ -11,10 +13,30 @@ export class Features extends React.Component<Props> {
render() { render() {
const { cluster } = this.props; const { cluster } = this.props;
return <div> return (
<h2>Features</h2> <div>
<InstallMetrics cluster={cluster}/> <h2>Features</h2>
<InstallUserMode cluster={cluster}/> <InstallFeature cluster={cluster} feature={MetricsFeature.id}>
</div>; <>
<SubTitle title="Metrics"/>
<p>
Enable timeseries data visualization (Prometheus stack) for your cluster.
Install this only if you don't have existing Prometheus stack installed.
You can see preview of manifests{" "}
<a href="https://github.com/lensapp/lens/tree/master/src/features/metrics" target="_blank">here</a>.
</p>
</>
</InstallFeature>
<InstallFeature cluster={cluster} feature={UserModeFeature.id}>
<>
<SubTitle title="User Mode"/>
<p>
User Mode feature enables non-admin users to see namespaces they have access to.{" "}
This is achieved by configuring RBAC rules so that every authenticated user is granted to list namespaces.
</p>
</>
</InstallFeature>
</div>
);
} }
} }

View File

@ -15,8 +15,6 @@ export class General extends React.Component<Props> {
render() { render() {
return <div> return <div>
<h2>General</h2> <h2>General</h2>
<hr/>
<ClusterNameSetting cluster={this.props.cluster} /> <ClusterNameSetting cluster={this.props.cluster} />
<ClusterWorkspaceSetting cluster={this.props.cluster} /> <ClusterWorkspaceSetting cluster={this.props.cluster} />
<ClusterIconSetting cluster={this.props.cluster} /> <ClusterIconSetting cluster={this.props.cluster} />

View File

@ -3,16 +3,18 @@ import { Cluster } from "../../../main/cluster";
import { RemoveClusterButton } from "./components/remove-cluster-button"; import { RemoveClusterButton } from "./components/remove-cluster-button";
interface Props { interface Props {
cluster: Cluster; cluster: Cluster;
} }
export class Removal extends React.Component<Props> { export class Removal extends React.Component<Props> {
render() { render() {
const { cluster } = this.props; const { cluster } = this.props;
return <div> return (
<h2>Removal</h2> <div>
<RemoveClusterButton cluster={cluster} /> <h2>Removal</h2>
</div>; <RemoveClusterButton cluster={cluster} />
</div>
);
} }
} }

View File

@ -1,41 +1,40 @@
import React from "react"; import React from "react";
import { Spinner } from "../spinner";
import { Cluster } from "../../../main/cluster"; import { Cluster } from "../../../main/cluster";
import { SubTitle } from "../layout/sub-title";
import { Table, TableCell, TableRow } from "../table";
interface Props { interface Props {
cluster: Cluster; cluster: Cluster;
} }
export class Status extends React.Component<Props> { export class Status extends React.Component<Props> {
renderStatusRows(): JSX.Element[] { renderStatusRows() {
const { cluster } = this.props; const { cluster } = this.props;
const rows = [
const rows: [string, React.ReactNode][] = [ ["Online Status", cluster.online ? "online" : `offline (${cluster.failureReason || "unknown reason"})`],
["Online Status", cluster.online ? "online" : `offline (${cluster.failureReason || "unknown reason"}`],
["Distribution", cluster.distribution], ["Distribution", cluster.distribution],
["Kerbel Version", cluster.version], ["Kerbel Version", cluster.version],
["API Address", cluster.apiUrl], ["API Address", cluster.apiUrl],
["Nodes Count", cluster.nodes || "0"]
]; ];
return (
if (cluster.nodes > 0) { <Table scrollable={false}>
rows.push(["Nodes Count", cluster.nodes]); {rows.map(([name, value]) => {
} return (
<TableRow key={name}>
return rows <TableCell>{name}</TableCell>
.map(([header, value]) => [ <TableCell className="value">{value}</TableCell>
<h5 key={header+"-header"}>{header}</h5>, </TableRow>
<span key={header + "-value"}>{value}</span> );
]) })}
.flat(); </Table>
);
} }
render() { render() {
const { cluster } = this.props;
return <div> return <div>
<h2>Status</h2> <h2>Status</h2>
<hr/> <SubTitle title="Cluster Status"/>
<h4>Cluster status</h4>
<p> <p>
Cluster status information including: detected distribution, kernel version, and online status. Cluster status information including: detected distribution, kernel version, and online status.
</p> </p>

View File

@ -4,7 +4,7 @@ import kebabCase from "lodash/kebabCase";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { Trans } from "@lingui/macro"; import { Trans } from "@lingui/macro";
import { DrawerItem, DrawerTitle } from "../drawer"; import { DrawerItem, DrawerTitle } from "../drawer";
import { cpuUnitsToNumber, cssNames, unitsToBytes } from "../../utils"; import { cpuUnitsToNumber, cssNames, unitsToBytes, metricUnitsToNumber } from "../../utils";
import { KubeObjectDetailsProps } from "../kube-object"; import { KubeObjectDetailsProps } from "../kube-object";
import { ResourceQuota, resourceQuotaApi } from "../../api/endpoints/resource-quota.api"; import { ResourceQuota, resourceQuotaApi } from "../../api/endpoints/resource-quota.api";
import { LineProgress } from "../line-progress"; import { LineProgress } from "../line-progress";
@ -15,24 +15,30 @@ import { KubeObjectMeta } from "../kube-object/kube-object-meta";
interface Props extends KubeObjectDetailsProps<ResourceQuota> { interface Props extends KubeObjectDetailsProps<ResourceQuota> {
} }
@observer const onlyNumbers = /$[0-9]*^/g;
export class ResourceQuotaDetails extends React.Component<Props> {
renderQuotas = (quota: ResourceQuota) => { function transformUnit(name: string, value: string): number {
const { hard, used } = quota.status if (name.includes("memory") || name.includes("storage")) {
if (!hard || !used) return null return unitsToBytes(value)
const transformUnit = (name: string, value: string) => { }
if (name.includes("memory") || name.includes("storage")) {
return unitsToBytes(value) if (name.includes("cpu")) {
} return cpuUnitsToNumber(value)
if (name.includes("cpu")) { }
return cpuUnitsToNumber(value)
} return metricUnitsToNumber(value);
return parseInt(value) }
}
return Object.entries(hard).map(([name, value]) => { function renderQuotas(quota: ResourceQuota): JSX.Element[] {
if (!used[name]) return null const { hard = {}, used = {} } = quota.status
return Object.entries(hard)
.filter(([name]) => used[name])
.map(([name, value]) => {
const current = transformUnit(name, used[name]) const current = transformUnit(name, used[name])
const max = transformUnit(name, value) const max = transformUnit(name, value)
const usage = max === 0 ? 100 : Math.ceil(current / max * 100); // special case 0 max as always 100% usage
return ( return (
<div key={name} className={cssNames("param", kebabCase(name))}> <div key={name} className={cssNames("param", kebabCase(name))}>
<span className="title">{name}</span> <span className="title">{name}</span>
@ -41,14 +47,16 @@ export class ResourceQuotaDetails extends React.Component<Props> {
max={max} max={max}
value={current} value={current}
tooltip={ tooltip={
<p><Trans>Set</Trans>: {value}. <Trans>Used</Trans>: {Math.ceil(current / max * 100) + "%"}</p> <p><Trans>Set</Trans>: {value}. <Trans>Usage</Trans>: {usage + "%"}</p>
} }
/> />
</div> </div>
) )
}) })
} }
@observer
export class ResourceQuotaDetails extends React.Component<Props> {
render() { render() {
const { object: quota } = this.props; const { object: quota } = this.props;
if (!quota) return null; if (!quota) return null;
@ -57,7 +65,7 @@ export class ResourceQuotaDetails extends React.Component<Props> {
<KubeObjectMeta object={quota}/> <KubeObjectMeta object={quota}/>
<DrawerItem name={<Trans>Quotas</Trans>} className="quota-list"> <DrawerItem name={<Trans>Quotas</Trans>} className="quota-list">
{this.renderQuotas(quota)} {renderQuotas(quota)}
</DrawerItem> </DrawerItem>
{quota.getScopeSelector().length > 0 && ( {quota.getScopeSelector().length > 0 && (

View File

@ -11,7 +11,7 @@ import { Service, serviceApi, endpointApi } from "../../api/endpoints";
import { _i18n } from "../../i18n"; import { _i18n } from "../../i18n";
import { apiManager } from "../../api/api-manager"; import { apiManager } from "../../api/api-manager";
import { KubeObjectMeta } from "../kube-object/kube-object-meta"; import { KubeObjectMeta } from "../kube-object/kube-object-meta";
import { ServicePorts } from "./service-ports"; import { ServicePortComponent } from "./service-port-component";
import { endpointStore } from "../+network-endpoints/endpoints.store"; import { endpointStore } from "../+network-endpoints/endpoints.store";
import { ServiceDetailsEndpoint } from "./service-details-endpoint"; import { ServiceDetailsEndpoint } from "./service-details-endpoint";
@ -61,7 +61,13 @@ export class ServiceDetails extends React.Component<Props> {
)} )}
<DrawerItem name={<Trans>Ports</Trans>}> <DrawerItem name={<Trans>Ports</Trans>}>
<ServicePorts service={service}/> <div>
{
service.getPorts().map((port) => (
<ServicePortComponent service={service} port={port} key={port.toString()}/>
))
}
</div>
</DrawerItem> </DrawerItem>
{spec.type === "LoadBalancer" && spec.loadBalancerIP && ( {spec.type === "LoadBalancer" && spec.loadBalancerIP && (

View File

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

View File

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

View File

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

View File

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

View File

@ -1,23 +1,51 @@
.Preferences { .Preferences {
h2 { position: fixed!important; // Allows to cover ClustersMenu
&:not(:first-child) { z-index: 1;
margin-top: $padding * 3;
.WizardLayout {
grid-template-columns: unset;
grid-template-rows: 76px 1fr;
padding: 0;
.content-col {
background-color: transparent;
padding: $padding * 8 0;
h2 {
margin-bottom: $margin * 2;
&:not(:first-child) {
margin-top: $margin * 3;
}
}
.repos {
position: relative;
.Badge {
display: flex;
margin: 0;
margin-bottom: 1px;
padding: $padding $padding * 2;
}
}
.hint {
margin-top: -$margin;
}
} }
} }
.info-block { .is-mac & {
--flex-gap: #{$padding}; .WizardLayout .head-col {
padding-top: 32px;
overflow: hidden;
}
} }
.repos { .Select {
--flex-gap: #{$padding}; &__control {
box-shadow: 0 0 0 1px $borderFaintColor;
> .title {
font-style: italic;
}
.Badge {
margin: $padding / 2;
} }
} }
} }

View File

@ -1,5 +1,5 @@
import "./preferences.scss" import "./preferences.scss"
import React, { Fragment } from "react"; import React from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { action, computed, observable } from "mobx"; import { action, computed, observable } from "mobx";
import { t, Trans } from "@lingui/macro"; import { t, Trans } from "@lingui/macro";
@ -15,11 +15,12 @@ import { Notifications } from "../notifications";
import { Badge } from "../badge"; import { Badge } from "../badge";
import { Spinner } from "../spinner"; import { Spinner } from "../spinner";
import { themeStore } from "../../theme.store"; import { themeStore } from "../../theme.store";
import { history } from "../../navigation";
import { Tooltip } from "../tooltip";
@observer @observer
export class Preferences extends React.Component { export class Preferences extends React.Component {
@observable helmLoading = false; @observable helmLoading = false;
@observable helmUpdating = false;
@observable helmRepos: HelmRepo[] = []; @observable helmRepos: HelmRepo[] = [];
@observable helmAddedRepos = observable.map<string, HelmRepo>(); @observable helmAddedRepos = observable.map<string, HelmRepo>();
@ -88,9 +89,9 @@ export class Preferences extends React.Component {
Notifications.ok(<Trans>Helm branch <b>{repo.name}</b> already in use</Trans>) Notifications.ok(<Trans>Helm branch <b>{repo.name}</b> already in use</Trans>)
return; return;
} }
this.helmUpdating = false; this.helmLoading = true;
await this.addRepo(repo); await this.addRepo(repo);
this.helmUpdating = false; this.helmLoading = false;
} }
formatHelmOptionLabel = ({ value: repo }: SelectOption<HelmRepo>) => { formatHelmOptionLabel = ({ value: repo }: SelectOption<HelmRepo>) => {
@ -103,104 +104,95 @@ export class Preferences extends React.Component {
) )
} }
renderInfo() {
return (
<Fragment>
<h2>
<Trans>Preferences</Trans>
</h2>
<div className="info-block flex gaps align-flex-start">
<Icon small material="info"/>
<p>
<Trans>Lens Global Settings</Trans> (<Trans>applicable to all clusters</Trans>)
</p>
</div>
</Fragment>
)
}
render() { render() {
const { preferences } = userStore; const { preferences } = userStore;
const header = (
<>
<h2>Preferences</h2>
<Icon material="close" big onClick={history.goBack}/>
</>
);
return ( return (
<WizardLayout className="Preferences" infoPanel={this.renderInfo()}> <div className="Preferences">
<h2><Trans>Color Theme</Trans></h2> <WizardLayout header={header} centered>
<Select <h2><Trans>Color Theme</Trans></h2>
options={this.themeOptions} <Select
value={preferences.colorTheme} options={this.themeOptions}
onChange={({ value }: SelectOption) => preferences.colorTheme = value} value={preferences.colorTheme}
/> onChange={({ value }: SelectOption) => preferences.colorTheme = value}
/>
<h2><Trans>Download Mirror</Trans></h2> <h2><Trans>Download Mirror</Trans></h2>
<Select <Select
placeholder={<Trans>Download mirror for kubectl</Trans>} placeholder={<Trans>Download mirror for kubectl</Trans>}
options={this.downloadMirrorOptions} options={this.downloadMirrorOptions}
value={preferences.downloadMirror} value={preferences.downloadMirror}
onChange={({ value }: SelectOption) => preferences.downloadMirror = value} onChange={({ value }: SelectOption) => preferences.downloadMirror = value}
/> />
<h2><Trans>Helm</Trans></h2> <h2><Trans>Helm</Trans></h2>
<Select <Select
placeholder={<Trans>Repositories</Trans>} placeholder={<Trans>Repositories</Trans>}
isLoading={this.helmLoading} isLoading={this.helmLoading}
isDisabled={this.helmUpdating} isDisabled={this.helmLoading}
options={this.helmOptions} options={this.helmOptions}
onChange={this.onRepoSelect} onChange={this.onRepoSelect}
formatOptionLabel={this.formatHelmOptionLabel} formatOptionLabel={this.formatHelmOptionLabel}
controlShouldRenderValue={false} controlShouldRenderValue={false}
/> />
<div className="repos flex gaps align-center"> <div className="repos flex gaps column">
<div className="title">
<Trans>Added repos:</Trans>
</div>
<div className="repos-list">
{this.helmLoading && <Spinner/>}
{Array.from(this.helmAddedRepos).map(([name, repo]) => { {Array.from(this.helmAddedRepos).map(([name, repo]) => {
const tooltipId = `message-${name}`;
return ( return (
<Badge key={name} className="added-repo flex gaps align-center" title={repo.url}> <Badge key={name} className="added-repo flex gaps align-center justify-space-between">
<span className="repo">{name}</span> <span id={tooltipId} className="repo">{name}</span>
<Icon <Icon
material="remove_circle_outline" material="delete"
onClick={() => this.removeRepo(repo)} onClick={() => this.removeRepo(repo)}
tooltip={<Trans>Remove</Trans>} tooltip={<Trans>Remove</Trans>}
/> />
<Tooltip targetId={tooltipId} formatters={{ narrow: true }}>
{repo.url}
</Tooltip>
</Badge> </Badge>
) )
})} })}
</div> </div>
</div>
<h2><Trans>HTTP Proxy</Trans></h2> <h2><Trans>HTTP Proxy</Trans></h2>
<Input <Input
placeholder={_i18n._(t`Type HTTP proxy url (example: http://proxy.acme.org:8080)`)} theme="round-black"
value={preferences.httpsProxy || ""} placeholder={_i18n._(t`Type HTTP proxy url (example: http://proxy.acme.org:8080)`)}
onChange={v => preferences.httpsProxy = v} value={preferences.httpsProxy || ""}
/> onChange={v => preferences.httpsProxy = v}
<small className="hint"> />
<Trans>Proxy is used only for non-cluster communication.</Trans> <small className="hint">
</small> <Trans>Proxy is used only for non-cluster communication.</Trans>
</small>
<h2><Trans>Certificate Trust</Trans></h2> <h2><Trans>Certificate Trust</Trans></h2>
<Checkbox <Checkbox
label={<Trans>Allow untrusted Certificate Authorities</Trans>} label={<Trans>Allow untrusted Certificate Authorities</Trans>}
value={preferences.allowUntrustedCAs} value={preferences.allowUntrustedCAs}
onChange={v => preferences.allowUntrustedCAs = v} onChange={v => preferences.allowUntrustedCAs = v}
/> />
<small className="hint"> <small className="hint">
<Trans>This will make Lens to trust ANY certificate authority without any validations.</Trans>{" "} <Trans>This will make Lens to trust ANY certificate authority without any validations.</Trans>{" "}
<Trans>Needed with some corporate proxies that do certificate re-writing.</Trans>{" "} <Trans>Needed with some corporate proxies that do certificate re-writing.</Trans>{" "}
<Trans>Does not affect cluster communications!</Trans> <Trans>Does not affect cluster communications!</Trans>
</small> </small>
<h2><Trans>Telemetry & Usage Tracking</Trans></h2> <h2><Trans>Telemetry & Usage Tracking</Trans></h2>
<Checkbox <Checkbox
label={<Trans>Allow telemetry & usage tracking</Trans>} label={<Trans>Allow telemetry & usage tracking</Trans>}
value={preferences.allowTelemetry} value={preferences.allowTelemetry}
onChange={v => preferences.allowTelemetry = v} onChange={v => preferences.allowTelemetry = v}
/> />
<small className="hint"> <small className="hint">
<Trans>Telemetry & usage data is collected to continuously improve the Lens experience.</Trans> <Trans>Telemetry & usage data is collected to continuously improve the Lens experience.</Trans>
</small> </small>
</WizardLayout> </WizardLayout>
) </div>
);
} }
} }

View File

@ -7,12 +7,11 @@ import { userStore } from "../../../common/user-store"
import { navigate } from "../../navigation"; import { navigate } from "../../navigation";
import { Button } from "../button"; import { Button } from "../button";
import { Trans } from "@lingui/macro"; import { Trans } from "@lingui/macro";
import { staticDir } from "../../../common/vars";
import marked from "marked" import marked from "marked"
@observer @observer
export class WhatsNew extends React.Component { export class WhatsNew extends React.Component {
releaseNotes = fs.readFileSync(path.join(staticDir, "RELEASE_NOTES.md")).toString(); releaseNotes = fs.readFileSync(path.join(__static, "RELEASE_NOTES.md")).toString();
ok = () => { ok = () => {
navigate("/"); navigate("/");

View File

@ -98,7 +98,7 @@ export function DeploymentMenu(props: KubeObjectMenuProps<Deployment>) {
return ( return (
<KubeObjectMenu {...props}> <KubeObjectMenu {...props}>
<MenuItem onClick={() => DeploymentScaleDialog.open(object)}> <MenuItem onClick={() => DeploymentScaleDialog.open(object)}>
<Icon material="control_camera" title={_i18n._(t`Scale`)} interactive={toolbar}/> <Icon material="open_with" title={_i18n._(t`Scale`)} interactive={toolbar}/>
<span className="title"><Trans>Scale</Trans></span> <span className="title"><Trans>Scale</Trans></span>
</MenuItem> </MenuItem>
</KubeObjectMenu> </KubeObjectMenu>

View File

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

View File

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

View File

@ -8,6 +8,7 @@ import { cssNames } from "../../utils";
import { StatusBrick } from "../status-brick"; import { StatusBrick } from "../status-brick";
import { Badge } from "../badge"; import { Badge } from "../badge";
import { ContainerEnvironment } from "./pod-container-env"; import { ContainerEnvironment } from "./pod-container-env";
import { PodContainerPort } from "./pod-container-port";
import { ResourceMetrics } from "../resource-metrics"; import { ResourceMetrics } from "../resource-metrics";
import { IMetrics } from "../../api/endpoints/metrics.api"; import { IMetrics } from "../../api/endpoints/metrics.api";
import { ContainerCharts } from "./container-charts"; import { ContainerCharts } from "./container-charts";
@ -64,13 +65,10 @@ export class PodDetailsContainer extends React.Component<Props> {
{ports && ports.length > 0 && {ports && ports.length > 0 &&
<DrawerItem name={<Trans>Ports</Trans>}> <DrawerItem name={<Trans>Ports</Trans>}>
{ {
ports.map(port => { ports.map((port) => {
const { name, containerPort, protocol } = port; const key = `${container.name}-port-${port.containerPort}-${port.protocol}`
const key = `${container.name}-port-${containerPort}-${protocol}` return(
return ( <PodContainerPort pod={pod} port={port} key={key}/>
<div key={key}>
{name ? name + ': ' : ''}{containerPort}/{protocol}
</div>
) )
}) })
} }

View File

@ -226,7 +226,7 @@ export class PodLogsDialog extends React.Component<Props> {
tooltip={(showTimestamps ? _i18n._(t`Hide`) : _i18n._(t`Show`)) + " " + _i18n._(t`timestamps`)} tooltip={(showTimestamps ? _i18n._(t`Hide`) : _i18n._(t`Show`)) + " " + _i18n._(t`timestamps`)}
/> />
<Icon <Icon
material="save_alt" material="get_app"
onClick={this.downloadLogs} onClick={this.downloadLogs}
tooltip={_i18n._(t`Save`)} tooltip={_i18n._(t`Save`)}
/> />

View File

@ -13,7 +13,6 @@ interface Props {
export class AppInit extends React.Component<Props> { export class AppInit extends React.Component<Props> {
static async start(rootElem: HTMLElement) { static async start(rootElem: HTMLElement) {
render(<AppInit/>, rootElem); // show loading indicator asap render(<AppInit/>, rootElem); // show loading indicator asap
await AppInit.readyStateCheck(rootElem); // wait while all good to run await AppInit.readyStateCheck(rootElem); // wait while all good to run
} }

View File

@ -82,7 +82,7 @@ hr {
h1 { h1 {
color: white; color: white;
font-size: 28px; font-size: 28px;
font-weight: 300; font-weight: normal;
letter-spacing: -.010em; letter-spacing: -.010em;
margin: 0; margin: 0;
} }
@ -99,13 +99,13 @@ h3 {
h4 { h4 {
@extend h3; @extend h3;
font-size: 16px; font-size: 18px;
} }
h5 { h5 {
@extend h4; @extend h4;
padding: $padding / 2 0; padding: $padding / 2 0;
font-size: 14px; font-size: 16px;
} }
h6 { h6 {

View File

@ -28,14 +28,21 @@ import { DeploymentScaleDialog } from "./+workloads-deployments/deployment-scale
import { CustomResources } from "./+custom-resources/custom-resources"; import { CustomResources } from "./+custom-resources/custom-resources";
import { crdRoute } from "./+custom-resources"; import { crdRoute } from "./+custom-resources";
import { isAllowedResource } from "../../common/rbac"; import { isAllowedResource } from "../../common/rbac";
import { ClusterSettings, clusterSettingsRoute } from "./+cluster-settings";
import { ErrorBoundary } from "./error-boundary"; import { ErrorBoundary } from "./error-boundary";
import { Terminal } from "./dock/terminal"; import { Terminal } from "./dock/terminal";
import { getHostedCluster, getHostedClusterId } from "../../common/cluster-store";
import logger from "../../main/logger";
import { clusterIpc } from "../../common/cluster-ipc";
import { webFrame } from "electron";
@observer @observer
export class App extends React.Component { export class App extends React.Component {
static async init() { static async init() {
const clusterId = getHostedClusterId();
logger.info(`[APP]: Init dashboard, clusterId=${clusterId}`)
await Terminal.preloadFonts() await Terminal.preloadFonts()
await clusterIpc.init.invokeFromRenderer(clusterId, webFrame.routingId);
await getHostedCluster().whenInitialized;
} }
get startURL() { get startURL() {
@ -52,7 +59,6 @@ export class App extends React.Component {
<ErrorBoundary> <ErrorBoundary>
<Switch> <Switch>
<Route component={Cluster} {...clusterRoute}/> <Route component={Cluster} {...clusterRoute}/>
<Route component={ClusterSettings} {...clusterSettingsRoute}/>
<Route component={Nodes} {...nodesRoute}/> <Route component={Nodes} {...nodesRoute}/>
<Route component={Workloads} {...workloadsRoute}/> <Route component={Workloads} {...workloadsRoute}/>
<Route component={Config} {...configRoute}/> <Route component={Config} {...configRoute}/>
@ -66,9 +72,9 @@ export class App extends React.Component {
<Redirect exact from="/" to={this.startURL}/> <Redirect exact from="/" to={this.startURL}/>
<Route component={NotFound}/> <Route component={NotFound}/>
</Switch> </Switch>
<KubeObjectDetails/>
<Notifications/> <Notifications/>
<ConfirmDialog/> <ConfirmDialog/>
<KubeObjectDetails/>
<KubeConfigDialog/> <KubeConfigDialog/>
<AddRoleBindingDialog/> <AddRoleBindingDialog/>
<PodLogsDialog/> <PodLogsDialog/>

View File

@ -7,6 +7,12 @@
user-select: none; user-select: none;
cursor: pointer; cursor: pointer;
&.interactive {
img {
opacity: .55;
}
}
&.active, &.interactive:hover { &.active, &.interactive:hover {
background-color: #fff; background-color: #fff;
@ -16,7 +22,6 @@
} }
img { img {
opacity: .55;
width: var(--size); width: var(--size);
height: var(--size); height: var(--size);
} }

View File

@ -42,12 +42,12 @@ export class ClusterIcon extends React.Component<Props> {
active: isActive, active: isActive,
}); });
return ( return (
<div {...elemProps} className={className} id={clusterIconId}> <div {...elemProps} className={className} id={showTooltip ? clusterIconId : null}>
{showTooltip && ( {showTooltip && (
<Tooltip targetId={clusterIconId}>{clusterName}</Tooltip> <Tooltip targetId={clusterIconId}>{clusterName}</Tooltip>
)} )}
{icon && <img src={icon} alt={clusterName}/>} {icon && <img src={icon} alt={clusterName}/>}
{!icon && <Hashicon value={clusterName} options={options}/>} {!icon && <Hashicon value={clusterId} options={options}/>}
{showErrors && isAdmin && eventCount > 0 && ( {showErrors && isAdmin && eventCount > 0 && (
<Badge <Badge
className={cssNames("events-count", errorClass)} className={cssNames("events-count", errorClass)}

View File

@ -1,13 +1,28 @@
.ClusterManager { .ClusterManager {
display: grid; display: grid;
grid-template-areas: "menu lens-view" "menu lens-view" "bottom-bar bottom-bar"; grid-template-areas: "menu main" "menu main" "bottom-bar bottom-bar";
grid-template-rows: auto 1fr min-content; grid-template-rows: auto 1fr min-content;
grid-template-columns: min-content 1fr; grid-template-columns: min-content 1fr;
height: 100%; height: 100%;
#lens-view { main {
grid-area: main;
position: relative; position: relative;
grid-area: lens-view; display: flex;
> * {
position: absolute;
left: 0;
top: 0;
right: 0;
bottom: 0;
display: flex;
background-color: $mainBackground;
> * {
flex: 1;
}
}
} }
.ClustersMenu { .ClustersMenu {

View File

@ -1,43 +1,68 @@
import "./cluster-manager.scss" import "./cluster-manager.scss"
import React from "react"; import React from "react";
import { observer } from "mobx-react"; import { Redirect, Route, Switch } from "react-router";
import { reaction } from "mobx";
import { disposeOnUnmount, observer } from "mobx-react";
import { ClustersMenu } from "./clusters-menu"; import { ClustersMenu } from "./clusters-menu";
import { BottomBar } from "./bottom-bar"; import { BottomBar } from "./bottom-bar";
import { cssNames, IClassName } from "../../utils"; import { LandingPage, landingRoute, landingURL } from "../+landing-page";
import { ClusterId } from "../../../common/cluster-store";
import { Route, Switch } from "react-router";
import { LandingPage, landingRoute } from "../+landing-page";
import { Preferences, preferencesRoute } from "../+preferences"; import { Preferences, preferencesRoute } from "../+preferences";
import { Workspaces, workspacesRoute } from "../+workspaces"; import { Workspaces, workspacesRoute } from "../+workspaces";
import { AddCluster, addClusterRoute } from "../+add-cluster"; import { AddCluster, addClusterRoute } from "../+add-cluster";
import { ClusterStatus } from "./cluster-status"; import { ClusterView } from "./cluster-view";
import { clusterStatusRoute } from "./cluster-status.route"; import { ClusterSettings, clusterSettingsRoute } from "../+cluster-settings";
import { clusterViewRoute, clusterViewURL, getMatchedCluster, getMatchedClusterId } from "./cluster-view.route";
interface Props { import { clusterStore } from "../../../common/cluster-store";
className?: IClassName; import { hasLoadedView, initView, lensViews, refreshViews } from "./lens-views";
contentClass?: IClassName;
}
@observer @observer
export class ClusterManager extends React.Component<Props> { export class ClusterManager extends React.Component {
activateView(clusterId: ClusterId) { componentDidMount() {
disposeOnUnmount(this, [
reaction(getMatchedClusterId, initView, {
fireImmediately: true
}),
reaction(() => [
hasLoadedView(getMatchedClusterId()), // refresh when cluster's webview loaded
getMatchedCluster()?.available, // refresh on disconnect active-cluster
], refreshViews, {
fireImmediately: true
})
])
}
componentWillUnmount() {
lensViews.clear();
}
get startUrl() {
const { activeClusterId } = clusterStore;
if (activeClusterId) {
return clusterViewURL({
params: {
clusterId: activeClusterId
}
})
}
return landingURL()
} }
render() { render() {
const { className } = this.props;
return ( return (
<div className={cssNames("ClusterManager", className)}> <div className="ClusterManager">
<div id="draggable-top"/> <div id="draggable-top"/>
<div id="lens-view"> <main>
<div id="lens-views"/>
<Switch> <Switch>
<Route component={LandingPage} {...landingRoute}/> <Route component={LandingPage} {...landingRoute}/>
<Route component={Preferences} {...preferencesRoute}/> <Route component={Preferences} {...preferencesRoute}/>
<Route component={Workspaces} {...workspacesRoute}/> <Route component={Workspaces} {...workspacesRoute}/>
<Route component={AddCluster} {...addClusterRoute}/> <Route component={AddCluster} {...addClusterRoute}/>
<Route component={ClusterStatus} {...clusterStatusRoute}/> <Route component={ClusterView} {...clusterViewRoute}/>
<Route render={() => <p>Lens</p>}/> <Route component={ClusterSettings} {...clusterSettingsRoute}/>
<Redirect exact to={this.startUrl}/>
</Switch> </Switch>
</div> </main>
<ClustersMenu/> <ClustersMenu/>
<BottomBar/> <BottomBar/>
</div> </div>

View File

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

View File

@ -1,14 +1,17 @@
.ClusterStatus { .ClusterStatus {
--flex-gap: #{$padding * 2}; --flex-gap: #{$padding * 2};
position: relative;
min-width: 350px; min-width: 350px;
margin: auto; margin: auto;
text-align: center; text-align: center;
z-index: 1;
pre { pre {
@include hidden-scrollbar; @include hidden-scrollbar;
max-width: 70vw; max-width: 70vw;
max-height: 40vh; max-height: 40vh;
white-space: pre-line;
} }
.Icon { .Icon {

View File

@ -2,102 +2,107 @@ import type { KubeAuthProxyLog } from "../../../main/kube-auth-proxy";
import "./cluster-status.scss" import "./cluster-status.scss"
import React from "react"; import React from "react";
import { disposeOnUnmount, observer } from "mobx-react"; import { observer } from "mobx-react";
import { ipcRenderer } from "electron"; import { ipcRenderer } from "electron";
import { autorun, computed, observable } from "mobx"; import { computed, observable } from "mobx";
import { clusterIpc } from "../../../common/cluster-ipc"; import { clusterIpc } from "../../../common/cluster-ipc";
import { Icon } from "../icon"; import { Icon } from "../icon";
import { Button } from "../button"; import { Button } from "../button";
import { cssNames } from "../../utils"; import { cssNames, IClassName } from "../../utils";
import { navigate } from "../../navigation";
import { Cluster } from "../../../main/cluster"; import { Cluster } from "../../../main/cluster";
import { ClusterId, clusterStore } from "../../../common/cluster-store";
import { CubeSpinner } from "../spinner";
interface Props {
className?: IClassName;
clusterId: ClusterId;
}
@observer @observer
export class ClusterStatus extends React.Component { export class ClusterStatus extends React.Component<Props> {
@observable authOutput: KubeAuthProxyLog[] = []; @observable authOutput: KubeAuthProxyLog[] = [];
@observable isReconnecting = false; @observable isReconnecting = false;
// fixme get cluster(): Cluster {
@computed get cluster(): Cluster { return clusterStore.getById(this.props.clusterId);
return null;
} }
@computed get hasErrors(): boolean { @computed get hasErrors(): boolean {
return this.authOutput.some(({ error }) => error) || !!this.cluster.failureReason; return this.authOutput.some(({ error }) => error) || !!this.cluster.failureReason;
} }
@disposeOnUnmount
autoRedirectToMain = autorun(() => {
if (this.cluster.accessible && !this.hasErrors) {
navigate("/");
}
})
async componentDidMount() { async componentDidMount() {
if (this.cluster.disconnected) {
return;
}
this.authOutput = [{ data: "Connecting..." }];
ipcRenderer.on(`kube-auth:${this.cluster.id}`, (evt, res: KubeAuthProxyLog) => { ipcRenderer.on(`kube-auth:${this.cluster.id}`, (evt, res: KubeAuthProxyLog) => {
this.authOutput.push({ this.authOutput.push({
data: res.data.trimRight(), data: res.data.trimRight(),
error: res.error, error: res.error,
}); });
}) })
await this.refreshClusterState(); if (!this.cluster.initialized || this.cluster.disconnected) {
await this.refreshCluster();
}
} }
componentWillUnmount() { componentWillUnmount() {
ipcRenderer.removeAllListeners(`kube-auth:${this.cluster.id}`); ipcRenderer.removeAllListeners(`kube-auth:${this.props.clusterId}`);
} }
async refreshClusterState() { refreshCluster = async () => {
return clusterIpc.activate.invokeFromRenderer(); await clusterIpc.activate.invokeFromRenderer(this.props.clusterId);
} }
reconnect = async () => { reconnect = async () => {
this.authOutput = [{ data: "Reconnecting..." }];
this.isReconnecting = true; this.isReconnecting = true;
await this.refreshClusterState(); await this.refreshCluster();
this.isReconnecting = false; this.isReconnecting = false;
} }
render() { renderContent() {
const { authOutput, cluster, hasErrors } = this; const { authOutput, cluster, hasErrors } = this;
const isDisconnected = !!cluster.disconnected;
const failureReason = cluster.failureReason; const failureReason = cluster.failureReason;
const isError = hasErrors || isDisconnected; if (!hasErrors || this.isReconnecting) {
return ( return (
<div className="ClusterStatus flex column gaps"> <>
{isError && ( <CubeSpinner />
<Icon
material="cloud_off"
className={cssNames({ error: hasErrors })}
/>
)}
<h2>
{cluster.contextName}
</h2>
{!isDisconnected && (
<pre className="kube-auth-out"> <pre className="kube-auth-out">
<p>{this.isReconnecting ? "Reconnecting..." : "Connecting..."}</p>
{authOutput.map(({ data, error }, index) => { {authOutput.map(({ data, error }, index) => {
return <p key={index} className={cssNames({ error })}>{data}</p> return <p key={index} className={cssNames({ error })}>{data}</p>
})} })}
</pre> </pre>
)} </>
);
}
return (
<>
<Icon material="cloud_off" className="error" />
<h2>
{cluster.preferences.clusterName}
</h2>
<pre className="kube-auth-out">
{authOutput.map(({ data, error }, index) => {
return <p key={index} className={cssNames({ error })}>{data}</p>
})}
</pre>
{failureReason && ( {failureReason && (
<div className="failure-reason error">{failureReason}</div> <div className="failure-reason error">{failureReason}</div>
)} )}
{isError && ( <Button
<Button primary
primary label="Reconnect"
label="Reconnect" className="box center"
className="box center" onClick={this.reconnect}
onClick={this.reconnect} waiting={this.isReconnecting}
waiting={this.isReconnecting} />
/> </>
)} );
}
render() {
return (
<div className={cssNames("ClusterStatus flex column gaps box center align-center justify-center", this.props.className)}>
{this.renderContent()}
</div> </div>
) );
} }
} }

View File

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

View File

@ -0,0 +1,5 @@
.ClusterView {
&:empty {
display: none;
}
}

View 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>
)
}
}

View File

@ -20,7 +20,7 @@ import { landingURL } from "../+landing-page";
import { Tooltip } from "../tooltip"; import { Tooltip } from "../tooltip";
import { ConfirmDialog } from "../confirm-dialog"; import { ConfirmDialog } from "../confirm-dialog";
import { clusterIpc } from "../../../common/cluster-ipc"; import { clusterIpc } from "../../../common/cluster-ipc";
import { clusterStatusURL } from "./cluster-status.route"; import { clusterViewURL, getMatchedClusterId } from "./cluster-view.route";
// fixme: allow to rearrange clusters with drag&drop // fixme: allow to rearrange clusters with drag&drop
@ -33,11 +33,8 @@ export class ClustersMenu extends React.Component<Props> {
@observable showHint = true; @observable showHint = true;
showCluster = (clusterId: ClusterId) => { showCluster = (clusterId: ClusterId) => {
if (clusterStore.activeClusterId === clusterId) { clusterStore.setActive(clusterId);
navigate("/"); // redirect to index navigate(clusterViewURL({ params: { clusterId } }));
} else {
clusterStore.activeClusterId = clusterId;
}
} }
addCluster = () => { addCluster = () => {
@ -50,16 +47,22 @@ export class ClustersMenu extends React.Component<Props> {
menu.append(new MenuItem({ menu.append(new MenuItem({
label: _i18n._(t`Settings`), label: _i18n._(t`Settings`),
click: () => navigate(clusterSettingsURL()) click: () => {
navigate(clusterSettingsURL({
params: {
clusterId: cluster.id
}
}))
}
})); }));
if (cluster.online) { if (cluster.online) {
menu.append(new MenuItem({ menu.append(new MenuItem({
label: _i18n._(t`Disconnect`), label: _i18n._(t`Disconnect`),
click: async () => { click: async () => {
await clusterIpc.disconnect.invokeFromRenderer(cluster.id); if (clusterStore.isActive(cluster.id)) {
if (cluster.id === clusterStore.activeClusterId) { navigate(landingURL());
navigate(clusterStatusURL());
} }
await clusterIpc.disconnect.invokeFromRenderer(cluster.id);
} }
})) }))
} }
@ -72,7 +75,10 @@ export class ClustersMenu extends React.Component<Props> {
accent: true, accent: true,
label: _i18n._(t`Remove`), label: _i18n._(t`Remove`),
}, },
ok: () => clusterStore.removeById(cluster.id), ok: () => {
clusterStore.removeById(cluster.id);
navigate(landingURL());
},
message: <p>Are you sure want to remove cluster <b title={cluster.id}>{cluster.contextName}</b>?</p>, message: <p>Are you sure want to remove cluster <b title={cluster.id}>{cluster.contextName}</b>?</p>,
}) })
} }
@ -110,7 +116,7 @@ export class ClustersMenu extends React.Component<Props> {
key={cluster.id} key={cluster.id}
showErrors={true} showErrors={true}
cluster={cluster} cluster={cluster}
isActive={cluster.id === clusterStore.activeClusterId} isActive={cluster.id === getMatchedClusterId()}
onClick={() => this.showCluster(cluster.id)} onClick={() => this.showCluster(cluster.id)}
onContextMenu={() => this.showContextMenu(cluster)} onContextMenu={() => this.showContextMenu(cluster)}
/> />

View 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"
})
}

View File

@ -1,14 +1,11 @@
.FilePicker { .FilePicker {
input[type="file"] { input[type="file"] {
display: none; display: none;
} }
label { label {
display: inline-block; display: inline-flex;
border: medium solid; cursor: pointer;
padding: 10px; color: var(--blue);
border-radius: 5px; }
cursor: pointer;
margin: 5px;
}
} }

View File

@ -43,7 +43,7 @@ export enum OverTotalSizeLimitStyle {
export interface BaseProps { export interface BaseProps {
accept?: string; accept?: string;
labelText: string; label: React.ReactNode;
multiple?: boolean; multiple?: boolean;
// limit is the optional maximum number of files to upload // limit is the optional maximum number of files to upload
@ -175,10 +175,10 @@ export class FilePicker extends React.Component<Props> {
} }
render() { render() {
const { accept, labelText, multiple } = this.props; const { accept, label, multiple } = this.props;
return <div className="FilePicker"> return <div className="FilePicker">
<label htmlFor="file-upload">{labelText} {this.getIconRight()}</label> <label className="flex gaps align-center" htmlFor="file-upload">{label} {this.getIconRight()}</label>
<input <input
id="file-upload" id="file-upload"
name="FilePicker" name="FilePicker"

View File

@ -74,7 +74,7 @@
.input-info { .input-info {
.errors { .errors {
color: var(color-error); color: var(--colorError);
font-size: $font-size-small; font-size: $font-size-small;
} }

View File

@ -285,6 +285,7 @@ export class Input extends React.Component<InputProps, State> {
rows: multiLine ? (rows || 1) : null, rows: multiLine ? (rows || 1) : null,
ref: this.bindRef, ref: this.bindRef,
type: "text", type: "text",
spellCheck: "false",
}); });
return ( return (

View File

@ -38,7 +38,7 @@ export const isNumber: Validator = {
export const isUrl: Validator = { export const isUrl: Validator = {
condition: ({ type }) => type === "url", condition: ({ type }) => type === "url",
message: () => _i18n._(t`Wrong url format`), message: () => _i18n._(t`Wrong url format`),
validate: value => !!value.match(/^(?:http(s)?:\/\/)?[\w.-]+(?:\.[\w\.-]+)+[\w\-\._~:/?#[\]@!\$&'\(\)\*\+,;=.]+$/), validate: value => !!value.match(/^http(s)?:\/\/\w+(\.\w+)*(:[0-9]+)?\/?(\/[\w\-\._~:/?#[\]@!\$&'\(\)\*\+,;=.]*)*$/),
}; };
export const minLength: Validator = { export const minLength: Validator = {
@ -53,9 +53,10 @@ export const maxLength: Validator = {
validate: (value, { maxLength }) => value.length <= maxLength, validate: (value, { maxLength }) => value.length <= maxLength,
}; };
const systemNameMatcher = /^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$/;
export const systemName: Validator = { export const systemName: Validator = {
message: () => _i18n._(t`This field must contain only lowercase latin characters, numbers and dash.`), message: () => _i18n._(t`A System Name must be lowercase DNS labels separated by dots. DNS labels are alphanumerics and dashes enclosed by alphanumerics.`),
validate: value => !!value.match(/^[a-z0-9-]+$/), validate: value => !!value.match(systemNameMatcher),
}; };
export const accountId: Validator = { export const accountId: Validator = {

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

View File

@ -48,11 +48,12 @@ export class MainLayout extends React.Component<Props> {
render() { render() {
const { className, contentClass, headerClass, tabs, footer, footerClass, children } = this.props; const { className, contentClass, headerClass, tabs, footer, footerClass, children } = this.props;
const routePath = navigation.location.pathname; const routePath = navigation.location.pathname;
const cluster = getHostedCluster();
return ( return (
<div className={cssNames("MainLayout", className, themeStore.activeTheme.type)}> <div className={cssNames("MainLayout", className, themeStore.activeTheme.type)}>
<header className={cssNames("flex gaps align-center", headerClass)}> <header className={cssNames("flex gaps align-center", headerClass)}>
<span className="cluster"> <span className="cluster">
{getHostedCluster().contextName} {cluster.preferences?.clusterName || cluster.contextName}
</span> </span>
</header> </header>

View File

@ -13,11 +13,21 @@
padding: $spacing; padding: $spacing;
} }
> .head-col {
position: sticky;
border-bottom: 1px solid $grey-800;
justify-content: space-between;
}
> .content-col { > .content-col {
margin-right: $spacing; margin-right: $spacing;
background-color: var(--clusters-menu-bgc); background-color: var(--clusters-menu-bgc);
border-radius: $radius; border-radius: $radius;
> div {
flex: 1;
}
> .error { > .error {
border-radius: $radius; border-radius: $radius;
padding: $padding; padding: $padding;
@ -29,7 +39,24 @@
border-left: 1px solid #353a3e; border-left: 1px solid #353a3e;
} }
p {
line-height: 140%;
}
a { a {
color: $colorInfo; color: $colorInfo;
} }
&.centered {
.content-col {
margin: 0;
> div {
margin: 0 auto;
width: 60%;
min-width: 570px;
max-width: 1000px;
}
}
}
} }

View File

@ -5,23 +5,35 @@ import { cssNames, IClassName } from "../../utils";
interface Props { interface Props {
className?: IClassName; className?: IClassName;
header?: React.ReactNode;
headerClass?: IClassName;
contentClass?: IClassName; contentClass?: IClassName;
infoPanelClass?: IClassName; infoPanelClass?: IClassName;
infoPanel?: React.ReactNode; infoPanel?: React.ReactNode;
centered?: boolean; // Centering content horizontally
} }
@observer @observer
export class WizardLayout extends React.Component<Props> { export class WizardLayout extends React.Component<Props> {
render() { render() {
const { className, contentClass, infoPanelClass, infoPanel, children: content } = this.props; const { className, contentClass, infoPanelClass, infoPanel, header, headerClass, centered, children: content } = this.props;
return ( return (
<div className={cssNames("WizardLayout", className)}> <div className={cssNames("WizardLayout", { centered }, className)}>
{header && (
<div className={cssNames("head-col flex gaps align-center", headerClass)}>
{header}
</div>
)}
<div className={cssNames("content-col flex column gaps", contentClass)}> <div className={cssNames("content-col flex column gaps", contentClass)}>
{content} <div className="flex column gaps">
</div> {content}
<div className={cssNames("info-col flex column gaps", infoPanelClass)}> </div>
{infoPanel}
</div> </div>
{infoPanel && (
<div className={cssNames("info-col flex column gaps", infoPanelClass)}>
{infoPanel}
</div>
)}
</div> </div>
) )
} }

View File

@ -23,7 +23,7 @@ html {
&--is-disabled { &--is-disabled {
opacity: .75; opacity: .75;
cursor: not-allowed; cursor: not-allowed;
pointer-events: auto; pointer-events: none;
} }
&__control { &__control {
@ -42,6 +42,10 @@ html {
margin-bottom: 1px; margin-bottom: 1px;
} }
&__single-value {
color: var(--textColorSecondary);
}
&__indicator { &__indicator {
padding: $padding /2; padding: $padding /2;
opacity: .55; opacity: .55;

View File

@ -9,6 +9,8 @@ import { _i18n } from "./i18n";
import { ClusterManager } from "./components/cluster-manager"; import { ClusterManager } from "./components/cluster-manager";
import { ErrorBoundary } from "./components/error-boundary"; import { ErrorBoundary } from "./components/error-boundary";
import { WhatsNew, whatsNewRoute } from "./components/+whats-new"; import { WhatsNew, whatsNewRoute } from "./components/+whats-new";
import { Notifications } from "./components/notifications";
import { ConfirmDialog } from "./components/confirm-dialog";
@observer @observer
export class LensApp extends React.Component { export class LensApp extends React.Component {
@ -23,6 +25,8 @@ export class LensApp extends React.Component {
<Route component={ClusterManager}/> <Route component={ClusterManager}/>
</Switch> </Switch>
</ErrorBoundary> </ErrorBoundary>
<Notifications/>
<ConfirmDialog/>
</Router> </Router>
</I18nProvider> </I18nProvider>
) )

View File

@ -1,14 +1,24 @@
// Navigation helpers // Navigation helpers
import { ipcRenderer } from "electron";
import { compile } from "path-to-regexp" import { compile } from "path-to-regexp"
import { createBrowserHistory, createMemoryHistory, Location, LocationDescriptor } from "history"; import { createBrowserHistory, createMemoryHistory, LocationDescriptor } from "history";
import { createObservableHistory } from "mobx-observable-history"; import { createObservableHistory } from "mobx-observable-history";
import logger from "../main/logger";
export const history = typeof window !== "undefined" ? createBrowserHistory() : createMemoryHistory(); export const history = typeof window !== "undefined" ? createBrowserHistory() : createMemoryHistory();
export const navigation = createObservableHistory(history); export const navigation = createObservableHistory(history);
// handle navigation from other process (e.g. system menus in main, common->cluster view interactions)
if (ipcRenderer) {
ipcRenderer.on("menu:navigate", (event, location: LocationDescriptor) => {
logger.info(`[IPC]: ${event.type} ${JSON.stringify(location)}`, event);
navigate(location);
})
}
export function navigate(location: LocationDescriptor) { export function navigate(location: LocationDescriptor) {
navigation.location = location as Location; navigation.push(location);
} }
export interface IURLParams<P = {}, Q = {}> { export interface IURLParams<P = {}, Q = {}> {
@ -16,6 +26,8 @@ export interface IURLParams<P = {}, Q = {}> {
query?: IQueryParams & Q; query?: IQueryParams & Q;
} }
// todo: extract building urls to commons (also used in menu.ts)
// fixme: missing types validation for params & query
export function buildURL<P extends object, Q = object>(path: string | string[]) { export function buildURL<P extends object, Q = object>(path: string | string[]) {
const pathBuilder = compile(path.toString()); const pathBuilder = compile(path.toString());
return function ({ params, query }: IURLParams<P, Q> = {}) { return function ({ params, query }: IURLParams<P, Q> = {}) {

View File

@ -1,10 +1,13 @@
// Helper to convert CPU K8S units to numbers // Helper to convert CPU K8S units to numbers
const thousand = 1000;
const million = thousand * thousand;
const shortBillion = thousand * million;
export function cpuUnitsToNumber(cpu: string) { export function cpuUnitsToNumber(cpu: string) {
const cpuNum = parseInt(cpu) const cpuNum = parseInt(cpu)
const billion = 1000000 * 1000 if (cpu.includes("m")) return cpuNum / thousand
if (cpu.includes("m")) return cpuNum / 1000 if (cpu.includes("u")) return cpuNum / million
if (cpu.includes("u")) return cpuNum / 1000000 if (cpu.includes("n")) return cpuNum / shortBillion
if (cpu.includes("n")) return cpuNum / billion
return parseFloat(cpu) return parseFloat(cpu)
} }

View File

@ -7,9 +7,9 @@ export function unitsToBytes(value: string) {
if (!suffixes.some(suffix => value.includes(suffix))) { if (!suffixes.some(suffix => value.includes(suffix))) {
return parseFloat(value) return parseFloat(value)
} }
const index = suffixes.findIndex(suffix =>
suffix == value.replace(/[0-9]|i|\./g, '') const suffix = value.replace(/[0-9]|i|\./g, '');
) const index = suffixes.indexOf(suffix);
return parseInt( return parseInt(
(parseFloat(value) * Math.pow(base, index + 1)).toFixed(1) (parseFloat(value) * Math.pow(base, index + 1)).toFixed(1)
) )
@ -21,8 +21,10 @@ export function bytesToUnits(bytes: number, precision = 1) {
if (!bytes) { if (!bytes) {
return "N/A" return "N/A"
} }
if (index === 0) { if (index === 0) {
return `${bytes}${sizes[index]}` return `${bytes}${sizes[index]}`
} }
return `${(bytes / (1024 ** index)).toFixed(precision)}${sizes[index]}i` return `${(bytes / (1024 ** index)).toFixed(precision)}${sizes[index]}i`
} }

View File

@ -20,3 +20,4 @@ export * from './formatDuration'
export * from './isReactNode' export * from './isReactNode'
export * from './convertMemory' export * from './convertMemory'
export * from './convertCpu' export * from './convertCpu'
export * from './metricUnitsToNumber'

View 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)
)
}

View 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
View File

@ -4,6 +4,9 @@ declare module "win-ca"
declare module "@hapi/call" declare module "@hapi/call"
declare module "@hapi/subtext" declare module "@hapi/subtext"
// Global path to static assets
declare const __static: string;
// Support import for custom module extensions // Support import for custom module extensions
// https://www.typescriptlang.org/docs/handbook/modules.html#wildcard-module-declarations // https://www.typescriptlang.org/docs/handbook/modules.html#wildcard-module-declarations
declare module "*.scss" { declare module "*.scss" {

View File

@ -1,10 +1,10 @@
import path from "path"; import path from "path";
import webpack, { LibraryTarget } from "webpack"; import webpack, { LibraryTarget } from "webpack";
import { isDevelopment, outDir } from "./src/common/vars"; import { isDevelopment, buildDir } from "./src/common/vars";
export const library = "dll" export const library = "dll"
export const libraryTarget: LibraryTarget = "commonjs2" export const libraryTarget: LibraryTarget = "commonjs2"
export const manifestPath = path.resolve(outDir, `${library}.manifest.json`); export const manifestPath = path.resolve(buildDir, `${library}.manifest.json`);
export const packages = [ export const packages = [
"react", "react-dom", "react", "react-dom",

View File

@ -1,10 +1,11 @@
import path from "path"; import path from "path";
import webpack from "webpack"; import webpack from "webpack";
import ForkTsCheckerPlugin from "fork-ts-checker-webpack-plugin" import ForkTsCheckerPlugin from "fork-ts-checker-webpack-plugin"
import { isDevelopment, isProduction, mainDir, outDir } from "./src/common/vars"; import { isDevelopment, isProduction, mainDir, buildDir } from "./src/common/vars";
import nodeExternals from "webpack-node-externals"; import nodeExternals from "webpack-node-externals";
export default function (): webpack.Configuration { export default function (): webpack.Configuration {
console.info('WEBPACK:main', require("./src/common/vars"))
return { return {
context: __dirname, context: __dirname,
target: "electron-main", target: "electron-main",
@ -15,7 +16,7 @@ export default function (): webpack.Configuration {
main: path.resolve(mainDir, "index.ts"), main: path.resolve(mainDir, "index.ts"),
}, },
output: { output: {
path: outDir, path: buildDir,
}, },
resolve: { resolve: {
extensions: ['.json', '.js', '.ts'] extensions: ['.json', '.js', '.ts']

Some files were not shown because too many files have changed in this diff Show More