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
coverage/
tmp/
static/build/client/
static/build/**
binaries/client/
binaries/server/
locales/**/**.js

View File

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

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -135,8 +135,4 @@ export class BaseStore<T = any> extends Singleton {
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";
export const clusterIpc = {
init: createIpcChannel({
channel: "cluster:init",
handle: async (clusterId: ClusterId, frameId: number) => {
const cluster = clusterStore.getById(clusterId);
if (cluster) {
cluster.frameId = frameId; // save cluster's webFrame.routingId to be able to send push-updates
return cluster.pushState();
}
},
}),
activate: createIpcChannel({
channel: "cluster:activate",
handle: async (clusterId: ClusterId = clusterStore.activeClusterId) => {
handle: (clusterId: ClusterId) => {
return clusterStore.getById(clusterId)?.activate();
},
}),
disconnect: createIpcChannel({
channel: "cluster:disconnect",
handle: (clusterId: ClusterId = clusterStore.activeClusterId) => {
handle: (clusterId: ClusterId) => {
tracker.event("cluster", "stop");
return clusterStore.getById(clusterId)?.disconnect();
},
@ -23,7 +33,6 @@ export const clusterIpc = {
handle: async (clusterId: ClusterId, feature: string, config?: any) => {
tracker.event("cluster", "install", feature);
const cluster = clusterStore.getById(clusterId);
if (cluster) {
await cluster.installFeature(feature, config)
} else {

View File

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

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

View File

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

View File

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

View File

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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 {
--flex-gap: #{$padding * 2};
position: relative;
min-width: 350px;
margin: auto;
text-align: center;
z-index: 1;
pre {
@include hidden-scrollbar;
max-width: 70vw;
max-height: 40vh;
white-space: pre-line;
}
.Icon {

View File

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

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

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 {
input[type="file"] {
display: none;
}
input[type="file"] {
display: none;
}
label {
display: inline-block;
border: medium solid;
padding: 10px;
border-radius: 5px;
cursor: pointer;
margin: 5px;
}
label {
display: inline-flex;
cursor: pointer;
color: var(--blue);
}
}

View File

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

View File

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

View File

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

View File

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

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() {
const { className, contentClass, headerClass, tabs, footer, footerClass, children } = this.props;
const routePath = navigation.location.pathname;
const cluster = getHostedCluster();
return (
<div className={cssNames("MainLayout", className, themeStore.activeTheme.type)}>
<header className={cssNames("flex gaps align-center", headerClass)}>
<span className="cluster">
{getHostedCluster().contextName}
{cluster.preferences?.clusterName || cluster.contextName}
</span>
</header>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -20,3 +20,4 @@ export * from './formatDuration'
export * from './isReactNode'
export * from './convertMemory'
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/subtext"
// Global path to static assets
declare const __static: string;
// Support import for custom module extensions
// https://www.typescriptlang.org/docs/handbook/modules.html#wildcard-module-declarations
declare module "*.scss" {

View File

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

View File

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

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