1
0
mirror of https://github.com/lensapp/lens.git synced 2025-05-20 05:10:56 +00:00

Views management refactoring (#669)

* Views management refactoring

Signed-off-by: Roman <ixrock@gmail.com>
Signed-off-by: Lauri Nevala <lauri.nevala@gmail.com>
This commit is contained in:
Roman 2020-08-19 10:49:39 +03:00 committed by GitHub
parent 592c8920b2
commit 47bcb63c95
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
72 changed files with 1958 additions and 1505 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

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

@ -1,10 +1,8 @@
import type { WorkspaceId } from "./workspace-store";
import path from "path";
import filenamify from "filenamify";
import { app, ipcRenderer, remote } from "electron";
import { copyFile, ensureDir, unlink } from "fs-extra";
import { unlink } from "fs-extra";
import { action, computed, observable, toJS } from "mobx";
import { appProto, noClustersHost } from "./vars";
import { BaseStore } from "./base-store";
import { Cluster, ClusterState } from "../main/cluster";
import migrations from "../migrations/cluster-store"
@ -64,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);
})
})
}
@ -86,6 +83,10 @@ 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;
}
@ -162,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 {
@ -181,12 +177,11 @@ export class ClusterStore extends BaseStore<ClusterStoreModel> {
export const clusterStore = ClusterStore.getInstance<ClusterStore>();
export function isNoClustersView() {
return location.hostname === noClustersHost
}
export function getHostedClusterId() {
return location.hostname.split(".")[0];
export function getHostedClusterId(): ClusterId {
const clusterHost = location.hostname.match(/^(.*?)\.localhost/);
if (clusterHost) {
return clusterHost[1]
}
}
export function getHostedCluster(): Cluster {

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

@ -153,7 +153,7 @@ export function saveConfigToAppFiles(clusterId: string, kubeConfig: KubeConfig |
export async function getKubeConfigLocal(): Promise<string> {
try {
const configFile = path.join(process.env.HOME, '.kube', 'config');
const configFile = path.join(os.homedir(), '.kube', 'config');
const file = await readFile(configFile, "utf8");
const obj = yaml.safeLoad(file);
if (obj.contexts) {

View File

@ -71,7 +71,6 @@ export class UserStore extends BaseStore<UserStoreModel> {
if (kubeConfig) {
this.newContexts.clear();
const localContexts = loadConfig(kubeConfig).getContexts();
console.log(localContexts)
localContexts
.filter(ctx => ctx.cluster)
.filter(ctx => !this.seenContexts.has(ctx.name))

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,20 +11,25 @@ export const isDevelopment = isDebugging || !isProduction;
export const isTestEnv = !!process.env.JEST_WORKER_ID;
export const appName = `${packageInfo.productName}${isDevelopment ? "Dev" : ""}`
export const appProto = "lens" // app.getPath("userData") folder
export const staticProto = "static" // static folder (e.g. "static://RELEASE_NOTES.md")
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");
// System pages
export const noClustersHost = "no-clusters.localhost"
// 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

View File

@ -1,10 +1,10 @@
import "../common/cluster-ipc";
import type http from "http"
import { autorun } from "mobx";
import { apiKubePrefix } from "../common/vars";
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) {
@ -30,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() {

View File

@ -1,7 +1,8 @@
import type { ClusterId, ClusterModel, ClusterPreferences } from "../common/cluster-store"
import type { FeatureStatusMap } from "./feature"
import type { IMetricsReqParams } from "../renderer/api/endpoints/metrics.api";
import type { WorkspaceId } from "../common/workspace-store";
import { action, observable, reaction, toJS, when } from "mobx";
import type { FeatureStatusMap } from "./feature"
import { action, computed, observable, reaction, toJS, when } from "mobx";
import { apiKubePrefix } from "../common/vars";
import { broadcastIpc } from "../common/ipc";
import { ContextHandler } from "./context-handler"
@ -39,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;
@ -52,7 +54,6 @@ export class Cluster implements ClusterModel {
@observable kubeConfigPath: string;
@observable apiUrl: string; // cluster server url
@observable kubeProxyUrl: string; // lens-proxy to kube-api url
@observable webContentUrl: string; // page content url for loading in renderer
@observable online: boolean;
@observable accessible: boolean;
@observable disconnected: boolean;
@ -67,6 +68,10 @@ export class Cluster implements ClusterModel {
@observable allowedNamespaces: string[] = [];
@observable allowedResources: string[] = [];
@computed get available() {
return this.accessible && !this.disconnected;
}
constructor(model: ClusterModel) {
this.updateModel(model);
}
@ -74,26 +79,21 @@ 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;
}
@action
async init(port: number) {
if (this.initialized) {
return;
}
try {
this.contextHandler = new ContextHandler(this);
this.kubeconfigManager = new KubeconfigManager(this, this.contextHandler);
this.kubeProxyUrl = `http://localhost:${port}${apiKubePrefix}`;
this.webContentUrl = `http://${this.id}.localhost:${port}`;
this.initialized = true;
logger.info(`[CLUSTER]: init success`, {
logger.info(`[CLUSTER]: "${this.contextName}" init success`, {
id: this.id,
serverUrl: this.apiUrl,
webContentUrl: this.webContentUrl,
kubeProxyUrl: this.kubeProxyUrl,
context: this.contextName,
apiUrl: this.apiUrl
});
} catch (err) {
logger.error(`[CLUSTER]: init failed: ${err}`, {
@ -155,7 +155,7 @@ export class Cluster implements ClusterModel {
@action
async refresh() {
logger.info(`[CLUSTER]: refresh`, this.getMeta());
await this.refreshConnectionStatus();
await this.refreshConnectionStatus(); // refresh "version", "online", etc.
if (this.accessible) {
this.kubeCtl = new Kubectl(this.version)
this.distribution = this.detectKubernetesDistribution(this.version)
@ -217,22 +217,30 @@ export class Cluster implements ClusterModel {
return uninstallFeature(name, this)
}
getPrometheusApiPrefix() {
return this.preferences.prometheus?.prefix || ""
}
protected async k8sRequest(path: string, options: RequestPromiseOptions = {}) {
protected async k8sRequest<T = any>(path: string, options: RequestPromiseOptions = {}): Promise<T> {
const apiUrl = this.kubeProxyUrl + path;
return request(apiUrl, {
json: true,
timeout: 5000,
...options,
headers: {
Host: `${this.id}.${new URL(this.kubeProxyUrl).host}`, // required in ClusterManager.getClusterForRequest()
...(options.headers || {}),
Host: new URL(this.webContentUrl).host,
},
})
}
getMetrics(prometheusPath: string, queryParams: IMetricsReqParams & { query: string }) {
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,
})
}
protected async getConnectionStatus(): Promise<ClusterStatus> {
try {
const response = await this.k8sRequest("/version")
@ -382,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,9 +3,8 @@
import "../common/system-ca"
import "../common/prometheus-providers"
import { app, dialog } from "electron"
import { appName, appProto, staticDir, staticProto } from "../common/vars";
import { appName } from "../common/vars";
import path from "path"
import { initMenu } from "./menu"
import { LensProxy } from "./lens-proxy"
import { WindowManager } from "./window-manager";
import { ClusterManager } from "./cluster-manager";
@ -20,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;
@ -31,18 +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(appProto, app.getPath("userData"));
registerFileProtocol(staticProto, staticDir);
registerFileProtocol("static", __static);
// find free port
let proxyPort: number
@ -74,7 +74,6 @@ async function main() {
// create window manager and open app
windowManager = new WindowManager(proxyPort);
initMenu(windowManager);
}
app.on("ready", main);

View File

@ -7,10 +7,11 @@ import { openShell } from "./node-shell-session";
import { Router } from "./router"
import { ClusterManager } from "./cluster-manager"
import { ContextHandler } from "./context-handler";
import { apiKubePrefix, noClustersHost } from "../common/vars";
import { apiKubePrefix } from "../common/vars";
import logger from "./logger"
export class LensProxy {
protected origin: string
protected proxyServer: http.Server
protected router: Router
protected closed = false
@ -21,12 +22,13 @@ export class LensProxy {
}
private constructor(protected port: number, protected clusterManager: ClusterManager) {
this.origin = `http://localhost:${port}`
this.router = new Router();
}
listen(port = this.port): this {
this.proxyServer = this.buildCustomProxy().listen(port);
logger.info(`LensProxy server has started http://localhost:${port}`);
logger.info(`LensProxy server has started at ${this.origin}`);
return this;
}
@ -117,26 +119,17 @@ export class LensProxy {
}
protected async handleRequest(proxy: httpProxy, req: http.IncomingMessage, res: http.ServerResponse) {
if (req.headers.host.split(":")[0] === noClustersHost) {
this.router.handleStaticFile(req.url, res);
return;
}
const cluster = this.clusterManager.getClusterForRequest(req)
if (!cluster) {
const reqId = this.getRequestId(req);
logger.error("Got request to unknown cluster", { reqId })
res.statusCode = 503
res.end()
return
}
const contextHandler = cluster.contextHandler
await contextHandler.ensureServer();
const proxyTarget = await this.getProxyTarget(req, contextHandler)
if (proxyTarget) {
proxy.web(req, res, proxyTarget)
} else {
this.router.route(cluster, req, res);
if (cluster) {
await cluster.contextHandler.ensureServer();
const proxyTarget = await this.getProxyTarget(req, cluster.contextHandler)
if (proxyTarget) {
// allow to fetch apis in "clusterId.localhost:port" from "localhost:port"
res.setHeader("Access-Control-Allow-Origin", this.origin);
return proxy.web(req, res, proxyTarget);
}
}
this.router.route(cluster, req, res);
}
protected async handleWsUpgrade(req: http.IncomingMessage, socket: net.Socket, head: Buffer) {

View File

@ -1,9 +1,7 @@
import type { WindowManager } from "./window-manager";
import { app, BrowserWindow, dialog, Menu, MenuItem, MenuItemConstructorOptions, shell, webContents } from "electron"
import { autorun } from "mobx";
import { broadcastIpc } from "../common/ipc";
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";
@ -11,45 +9,61 @@ 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
});
}
function buildMenu(windowManager: WindowManager) {
const hasClusters = clusterStore.hasClusters();
const activeClusterId = clusterStore.activeClusterId;
function navigate(url: string) {
const clusterView = windowManager.getClusterView(activeClusterId);
broadcastIpc({
channel: "menu:navigate",
webContentId: clusterView ? clusterView.id : undefined /*no-clusters*/,
args: [url],
});
}
function macOnly(menuItems: MenuItemConstructorOptions[]): MenuItemConstructorOptions[] {
if (!isMac) return [];
export function buildMenu(windowManager: WindowManager) {
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() {
navigate(addClusterURL())
label: "About Lens",
click(menuItem: MenuItem, browserWindow: BrowserWindow) {
showAbout(browserWindow)
}
},
...(hasClusters ? [{
label: 'Cluster Settings',
click() {
navigate(clusterSettingsURL())
}
}] : []),
{ type: 'separator' },
{
label: 'Preferences',
@ -57,19 +71,57 @@ function buildMenu(windowManager: WindowManager) {
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: [
@ -84,7 +136,7 @@ function buildMenu(windowManager: WindowManager) {
{ role: 'selectAll' },
]
};
mt.push(editMenu)
const viewMenu: MenuItemConstructorOptions = {
label: 'View',
submenu: [
@ -92,21 +144,21 @@ 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' },
@ -118,16 +170,11 @@ function buildMenu(windowManager: WindowManager) {
{ role: 'togglefullscreen' }
]
};
mt.push(viewMenu)
const helpMenu: MenuItemConstructorOptions = {
role: 'help',
submenu: [
{
label: "What's new?",
click() {
navigate(whatsNewURL())
},
},
{
label: "License",
click: async () => {
@ -147,27 +194,23 @@ 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);
}
}

View File

@ -1,7 +1,5 @@
import url from "url"
import { LensApiRequest } from "../router"
import { LensApi } from "../lens-api"
import requestPromise from "request-promise-native"
import { PrometheusClusterQuery, PrometheusIngressQuery, PrometheusNodeQuery, PrometheusPodQuery, PrometheusProvider, PrometheusPvcQuery, PrometheusQueryOpts } from "../prometheus/provider-registry"
export type IMetricsQuery = string | string[] | {
@ -9,25 +7,19 @@ export type IMetricsQuery = string | string[] | {
}
class MetricsRoute extends LensApi {
public async routeMetrics(request: LensApiRequest) {
async routeMetrics(request: LensApiRequest) {
const { response, cluster, payload } = request
const { contextHandler, kubeProxyUrl } = cluster;
const headers: Record<string, string> = {
"Host": url.parse(cluster.webContentUrl).host,
"Content-type": "application/json",
}
const queryParams: IMetricsQuery = {}
request.query.forEach((value: string, key: string) => {
queryParams[key] = value
})
let metricsUrl: string
let prometheusPath: string
let prometheusProvider: PrometheusProvider
try {
const prometheusPath = await contextHandler.getPrometheusPath()
metricsUrl = `${kubeProxyUrl}/api/v1/namespaces/${prometheusPath}/proxy${cluster.getPrometheusApiPrefix()}/api/v1/query_range`
prometheusProvider = await contextHandler.getPrometheusProvider()
[prometheusPath, prometheusProvider] = await Promise.all([
cluster.contextHandler.getPrometheusPath(),
cluster.contextHandler.getPrometheusProvider()
])
} catch {
this.respondJson(response, {})
return
@ -35,18 +27,10 @@ class MetricsRoute extends LensApi {
// prometheus metrics loader
const attempts: { [query: string]: number } = {};
const maxAttempts = 5;
const loadMetrics = (orgQuery: string): Promise<any> => {
const query = orgQuery.trim()
const loadMetrics = (promQuery: string): Promise<any> => {
const query = promQuery.trim()
const attempt = attempts[query] = (attempts[query] || 0) + 1;
return requestPromise(metricsUrl, {
resolveWithFullResponse: false,
headers: headers,
json: true,
qs: {
query: query,
...queryParams
}
}).catch(async (error) => {
return cluster.getMetrics(prometheusPath, { query, ...queryParams }).catch(async error => {
if (attempt < maxAttempts && (error.statusCode && error.statusCode != 404)) {
await new Promise(resolve => setTimeout(resolve, attempt * 1000)); // add delay before repeating request
return loadMetrics(query);

View File

@ -1,65 +1,71 @@
import { reaction } from "mobx";
import { BrowserWindow, shell } from "electron"
import windowStateKeeper from "electron-window-state"
import type { ClusterId } from "../common/cluster-store";
import { clusterStore } from "../common/cluster-store";
import { noClustersHost } from "../common/vars";
import logger from "./logger";
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 {
protected activeView: BrowserWindow;
protected mainView: BrowserWindow;
protected splashWindow: BrowserWindow;
protected noClustersWindow: BrowserWindow;
protected views = new Map<ClusterId, BrowserWindow>();
protected disposers: CallableFunction[] = [];
protected windowState: windowStateKeeper.State;
constructor(protected proxyPort: number, showSplash = true) {
@observable activeClusterId: ClusterId;
constructor(protected proxyPort: number) {
// Manage main window size and position with state persistence
this.windowState = windowStateKeeper({
defaultHeight: 900,
defaultWidth: 1440,
});
// Show while app not ready
if (showSplash) {
this.showSplash();
}
const { width, height, x, y } = this.windowState;
this.mainView = new BrowserWindow({
x, y, width, height,
show: false,
minWidth: 900,
minHeight: 760,
titleBarStyle: "hidden",
backgroundColor: "#1e2124",
webPreferences: {
nodeIntegration: true,
nodeIntegrationInSubFrames: true,
enableRemoteModule: true,
},
});
this.windowState.manage(this.mainView);
// Manage reactive state
this.disposers.push(
// auto-show/hide "no-clusters" window when necessary
reaction(() => clusterStore.hasClusters(), hasClusters => {
this.handleNoClustersView({ activate: !hasClusters });
}, {
fireImmediately: true
}),
// open external links in default browser (target=_blank, window.open)
this.mainView.webContents.on("new-window", (event, url) => {
event.preventDefault();
shell.openExternal(url);
});
// auto-show active cluster window
reaction(() => clusterStore.activeClusterId, this.activateView, {
fireImmediately: true,
}),
// track visible cluster from ui
ipcMain.on("cluster-view:change", (event, clusterId: ClusterId) => {
this.activeClusterId = clusterId;
});
// auto-destroy views for removed clusters
reaction(() => clusterStore.removedClusters.toJS(), removedClusters => {
removedClusters.forEach(cluster => {
this.destroyClusterView(cluster.id);
});
}, {
delay: 25, // fix: destroy later and allow to use view's state in next activateView()
}),
);
// load & show app
this.showMain();
initMenu(this);
}
protected handleNoClustersView = async ({ activate = false } = {}) => {
if (!this.noClustersWindow) {
this.noClustersWindow = this.initClusterView(null);
await this.noClustersWindow.loadURL(`http://${noClustersHost}:${this.proxyPort}`);
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);
}
if (activate) {
this.activeView = this.noClustersWindow;
this.noClustersWindow.show();
this.hideSplash();
}
async showMain() {
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())
}
}
@ -73,101 +79,18 @@ export class WindowManager {
frame: false,
resizable: false,
show: false,
webPreferences: {
nodeIntegration: true
}
});
await this.splashWindow.loadURL("static://splash.html");
}
this.splashWindow.show();
}
hideSplash() {
this.splashWindow.hide();
}
getClusterView(clusterId: ClusterId): BrowserWindow {
return this.views.get(clusterId);
}
activateView = async (clusterId: ClusterId): Promise<number> => {
const cluster = clusterStore.getById(clusterId);
if (!cluster) return;
try {
const prevActiveView = this.activeView;
const isLoadedBefore = !!this.getClusterView(clusterId);
const view = this.initClusterView(clusterId);
logger.info(`[WINDOW-MANAGER]: activating cluster view`, {
id: view.id,
clusterId: cluster.id,
contextName: cluster.contextName,
isLoadedBefore: isLoadedBefore,
});
if (prevActiveView !== view) {
this.activeView = view;
if (!isLoadedBefore) {
await cluster.whenInitialized; // wait for url
await view.loadURL(cluster.webContentUrl);
this.hideSplash();
}
// refresh position and hide previous active window
if (prevActiveView) {
view.setBounds(prevActiveView.getBounds());
prevActiveView.hide();
}
view.show();
return view.id;
}
} catch (err) {
logger.error(`[WINDOW-MANAGER]: can't activate cluster view`, {
clusterId: cluster.id,
err: String(err),
});
}
}
protected initClusterView(clusterId: ClusterId): BrowserWindow {
let view = this.getClusterView(clusterId);
if (!view) {
const { width, height, x, y } = this.windowState;
view = new BrowserWindow({
show: false,
x: x, y: y,
width: width,
height: height,
minWidth: 900,
minHeight: 760,
titleBarStyle: "hidden",
backgroundColor: "#1e2124",
webPreferences: {
nodeIntegration: true,
enableRemoteModule: true,
},
});
// open external links in default browser (target=_blank, window.open)
view.webContents.on("new-window", (event, url) => {
event.preventDefault();
shell.openExternal(url);
});
this.views.set(clusterId, view);
this.windowState.manage(view);
}
return view;
}
protected destroyClusterView(clusterId: ClusterId) {
const view = this.views.get(clusterId);
if (view) {
view.destroy();
this.views.delete(clusterId);
}
}
destroy() {
this.windowState.unmanage();
this.disposers.forEach(dispose => dispose());
this.disposers.length = 0;
this.views.forEach(view => view.destroy());
this.views.clear();
this.splashWindow.destroy();
this.splashWindow = null;
this.activeView = null;
this.mainView.destroy();
}
}

View File

@ -4,16 +4,16 @@ import { Notifications } from "../components/notifications";
import { apiKubePrefix, apiPrefix, isDevelopment } from "../../common/vars";
export const apiBase = new JsonApi({
apiBase: apiPrefix,
debug: isDevelopment,
apiPrefix: apiPrefix,
});
export const apiKube = new KubeJsonApi({
apiBase: apiKubePrefix,
debug: isDevelopment,
apiPrefix: apiKubePrefix,
});
// Common handler for HTTP api errors
function onApiError(error: JsonApiErrorParsed, res: Response) {
export function onApiError(error: JsonApiErrorParsed, res: Response) {
switch (res.status) {
case 403:
error.isUsedForNotification = true;

View File

@ -27,7 +27,7 @@ export interface JsonApiLog {
}
export interface JsonApiConfig {
apiPrefix: string;
apiBase: string;
debug?: boolean;
}
@ -72,7 +72,7 @@ export class JsonApi<D = JsonApiData, P extends JsonApiParams = JsonApiParams> {
}
protected request<D>(path: string, params?: P, init: RequestInit = {}) {
let reqUrl = this.config.apiPrefix + path;
let reqUrl = this.config.apiBase + path;
const reqInit: RequestInit = { ...this.reqInit, ...init };
const { data, query } = params || {} as P;
if (data && !reqInit.body) {

View File

@ -61,7 +61,7 @@ export class KubeWatchApi {
}
protected getQuery(): Partial<IKubeWatchRouteQuery> {
const { isAdmin, allowedNamespaces } = getHostedCluster();
const { isAdmin, allowedNamespaces } = getHostedCluster()
return {
api: this.activeApis.map(api => {
if (isAdmin) return api.getWatchUrl();

View File

@ -0,0 +1,38 @@
import "./components/app.scss"
import React from "react";
import { render } from "react-dom";
import { isMac } from "../common/vars";
import { userStore } from "../common/user-store";
import { workspaceStore } from "../common/workspace-store";
import { clusterStore, getHostedClusterId } from "../common/cluster-store";
import { i18nStore } from "./i18n";
import { themeStore } from "./theme.store";
import { App } from "./components/app";
import { LensApp } from "./lens-app";
type AppComponent = React.ComponentType & {
init?(): void;
}
export async function bootstrap(App: AppComponent) {
const rootElem = document.getElementById("app")
rootElem.classList.toggle("is-mac", isMac);
// preload common stores
await Promise.all([
userStore.load(),
workspaceStore.load(),
clusterStore.load(),
i18nStore.init(),
themeStore.init(),
]);
// init app's dependencies if any
if (App.init) {
await App.init();
}
render(<App/>, rootElem);
}
// run
bootstrap(getHostedClusterId() ? App : LensApp);

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 {
@ -165,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}>
@ -179,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>
)}
@ -195,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}
@ -207,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

@ -58,7 +58,7 @@ export class ReleaseStore extends ItemStore<HelmRelease> {
this.isLoading = true;
let items;
try {
const { isAdmin, allowedNamespaces } = getHostedCluster();
const { isAdmin, allowedNamespaces } = getHostedCluster()
items = await this.loadItems(!isAdmin ? allowedNamespaces : null);
} finally {
if (items) {

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,38 +1,25 @@
.ClusterSettings {
grid-template-columns: unset;
padding: 0;
.WizardLayout {
grid-template-columns: unset;
grid-template-rows: 76px 1fr;
padding: 0;
.head-col {
justify-content: space-between;
.head-col {
justify-content: space-between;
:nth-child(2) {
flex: 1 0 0;
:nth-child(2) {
flex: 1 0 0;
}
}
a {
text-decoration: none;
color: $grey-600;
}
}
.content-col {
margin: 0;
padding-top: $padding * 3;
background-color: transparent;
.info-col {
display: none;
}
.content-col {
margin: 0;
padding-top: $padding * 3;
background-color: transparent;
.SubTitle {
text-transform: none;
}
.settings-wrapper {
margin: 0 auto;
width: 60%;
min-width: 570px;
max-width: 1000px;
.SubTitle {
text-transform: none;
}
> div {
margin-top: $margin * 5;
@ -47,49 +34,50 @@
.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;
}
}
.file-loader {
margin-top: $margin * 2;
}
.status-table {
margin: $margin * 3 0;
.hint {
font-size: smaller;
opacity: 0.8;
}
.Table {
border: 1px solid var(--drawerSubtitleBackground);
border-radius: $radius;
p + p, .hint + p {
padding-top: $padding;
}
}
.TableRow {
&:not(:last-of-type) {
border-bottom: 1px solid var(--drawerSubtitleBackground);
}
.status-table {
margin: $margin * 3 0;
.Table {
border: 1px solid var(--drawerSubtitleBackground);
border-radius: $radius;
.TableRow {
&:not(:last-of-type) {
border-bottom: 1px solid var(--drawerSubtitleBackground);
}
.value {
flex-grow: 2;
color: var(--textColorSecondary);
.value {
flex-grow: 2;
word-break: break-word;
color: var(--textColorSecondary);
}
}
}
}
}
.Input, .Select {
margin-top: 10px;
}
.Input, .Select {
margin-top: 10px;
}
.Select {
&__control {
box-shadow: 0 0 0 1px $borderFaintColor;
.Select {
&__control {
box-shadow: 0 0 0 1px $borderFaintColor;
}
}
}
}

View File

@ -7,15 +7,17 @@ import { Features } from "./features";
import { Removal } from "./removal";
import { Status } from "./status";
import { General } from "./general";
import { getHostedCluster } from "../../../common/cluster-store";
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
@ -24,20 +26,18 @@ export class ClusterSettings extends React.Component {
showTooltip={false}
/>
<h2>{cluster.preferences.clusterName}</h2>
<Link to="/">
<Icon material="close" big />
</Link>
<Icon material="close" onClick={() => navigate("/")} big/>
</>
);
return (
<WizardLayout header={header} className="ClusterSettings">
<div className="settings-wrapper">
<div className="ClusterSettings">
<WizardLayout header={header} centered>
<Status cluster={cluster}></Status>
<General cluster={cluster}></General>
<Features cluster={cluster}></Features>
<Removal cluster={cluster}></Removal>
</div>
</WizardLayout>
</WizardLayout>
</div>
);
}
}

View File

@ -11,7 +11,7 @@ export class Status extends React.Component<Props> {
renderStatusRows() {
const { cluster } = this.props;
const rows = [
["Online Status", cluster.online ? "online" : `offline (${cluster.failureReason || "unknown reason"}`],
["Online Status", cluster.online ? "online" : `offline (${cluster.failureReason || "unknown reason"})`],
["Distribution", cluster.distribution],
["Kerbel Version", cluster.version],
["API Address", cluster.apiUrl],

View File

@ -1,2 +1,2 @@
export * from "./cluster.routes"
export * from "./cluster.route"

View File

@ -1,16 +1,18 @@
import "./landing-page.scss"
import React from "react";
import { observer } from "mobx-react";
import { clusterStore } from "../../../common/cluster-store";
import { Trans } from "@lingui/macro";
import { clusterStore } from "../../../common/cluster-store";
import { workspaceStore } from "../../../common/workspace-store";
@observer
export class LandingPage extends React.Component {
render() {
const noClusters = !clusterStore.hasClusters();
const clusters = clusterStore.getByWorkspaceId(workspaceStore.currentWorkspaceId);
const noClustersInScope = !clusters.length;
return (
<div className="LandingPage flex">
{noClusters && (
{noClustersInScope && (
<div className="no-clusters flex column gaps box center">
<h1>
<Trans>Welcome!</Trans>

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

@ -5,7 +5,7 @@ import { observer } from "mobx-react";
import { Trans } from "@lingui/macro";
import { RouteComponentProps } from "react-router";
import { Icon } from "../icon";
import { IRoleBindingsRouteParams } from "../+user-management/user-management.routes";
import { IRoleBindingsRouteParams } from "../+user-management/user-management.route";
import { KubeObjectMenu, KubeObjectMenuProps } from "../kube-object/kube-object-menu";
import { clusterRoleBindingApi, RoleBinding, roleBindingApi } from "../../api/endpoints";
import { roleBindingsStore } from "./role-bindings.store";

View File

@ -4,7 +4,7 @@ import React from "react";
import { observer } from "mobx-react";
import { Trans } from "@lingui/macro";
import { RouteComponentProps } from "react-router";
import { IRolesRouteParams } from "../+user-management/user-management.routes";
import { IRolesRouteParams } from "../+user-management/user-management.route";
import { KubeObjectMenu, KubeObjectMenuProps } from "../kube-object/kube-object-menu";
import { rolesStore } from "./roles.store";
import { clusterRoleApi, Role, roleApi } from "../../api/endpoints";

View File

@ -1,2 +1,2 @@
export * from "./user-management"
export * from "./user-management.routes"
export * from "./user-management.route"

View File

@ -8,7 +8,7 @@ import { MainLayout, TabRoute } from "../layout/main-layout";
import { Roles } from "../+user-management-roles";
import { RoleBindings } from "../+user-management-roles-bindings";
import { ServiceAccounts } from "../+user-management-service-accounts";
import { roleBindingsRoute, roleBindingsURL, rolesRoute, rolesURL, serviceAccountsRoute, serviceAccountsURL, usersManagementURL } from "./user-management.routes";
import { roleBindingsRoute, roleBindingsURL, rolesRoute, rolesURL, serviceAccountsRoute, serviceAccountsURL, usersManagementURL } from "./user-management.route";
import { namespaceStore } from "../+namespaces/namespace.store";
import { PodSecurityPolicies, podSecurityPoliciesRoute, podSecurityPoliciesURL } from "../+pod-security-policies";
import { isAllowedResource } from "../../../common/rbac";

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

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

@ -1,13 +1,14 @@
import "./app.scss";
import React from "react";
import { disposeOnUnmount, observer } from "mobx-react";
import { observable, reaction } from "mobx";
import { Redirect, Route, Switch } from "react-router";
import { observer } from "mobx-react";
import { Redirect, Route, Router, Switch } from "react-router";
import { I18nProvider } from "@lingui/react";
import { _i18n } from "../i18n";
import { history } from "../navigation";
import { Notifications } from "./notifications";
import { NotFound } from "./+404";
import { UserManagement } from "./+user-management/user-management";
import { ConfirmDialog } from "./confirm-dialog";
import { usersManagementRoute } from "./+user-management/user-management.routes";
import { usersManagementRoute } from "./+user-management/user-management.route";
import { clusterRoute, clusterURL } from "./+cluster";
import { KubeConfigDialog } from "./kubeconfig-dialog/kubeconfig-dialog";
import { Nodes, nodesRoute } from "./+nodes";
@ -27,94 +28,60 @@ 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 { AddCluster, addClusterRoute } from "./+add-cluster";
import { LandingPage, landingRoute, landingURL } from "./+landing-page";
import { ClusterSettings, clusterSettingsRoute } from "./+cluster-settings";
import { Workspaces, workspacesRoute } from "./+workspaces";
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 { getHostedCluster } from "../../common/cluster-store";
import { clusterStatusRoute, clusterStatusURL } from "./cluster-manager/cluster-status.route";
import { Preferences, preferencesRoute } from "./+preferences";
import { ClusterStatus } from "./cluster-manager/cluster-status";
import { CubeSpinner } from "./spinner";
import { navigate, navigation } from "../navigation";
import { webFrame } from "electron";
@observer
export class App extends React.Component {
@observable isReady = false;
get cluster() {
return getHostedCluster()
}
async componentDidMount() {
if (this.cluster) {
await clusterIpc.activate.invokeFromRenderer(); // refresh state, reconnect, etc.
disposeOnUnmount(this, [
reaction(() => this.cluster.accessible, this.onClusterAccessChange, {
fireImmediately: true
})
])
}
this.isReady = true;
}
protected onClusterAccessChange = (accessible: boolean) => {
const path = navigation.getPath();
if (!accessible || path === "/") {
navigate(this.startURL);
}
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() {
if (this.cluster) {
if (!this.cluster.accessible) {
return clusterStatusURL();
}
if (isAllowedResource(["events", "nodes", "pods"])) {
return clusterURL();
}
return workloadsURL();
if (isAllowedResource(["events", "nodes", "pods"])) {
return clusterURL();
}
return landingURL();
return workloadsURL();
}
render() {
if (!this.isReady) {
return <CubeSpinner className="box center"/>
}
return (
<ErrorBoundary>
<Switch>
<Route component={LandingPage} {...landingRoute}/>
<Route component={Preferences} {...preferencesRoute}/>
<Route component={Workspaces} {...workspacesRoute}/>
<Route component={AddCluster} {...addClusterRoute}/>
<Route component={Cluster} {...clusterRoute}/>
<Route component={ClusterStatus} {...clusterStatusRoute}/>
<Route component={ClusterSettings} {...clusterSettingsRoute}/>
<Route component={Nodes} {...nodesRoute}/>
<Route component={Workloads} {...workloadsRoute}/>
<Route component={Config} {...configRoute}/>
<Route component={Network} {...networkRoute}/>
<Route component={Storage} {...storageRoute}/>
<Route component={Namespaces} {...namespacesRoute}/>
<Route component={Events} {...eventRoute}/>
<Route component={CustomResources} {...crdRoute}/>
<Route component={UserManagement} {...usersManagementRoute}/>
<Route component={Apps} {...appsRoute}/>
<Redirect exact from="/" to={this.startURL}/>
<Route component={NotFound}/>
</Switch>
<KubeObjectDetails/>
<Notifications/>
<ConfirmDialog/>
<KubeConfigDialog/>
<AddRoleBindingDialog/>
<PodLogsDialog/>
<DeploymentScaleDialog/>
</ErrorBoundary>
<I18nProvider i18n={_i18n}>
<Router history={history}>
<ErrorBoundary>
<Switch>
<Route component={Cluster} {...clusterRoute}/>
<Route component={Nodes} {...nodesRoute}/>
<Route component={Workloads} {...workloadsRoute}/>
<Route component={Config} {...configRoute}/>
<Route component={Network} {...networkRoute}/>
<Route component={Storage} {...storageRoute}/>
<Route component={Namespaces} {...namespacesRoute}/>
<Route component={Events} {...eventRoute}/>
<Route component={CustomResources} {...crdRoute}/>
<Route component={UserManagement} {...usersManagementRoute}/>
<Route component={Apps} {...appsRoute}/>
<Redirect exact from="/" to={this.startURL}/>
<Route component={NotFound}/>
</Switch>
<Notifications/>
<ConfirmDialog/>
<KubeObjectDetails/>
<KubeConfigDialog/>
<AddRoleBindingDialog/>
<PodLogsDialog/>
<DeploymentScaleDialog/>
</ErrorBoundary>
</Router>
</I18nProvider>
)
}
}

View File

@ -1,20 +1,27 @@
.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;
overflow: hidden;
display: flex;
&.inactive {
opacity: .85;
filter: grayscale(1);
user-select: none;
pointer-events: none;
> * {
position: absolute;
left: 0;
top: 0;
right: 0;
bottom: 0;
display: flex;
background-color: $mainBackground;
> * {
flex: 1;
}
}
}

View File

@ -1,52 +1,70 @@
import "./cluster-manager.scss"
import React from "react";
import { computed } from "mobx";
import { observer } from "mobx-react";
import { App } from "../app";
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 { Terminal } from "../dock/terminal";
import { i18nStore } from "../../i18n";
import { themeStore } from "../../theme.store";
import { clusterStore, getHostedClusterId, isNoClustersView } from "../../../common/cluster-store";
import { CubeSpinner } from "../spinner";
interface Props {
className?: IClassName;
contentClass?: IClassName;
}
import { LandingPage, landingRoute, landingURL } from "../+landing-page";
import { Preferences, preferencesRoute } from "../+preferences";
import { Workspaces, workspacesRoute } from "../+workspaces";
import { AddCluster, addClusterRoute } from "../+add-cluster";
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> {
static async init() {
await Promise.all([
i18nStore.init(),
themeStore.init(),
Terminal.preloadFonts(),
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
})
])
}
@computed get isInactive() {
const { activeCluster, activeClusterId, clusters } = clusterStore;
const isActivatedBefore = activeCluster?.initialized;
return clusters.size > 0 && !isActivatedBefore && activeClusterId !== getHostedClusterId();
componentWillUnmount() {
lensViews.clear();
}
get startUrl() {
const { activeClusterId } = clusterStore;
if (activeClusterId) {
return clusterViewURL({
params: {
clusterId: activeClusterId
}
})
}
return landingURL()
}
render() {
const { className, contentClass } = this.props;
const lensViewClass = cssNames("flex column", contentClass, {
inactive: this.isInactive,
});
return (
<div className={cssNames("ClusterManager", className)}>
<div className="ClusterManager">
<div id="draggable-top"/>
<div id="lens-view" className={lensViewClass}>
<App/>
</div>
<main>
<div id="lens-views"/>
<Switch>
<Route component={LandingPage} {...landingRoute}/>
<Route component={Preferences} {...preferencesRoute}/>
<Route component={Workspaces} {...workspacesRoute}/>
<Route component={AddCluster} {...addClusterRoute}/>
<Route component={ClusterView} {...clusterViewRoute}/>
<Route component={ClusterSettings} {...clusterSettingsRoute}/>
<Redirect exact to={this.startUrl}/>
</Switch>
</main>
<ClustersMenu/>
<BottomBar/>
{this.isInactive && <CubeSpinner center/>}
</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,93 +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 { getHostedCluster } from "../../../common/cluster-store";
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;
@computed get cluster() {
return getHostedCluster();
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() {
this.authOutput = [{ data: "Connecting..." }];
ipcRenderer.on(`kube-auth:${this.cluster.id}`, (evt, res: KubeAuthProxyLog) => {
this.authOutput.push({
data: res.data.trimRight(),
error: res.error,
});
})
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}`);
}
refreshCluster = async () => {
await clusterIpc.activate.invokeFromRenderer(this.props.clusterId);
}
reconnect = async () => {
this.authOutput = [{ data: "Reconnecting..." }];
this.isReconnecting = true;
await clusterIpc.activate.invokeFromRenderer();
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

@ -42,6 +42,7 @@
> .add-cluster {
position: relative;
margin-top: $padding;
min-width: 43px;
.Icon {
border-radius: $radius;

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
@ -34,7 +34,7 @@ export class ClustersMenu extends React.Component<Props> {
showCluster = (clusterId: ClusterId) => {
clusterStore.setActive(clusterId);
navigate("/"); // redirect to index
navigate(clusterViewURL({ params: { clusterId } }));
}
addCluster = () => {
@ -48,18 +48,21 @@ export class ClustersMenu extends React.Component<Props> {
menu.append(new MenuItem({
label: _i18n._(t`Settings`),
click: () => {
clusterStore.setActive(cluster.id);
navigate(clusterSettingsURL())
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>,
})
}
@ -85,11 +91,10 @@ export class ClustersMenu extends React.Component<Props> {
render() {
const { className } = this.props;
const { newContexts } = userStore;
const { currentWorkspaceId } = workspaceStore;
const clusters = clusterStore.getByWorkspaceId(currentWorkspaceId);
const noClusters = !clusterStore.clusters.size;
const clusters = clusterStore.getByWorkspaceId(workspaceStore.currentWorkspaceId);
const noClustersInScope = clusters.length === 0;
const isLanding = navigation.getPath() === landingURL();
const showStartupHint = this.showHint && isLanding && noClusters;
const showStartupHint = this.showHint && isLanding && noClustersInScope;
return (
<div
className={cssNames("ClustersMenu flex column gaps", className)}
@ -111,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

@ -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 = {

View File

@ -10,8 +10,8 @@ import { Sidebar } from "./sidebar";
import { ErrorBoundary } from "../error-boundary";
import { Dock } from "../dock";
import { navigate, navigation } from "../../navigation";
import { themeStore } from "../../theme.store";
import { getHostedCluster } from "../../../common/cluster-store";
import { themeStore } from "../../theme.store";
export interface TabRoute extends RouteProps {
title: React.ReactNode;
@ -47,12 +47,14 @@ export class MainLayout extends React.Component<Props> {
render() {
const { className, contentClass, headerClass, tabs, footer, footerClass, children } = this.props;
const { contextName: clusterName } = getHostedCluster();
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">{clusterName}</span>
<span className="cluster">
{cluster.preferences?.clusterName || cluster.contextName}
</span>
</header>
<aside className={cssNames("flex column", { pinned: this.isPinned, accessible: this.isAccessible })}>

View File

@ -11,7 +11,7 @@ import { Icon } from "../icon";
import { workloadsRoute, workloadsURL } from "../+workloads/workloads.route";
import { namespacesURL } from "../+namespaces/namespaces.route";
import { nodesURL } from "../+nodes/nodes.route";
import { usersManagementRoute, usersManagementURL } from "../+user-management/user-management.routes";
import { usersManagementRoute, usersManagementURL } from "../+user-management/user-management.route";
import { networkRoute, networkURL } from "../+network/network.route";
import { storageRoute, storageURL } from "../+storage/storage.route";
import { clusterURL } from "../+cluster";
@ -43,7 +43,9 @@ interface Props {
@observer
export class Sidebar extends React.Component<Props> {
async componentDidMount() {
if (!crdStore.isLoaded && isAllowedResource('customresourcedefinitions')) crdStore.loadAll()
if (!crdStore.isLoaded && isAllowedResource('customresourcedefinitions')) {
crdStore.loadAll()
}
}
renderCustomResources() {

View File

@ -16,6 +16,7 @@
> .head-col {
position: sticky;
border-bottom: 1px solid $grey-800;
justify-content: space-between;
}
> .content-col {
@ -23,6 +24,10 @@
background-color: var(--clusters-menu-bgc);
border-radius: $radius;
> div {
flex: 1;
}
> .error {
border-radius: $radius;
padding: $padding;
@ -41,4 +46,17 @@
a {
color: $colorInfo;
}
&.centered {
.content-col {
margin: 0;
> div {
margin: 0 auto;
width: 60%;
min-width: 570px;
max-width: 1000px;
}
}
}
}

View File

@ -10,25 +10,30 @@ interface Props {
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, header, headerClass, 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 {

View File

@ -1,33 +1,19 @@
import "../common/system-ca"
import React from "react";
import { render } from "react-dom";
import { Route, Router, Switch } from "react-router";
import { observer } from "mobx-react";
import { userStore } from "../common/user-store";
import { workspaceStore } from "../common/workspace-store";
import { clusterStore } from "../common/cluster-store";
import { I18nProvider } from "@lingui/react";
import { history } from "./navigation";
import { isMac } from "../common/vars";
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
class LensApp extends React.Component {
static async init() {
const rootElem = document.getElementById("app");
rootElem.classList.toggle("is-mac", isMac);
await Promise.all([
userStore.load(),
workspaceStore.load(),
clusterStore.load(),
]);
await ClusterManager.init();
render(<LensApp/>, rootElem);
}
export class LensApp extends React.Component {
render() {
return (
<I18nProvider i18n={_i18n}>
@ -39,11 +25,10 @@ class LensApp extends React.Component {
<Route component={ClusterManager}/>
</Switch>
</ErrorBoundary>
<Notifications/>
<ConfirmDialog/>
</Router>
</I18nProvider>
)
}
}
// run
LensApp.init();

View File

@ -2,21 +2,23 @@
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) {
// subscribe for navigation via menu.ts
ipcRenderer.on("menu:navigate", (event, path: string) => {
navigate(path);
});
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 = {}> {
@ -24,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,5 +1,5 @@
import { computed, observable, reaction } from "mobx";
import { autobind } from "./utils";
import { autobind } from "./utils/autobind";
import { userStore } from "../common/user-store";
import logger from "../main/logger";

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']

View File

@ -1,13 +1,13 @@
import { htmlTemplate, isDevelopment, isProduction, outDir, appName, rendererDir, sassCommonVars } from "./src/common/vars";
import { appName, htmlTemplate, isDevelopment, isProduction, buildDir, rendererDir, sassCommonVars, publicPath } from "./src/common/vars";
import path from "path";
import webpack from "webpack";
import HtmlWebpackPlugin from "html-webpack-plugin";
import MiniCssExtractPlugin from "mini-css-extract-plugin";
import TerserPlugin from "terser-webpack-plugin";
import ForkTsCheckerPlugin from "fork-ts-checker-webpack-plugin"
import CircularDependencyPlugin from "circular-dependency-plugin"
export default function (): webpack.Configuration {
console.info('WEBPACK:renderer', require("./src/common/vars"))
return {
context: __dirname,
target: "electron-renderer",
@ -15,11 +15,11 @@ export default function (): webpack.Configuration {
mode: isProduction ? "production" : "development",
cache: isDevelopment,
entry: {
[appName]: path.resolve(rendererDir, "index.tsx"),
[appName]: path.resolve(rendererDir, "bootstrap.tsx"),
},
output: {
publicPath: "/",
path: outDir,
publicPath: publicPath,
path: buildDir,
filename: '[name].js',
chunkFilename: 'chunks/[name].js',
},

184
yarn.lock
View File

@ -950,7 +950,7 @@
ajv "^6.12.0"
ajv-keywords "^3.4.1"
"@electron/get@^1.0.1":
"@electron/get@^1.0.1", "@electron/get@^1.12.2":
version "1.12.2"
resolved "https://registry.yarnpkg.com/@electron/get/-/get-1.12.2.tgz#6442066afb99be08cefb9a281e4b4692b33764f3"
integrity sha512-vAuHUbfvBQpYTJ5wB7uVIDq5c/Ry0fiTBMs7lnEYAo/qXXppIVcWdfBr57u6eRnKdVso7KSiH6p/LbQAG6Izrg==
@ -1978,7 +1978,7 @@
dependencies:
"@types/react" "*"
"@types/react-dom@*", "@types/react-dom@^16.9.8":
"@types/react-dom@*":
version "16.9.8"
resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-16.9.8.tgz#fe4c1e11dfc67155733dfa6aa65108b4971cb423"
integrity sha512-ykkPQ+5nFknnlU6lDd947WbQ6TE3NNzbQAkInC2EKY1qeYdTKp7onFusmYZb+ityzx2YviqT6BXSu+LyWWJwcA==
@ -2157,7 +2157,7 @@
resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-8.0.0.tgz#165aae4819ad2174a17476dbe66feebd549556c0"
integrity sha512-xSQfNcvOiE5f9dyd4Kzxbof1aTrLobL278pGLKOZI6esGfZ7ts9Ka16CzIN6Y8hFHE1C7jIBZokULhK1bOgjRw==
"@types/webdriverio@^4.13.0":
"@types/webdriverio@^4.13.0", "@types/webdriverio@^4.8.0":
version "4.13.3"
resolved "https://registry.yarnpkg.com/@types/webdriverio/-/webdriverio-4.13.3.tgz#c1571c4e62724135c0b11e7d7e36b07af5168856"
integrity sha512-AfSQM1xTO9Ax+u9uSQPDuw69DQ0qA2RMoKHn86jCgWNcwKVUjGMSP4sfSl3JOfcZN8X/gWvn7znVPp2/g9zcJA==
@ -2216,6 +2216,13 @@
dependencies:
"@types/yargs-parser" "*"
"@types/yauzl@^2.9.1":
version "2.9.1"
resolved "https://registry.yarnpkg.com/@types/yauzl/-/yauzl-2.9.1.tgz#d10f69f9f522eef3cf98e30afb684a1e1ec923af"
integrity sha512-A1b8SU4D10uoPjwb0lnHmmu8wZhR9d+9o2PKBQT2jU5YPTKsxac6M2qGAdY7VcL+dHHhARVUDmeg0rOrcd9EjA==
dependencies:
"@types/node" "*"
"@typescript-eslint/eslint-plugin@^3.4.0":
version "3.4.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-3.4.0.tgz#8378062e6be8a1d049259bdbcf27ce5dfbeee62b"
@ -4188,7 +4195,7 @@ debug@4.1.0:
dependencies:
ms "^2.1.1"
debug@^2.1.3, debug@^2.2.0, debug@^2.3.3, debug@^2.5.1, debug@^2.6.9:
debug@^2.2.0, debug@^2.3.3, debug@^2.5.1, debug@^2.6.9:
version "2.6.9"
resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f"
integrity sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==
@ -4552,28 +4559,13 @@ electron-builder@^22.7.0:
update-notifier "^4.1.0"
yargs "^15.3.1"
electron-chromedriver@^6.0.0:
version "6.0.0"
resolved "https://registry.yarnpkg.com/electron-chromedriver/-/electron-chromedriver-6.0.0.tgz#a91b940c83f1c42ced52c9ef0605d8721613a8a2"
integrity sha512-UIhRl0sN5flfUjqActXsFrZQU1NmBObvlxzPnyeud8vhR67TllXCoqfvhQJmIrJAJJK+5M1DFhJ5iTGT++dvkg==
electron-chromedriver@^9.0.0:
version "9.0.0"
resolved "https://registry.yarnpkg.com/electron-chromedriver/-/electron-chromedriver-9.0.0.tgz#c7629fe6b9721140f3a380144f99960c2bc3b5c1"
integrity sha512-+MuukzicyfduXO/4yQv9ygLKaScttJNbWtg77A9fs2YhbkISjObWaCF3eJNZL+edZXRfaF/6D4XuXvklQCmwQg==
dependencies:
electron-download "^4.1.1"
extract-zip "^1.6.7"
electron-download@^4.1.1:
version "4.1.1"
resolved "https://registry.yarnpkg.com/electron-download/-/electron-download-4.1.1.tgz#02e69556705cc456e520f9e035556ed5a015ebe8"
integrity sha512-FjEWG9Jb/ppK/2zToP+U5dds114fM1ZOJqMAR4aXXL5CvyPE9fiqBK/9YcwC9poIFQTEJk/EM/zyRwziziRZrg==
dependencies:
debug "^3.0.0"
env-paths "^1.0.0"
fs-extra "^4.0.1"
minimist "^1.2.0"
nugget "^2.0.1"
path-exists "^3.0.0"
rc "^1.2.1"
semver "^5.4.1"
sumchecker "^2.0.2"
"@electron/get" "^1.12.2"
extract-zip "^2.0.0"
electron-notarize@^0.3.0:
version "0.3.0"
@ -4647,10 +4639,10 @@ electron@*:
"@types/node" "^12.0.12"
extract-zip "^1.0.3"
electron@^9.1.0:
version "9.1.0"
resolved "https://registry.yarnpkg.com/electron/-/electron-9.1.0.tgz#ca77600c9e4cd591298c340e013384114d3d8d05"
integrity sha512-VRAF8KX1m0py9I9sf0kw1kWfeC87mlscfFcbcRdLBsNJ44/GrJhi3+E8rKbpHUeZNQxsPaVA5Zu5Lxb6dV/scQ==
electron@^9.1.2:
version "9.1.2"
resolved "https://registry.yarnpkg.com/electron/-/electron-9.1.2.tgz#bfa26d6b192ea13abb6f1461371fd731a8358988"
integrity sha512-xEYadr3XqIqJ4ktBPo0lhzPdovv4jLCpiUUGc2M1frUhFhwqXokwhPaTUcE+zfu5+uf/ONDnQApwjzznBsRrgQ==
dependencies:
"@electron/get" "^1.0.1"
"@types/node" "^12.0.12"
@ -4743,11 +4735,6 @@ entities@^2.0.0:
resolved "https://registry.yarnpkg.com/entities/-/entities-2.0.3.tgz#5c487e5742ab93c15abb5da22759b8590ec03b7f"
integrity sha512-MyoZ0jgnLvB2X3Lg5HqpFmn1kybDiIfEQmKzTb5apr51Rb+T3KdmMiqa70T+bhGnyv7bQ6WMj2QMHpGMmlrUYQ==
env-paths@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/env-paths/-/env-paths-1.0.0.tgz#4168133b42bb05c38a35b1ae4397c8298ab369e0"
integrity sha1-QWgTO0K7BcOKNbGuQ5fIKYqzaeA=
env-paths@^2.2.0:
version "2.2.0"
resolved "https://registry.yarnpkg.com/env-paths/-/env-paths-2.2.0.tgz#cdca557dc009152917d6166e2febe1f039685e43"
@ -5098,7 +5085,7 @@ extglob@^2.0.4:
snapdragon "^0.8.1"
to-regex "^3.0.1"
extract-zip@^1.0.3, extract-zip@^1.6.7:
extract-zip@^1.0.3:
version "1.7.0"
resolved "https://registry.yarnpkg.com/extract-zip/-/extract-zip-1.7.0.tgz#556cc3ae9df7f452c493a0cfb51cc30277940927"
integrity sha512-xoh5G1W/PB0/27lXgMQyIhP5DSY/LhoCsOyZgb+6iMmRtCwVBo55uKaMoEYrDCKQhWvqEip5ZPKAc6eFNyf/MA==
@ -5108,6 +5095,17 @@ extract-zip@^1.0.3, extract-zip@^1.6.7:
mkdirp "^0.5.4"
yauzl "^2.10.0"
extract-zip@^2.0.0:
version "2.0.1"
resolved "https://registry.yarnpkg.com/extract-zip/-/extract-zip-2.0.1.tgz#663dca56fe46df890d5f131ef4a06d22bb8ba13a"
integrity sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==
dependencies:
debug "^4.1.1"
get-stream "^5.1.0"
yauzl "^2.10.0"
optionalDependencies:
"@types/yauzl" "^2.9.1"
extsprintf@1.3.0:
version "1.3.0"
resolved "https://registry.yarnpkg.com/extsprintf/-/extsprintf-1.3.0.tgz#96918440e3041a7a414f8c52e3c574eb3c3e1e05"
@ -5403,7 +5401,7 @@ fs-constants@^1.0.0:
resolved "https://registry.yarnpkg.com/fs-constants/-/fs-constants-1.0.0.tgz#6be0de9be998ce16af8afc24497b9ee9b7ccd9ad"
integrity sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==
fs-extra@^4.0.1, fs-extra@^4.0.3:
fs-extra@^4.0.3:
version "4.0.3"
resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-4.0.3.tgz#0d852122e5bc5beb453fb028e9c0c9bf36340c94"
integrity sha512-q6rbdDd1o2mAnQreO7YADIxf/Whx4AHBiRf6d+/cVT8h44ss+lHgxf1FemcqDnQt9X3ct4McHr+JMGlYSsK7Cg==
@ -7726,7 +7724,7 @@ memory-fs@^0.5.0:
errno "^0.1.3"
readable-stream "^2.0.1"
meow@^3.1.0, meow@^3.7.0:
meow@^3.7.0:
version "3.7.0"
resolved "https://registry.yarnpkg.com/meow/-/meow-3.7.0.tgz#72cb668b425228290abbfa856892587308a801fb"
integrity sha1-cstmi0JSKCkKu/qFaJJYcwioAfs=
@ -7859,7 +7857,7 @@ minimatch@^3.0.4, minimatch@~3.0.2:
dependencies:
brace-expansion "^1.1.7"
minimist@^1.1.0, minimist@^1.1.1, minimist@^1.1.3, minimist@^1.2.0, minimist@^1.2.5:
minimist@^1.1.1, minimist@^1.1.3, minimist@^1.2.0, minimist@^1.2.5:
version "1.2.5"
resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.5.tgz#67d66014b66a6a8aaa0c083c5fd58df4e4e97602"
integrity sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==
@ -7982,6 +7980,11 @@ mobx@^5.15.4:
resolved "https://registry.yarnpkg.com/mobx/-/mobx-5.15.4.tgz#9da1a84e97ba624622f4e55a0bf3300fb931c2ab"
integrity sha512-xRFJxSU2Im3nrGCdjSuOTFmxVDGeqOHL+TyADCGbT0k4HHqGmx5u2yaHNryvoORpI4DfbzjJ5jPmuv+d7sioFw==
mobx@^5.15.5:
version "5.15.5"
resolved "https://registry.yarnpkg.com/mobx/-/mobx-5.15.5.tgz#69715dc8662f64d153309bfe95169b8df4b4be4b"
integrity sha512-hzk17T+/IIYLPWClRcfoA6Q5aZhFpDCr1oh8RZzu+esWP77IX/lS0V/Ee1Np+aOPKFfbSInF0reHH0L/aFfSrw==
mock-fs@^4.12.0:
version "4.12.0"
resolved "https://registry.yarnpkg.com/mock-fs/-/mock-fs-4.12.0.tgz#a5d50b12d2d75e5bec9dac3b67ffe3c41d31ade4"
@ -8361,19 +8364,6 @@ nth-check@~1.0.1:
dependencies:
boolbase "~1.0.0"
nugget@^2.0.1:
version "2.0.1"
resolved "https://registry.yarnpkg.com/nugget/-/nugget-2.0.1.tgz#201095a487e1ad36081b3432fa3cada4f8d071b0"
integrity sha1-IBCVpIfhrTYIGzQy+jytpPjQcbA=
dependencies:
debug "^2.1.3"
minimist "^1.1.0"
pretty-bytes "^1.0.2"
progress-stream "^1.1.0"
request "^2.45.0"
single-line-log "^1.1.2"
throttleit "0.0.2"
number-is-nan@^1.0.0:
version "1.0.1"
resolved "https://registry.yarnpkg.com/number-is-nan/-/number-is-nan-1.0.1.tgz#097b602b53422a522c1afb8790318336941a011d"
@ -8423,11 +8413,6 @@ object-keys@^1.0.11, object-keys@^1.0.12, object-keys@^1.1.1:
resolved "https://registry.yarnpkg.com/object-keys/-/object-keys-1.1.1.tgz#1c47f272df277f3b1daf061677d9c82e2322c60e"
integrity sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==
object-keys@~0.4.0:
version "0.4.0"
resolved "https://registry.yarnpkg.com/object-keys/-/object-keys-0.4.0.tgz#28a6aae7428dd2c3a92f3d95f21335dd204e0336"
integrity sha1-KKaq50KN0sOpLz2V8hM13SBOAzY=
object-visit@^1.0.0:
version "1.0.1"
resolved "https://registry.yarnpkg.com/object-visit/-/object-visit-1.0.1.tgz#f79c4493af0c5377b59fe39d395e41042dd045bb"
@ -9124,14 +9109,6 @@ prepend-http@^2.0.0:
resolved "https://registry.yarnpkg.com/prepend-http/-/prepend-http-2.0.0.tgz#e92434bfa5ea8c19f41cdfd401d741a3c819d897"
integrity sha1-6SQ0v6XqjBn0HN/UAddBo8gZ2Jc=
pretty-bytes@^1.0.2:
version "1.0.4"
resolved "https://registry.yarnpkg.com/pretty-bytes/-/pretty-bytes-1.0.4.tgz#0a22e8210609ad35542f8c8d5d2159aff0751c84"
integrity sha1-CiLoIQYJrTVUL4yNXSFZr/B1HIQ=
dependencies:
get-stdin "^4.0.1"
meow "^3.1.0"
pretty-error@^2.1.1:
version "2.1.1"
resolved "https://registry.yarnpkg.com/pretty-error/-/pretty-error-2.1.1.tgz#5f4f87c8f91e5ae3f3ba87ab4cf5e03b1a17f1a3"
@ -9175,14 +9152,6 @@ process@^0.11.10:
resolved "https://registry.yarnpkg.com/process/-/process-0.11.10.tgz#7332300e840161bda3e69a1d1d91a7d4bc16f182"
integrity sha1-czIwDoQBYb2j5podHZGn1LwW8YI=
progress-stream@^1.1.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/progress-stream/-/progress-stream-1.2.0.tgz#2cd3cfea33ba3a89c9c121ec3347abe9ab125f77"
integrity sha1-LNPP6jO6OonJwSHsM0er6asSX3c=
dependencies:
speedometer "~0.1.2"
through2 "~0.2.3"
progress@^2.0.0, progress@^2.0.3:
version "2.0.3"
resolved "https://registry.yarnpkg.com/progress/-/progress-2.0.3.tgz#7e8cf8d8f5b8f239c1bc68beb4eb78567d572ef8"
@ -9375,7 +9344,7 @@ raw-loader@^4.0.1:
loader-utils "^2.0.0"
schema-utils "^2.6.5"
rc@^1.2.1, rc@^1.2.8:
rc@^1.2.8:
version "1.2.8"
resolved "https://registry.yarnpkg.com/rc/-/rc-1.2.8.tgz#cd924bf5200a075b83c188cd6b9e211b7fc0d3ed"
integrity sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==
@ -9420,7 +9389,7 @@ react-router-dom@^5.2.0:
tiny-invariant "^1.0.2"
tiny-warning "^1.0.0"
react-router@5.2.0:
react-router@5.2.0, react-router@^5.2.0:
version "5.2.0"
resolved "https://registry.yarnpkg.com/react-router/-/react-router-5.2.0.tgz#424e75641ca8747fbf76e5ecca69781aa37ea293"
integrity sha512-smz1DUuFHRKdcJC0jobGo8cVbhO3x50tCL4icacOlcwDOEQPq4TMqwx3sY1TP+DvtTgz4nm3thuo7A+BK2U0Dw==
@ -9560,16 +9529,6 @@ readable-stream@^3.1.1, readable-stream@^3.6.0:
string_decoder "^1.1.1"
util-deprecate "^1.0.1"
readable-stream@~1.1.9:
version "1.1.14"
resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-1.1.14.tgz#7cf4c54ef648e3813084c636dd2079e166c081d9"
integrity sha1-fPTFTvZI44EwhMY23SB54WbAgdk=
dependencies:
core-util-is "~1.0.0"
inherits "~2.0.1"
isarray "0.0.1"
string_decoder "~0.10.x"
readdirp@^2.2.1:
version "2.2.1"
resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-2.2.1.tgz#0e87622a3325aa33e892285caf8b4e846529a525"
@ -9736,7 +9695,7 @@ request-promise-native@^1.0.8:
stealthy-require "^1.1.1"
tough-cookie "^2.3.3"
request@^2.45.0, request@^2.83.0, request@^2.87.0, request@^2.88.0, request@^2.88.2:
request@^2.83.0, request@^2.87.0, request@^2.88.0, request@^2.88.2:
version "2.88.2"
resolved "https://registry.yarnpkg.com/request/-/request-2.88.2.tgz#d73c918731cb5a87da047e207234146f664d12b3"
integrity sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw==
@ -10219,13 +10178,6 @@ simple-swizzle@^0.2.2:
dependencies:
is-arrayish "^0.3.1"
single-line-log@^1.1.2:
version "1.1.2"
resolved "https://registry.yarnpkg.com/single-line-log/-/single-line-log-1.1.2.tgz#c2f83f273a3e1a16edb0995661da0ed5ef033364"
integrity sha1-wvg/Jzo+GhbtsJlWYdoO1e8DM2Q=
dependencies:
string-width "^1.0.1"
sisteransi@^1.0.4:
version "1.0.5"
resolved "https://registry.yarnpkg.com/sisteransi/-/sisteransi-1.0.5.tgz#134d681297756437cc05ca01370d3a7a571075ed"
@ -10385,22 +10337,18 @@ spdx-license-ids@^3.0.0:
resolved "https://registry.yarnpkg.com/spdx-license-ids/-/spdx-license-ids-3.0.5.tgz#3694b5804567a458d3c8045842a6358632f62654"
integrity sha512-J+FWzZoynJEXGphVIS+XEh3kFSjZX/1i9gFBaWQcB+/tmpe2qUsSBABpcxqxnAxFdiUFEgAX1bjYGQvIZmoz9Q==
spectron@^8.0.0:
version "8.0.0"
resolved "https://registry.yarnpkg.com/spectron/-/spectron-8.0.0.tgz#86e83c5dccb174850c052e2e718d5b1158764a52"
integrity sha512-MI9+lAamDnw7S0vKaxXjU3g5qaW5KANaFLc+Hgq+QmMCkQbZLt6ukFFGfalmwIuYrmq+yWQPCD4CXgt3VSHrLA==
spectron@11.0.0:
version "11.0.0"
resolved "https://registry.yarnpkg.com/spectron/-/spectron-11.0.0.tgz#79d785e6b8898638e77b5186711e3910ed4ca09b"
integrity sha512-YRiB0TTpJa8ofNML/k1fJShe+m7U/E2HnFZsdZK57ekWIzlTHF+Lq7ZvuKGxMbpooU/OZkLObZfitemxhBVH4w==
dependencies:
"@types/webdriverio" "^4.8.0"
dev-null "^0.1.1"
electron-chromedriver "^6.0.0"
electron-chromedriver "^9.0.0"
request "^2.87.0"
split "^1.0.0"
webdriverio "^4.13.0"
speedometer@~0.1.2:
version "0.1.4"
resolved "https://registry.yarnpkg.com/speedometer/-/speedometer-0.1.4.tgz#9876dbd2a169d3115402d48e6ea6329c8816a50d"
integrity sha1-mHbb0qFp0xFUAtSObqYynIgWpQ0=
split-string@^3.0.1, split-string@^3.0.2:
version "3.1.0"
resolved "https://registry.yarnpkg.com/split-string/-/split-string-3.1.0.tgz#7cb09dda3a86585705c64b39a6466038682e8fe2"
@ -10619,11 +10567,6 @@ string_decoder@^1.0.0, string_decoder@^1.1.1:
dependencies:
safe-buffer "~5.2.0"
string_decoder@~0.10.x:
version "0.10.31"
resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-0.10.31.tgz#62e203bc41766c6c28c9fc84301dab1c5310fa94"
integrity sha1-YuIDvEF2bGwoyfyEMB2rHFMQ+pQ=
string_decoder@~1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.1.1.tgz#9cf1611ba62685d7030ae9e4ba34149c3af03fc8"
@ -10713,13 +10656,6 @@ style-loader@^1.2.1:
loader-utils "^2.0.0"
schema-utils "^2.6.6"
sumchecker@^2.0.2:
version "2.0.2"
resolved "https://registry.yarnpkg.com/sumchecker/-/sumchecker-2.0.2.tgz#0f42c10e5d05da5d42eea3e56c3399a37d6c5b3e"
integrity sha1-D0LBDl0F2l1C7qPlbDOZo31sWz4=
dependencies:
debug "^2.2.0"
sumchecker@^3.0.1:
version "3.0.1"
resolved "https://registry.yarnpkg.com/sumchecker/-/sumchecker-3.0.1.tgz#6377e996795abb0b6d348e9b3e1dfb24345a8e42"
@ -10942,11 +10878,6 @@ throat@^5.0.0:
resolved "https://registry.yarnpkg.com/throat/-/throat-5.0.0.tgz#c5199235803aad18754a667d659b5e72ce16764b"
integrity sha512-fcwX4mndzpLQKBS1DVYhGAcYaYt7vsHNIvQV+WXMvnow5cgjPphq5CaayLaGsjRdSCKZFNGt7/GYAuXaNOiYCA==
throttleit@0.0.2:
version "0.0.2"
resolved "https://registry.yarnpkg.com/throttleit/-/throttleit-0.0.2.tgz#cfedf88e60c00dd9697b61fdd2a8343a9b680eaf"
integrity sha1-z+34jmDADdlpe2H90qg0OptoDq8=
through2@^2.0.0:
version "2.0.5"
resolved "https://registry.yarnpkg.com/through2/-/through2-2.0.5.tgz#01c1e39eb31d07cb7d03a96a70823260b23132cd"
@ -10955,14 +10886,6 @@ through2@^2.0.0:
readable-stream "~2.3.6"
xtend "~4.0.1"
through2@~0.2.3:
version "0.2.3"
resolved "https://registry.yarnpkg.com/through2/-/through2-0.2.3.tgz#eb3284da4ea311b6cc8ace3653748a52abf25a3f"
integrity sha1-6zKE2k6jEbbMis42U3SKUqvyWj8=
dependencies:
readable-stream "~1.1.9"
xtend "~2.1.1"
through@2, through@^2.3.6:
version "2.3.8"
resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5"
@ -11879,13 +11802,6 @@ xtend@^4.0.0, xtend@~4.0.1:
resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54"
integrity sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==
xtend@~2.1.1:
version "2.1.2"
resolved "https://registry.yarnpkg.com/xtend/-/xtend-2.1.2.tgz#6efecc2a4dad8e6962c4901b337ce7ba87b5d28b"
integrity sha1-bv7MKk2tjmlixJAbM3znuoe10os=
dependencies:
object-keys "~0.4.0"
xterm-addon-fit@^0.4.0:
version "0.4.0"
resolved "https://registry.yarnpkg.com/xterm-addon-fit/-/xterm-addon-fit-0.4.0.tgz#06e0c5d0a6aaacfb009ef565efa1c81e93d90193"