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 authored and GitHub committed 2020-08-19 10:49:39 +03:00
1 parent 592c8920b2
commit 47bcb63c95
72 files changed
+1958 -1505

No files matched your search

+1 -1
View File
@@ -5,7 +5,7 @@ node_modules/
yarn-error.log yarn-error.log
coverage/ coverage/
tmp/ tmp/
static/build/client/ static/build/**
binaries/client/ binaries/client/
binaries/server/ binaries/server/
locales/**/**.js locales/**/**.js
+6 -2
View File
@@ -3,13 +3,13 @@ import { Application } from "spectron";
let appPath = "" let appPath = ""
switch(process.platform) { switch(process.platform) {
case "win32": case "win32":
appPath = "./dist/win-unpacked/LensDev.exe" appPath = "./dist/win-unpacked/Lens.exe"
break break
case "linux": case "linux":
appPath = "./dist/linux-unpacked/kontena-lens" appPath = "./dist/linux-unpacked/kontena-lens"
break break
case "darwin": case "darwin":
appPath = "./dist/mac/LensDev.app/Contents/MacOS/LensDev" appPath = "./dist/mac/Lens.app/Contents/MacOS/Lens"
break break
} }
@@ -20,6 +20,10 @@ export function setup(): Application {
path: appPath, path: appPath,
startTimeout: 30000, startTimeout: 30000,
waitTimeout: 30000, waitTimeout: 30000,
chromeDriverArgs: ['remote-debugging-port=9222'],
env: {
CICD: "true"
}
}) })
} }
+8 -7
View File
@@ -1,7 +1,6 @@
import { Application } from "spectron" import { Application } from "spectron"
import * as util from "../helpers/utils" import * as util from "../helpers/utils"
import { spawnSync } from "child_process" import { spawnSync } from "child_process"
import { stat } from "fs"
jest.setTimeout(20000) jest.setTimeout(20000)
@@ -11,19 +10,21 @@ describe("app start", () => {
let app: Application let app: Application
const clickWhatsNew = async (app: Application) => { const clickWhatsNew = async (app: Application) => {
await app.client.waitUntilTextExists("h1", "What's new") await app.client.waitUntilTextExists("h1", "What's new")
await app.client.click("button.btn-primary") await app.client.click("button.primary")
await app.client.waitUntilTextExists("h1", "Welcome") await app.client.waitUntilTextExists("h1", "Welcome")
} }
const addMinikubeCluster = async (app: Application) => { const addMinikubeCluster = async (app: Application) => {
await app.client.click("a#add-cluster") await app.client.click("div.add-cluster")
await app.client.waitUntilTextExists("legend", "Choose config:") await app.client.waitUntilTextExists("p", "Choose config")
await app.client.selectByVisibleText("select#kubecontext-select", "minikube (new)") await app.client.click("div#kubecontext-select")
await app.client.click("button.btn-primary") await app.client.waitUntilTextExists("div", "minikube")
await app.client.click("div.minikube")
await app.client.click("button.primary")
} }
const waitForMinikubeDashboard = async (app: Application) => { const waitForMinikubeDashboard = async (app: Application) => {
await app.client.waitUntilTextExists("pre.auth-output", "Authentication proxy started") await app.client.waitUntilTextExists("pre.kube-auth-out", "Authentication proxy started")
let windowCount = await app.client.getWindowCount() let windowCount = await app.client.getWindowCount()
// wait for webview to appear on window count // wait for webview to appear on window count
while (windowCount == 1) { while (windowCount == 1) {
+191 -175
View File
File diff suppressed because it is too large. Load diff
+189 -173
View File
File diff suppressed because it is too large. Load diff
+190 -174
View File
File diff suppressed because it is too large. Load diff
+19 -20
View File
@@ -3,7 +3,7 @@
"productName": "Lens", "productName": "Lens",
"description": "Lens - The Kubernetes IDE", "description": "Lens - The Kubernetes IDE",
"version": "3.6.0-dev", "version": "3.6.0-dev",
"main": "out/main.js", "main": "static/build/main.js",
"copyright": "© 2020, Lakend Labs, Inc.", "copyright": "© 2020, Lakend Labs, Inc.",
"license": "MIT", "license": "MIT",
"author": { "author": {
@@ -12,29 +12,28 @@
}, },
"scripts": { "scripts": {
"dev": "concurrently -k \"yarn dev-run -C\" \"yarn dev:main\" \"yarn dev:renderer\"", "dev": "concurrently -k \"yarn dev-run -C\" \"yarn dev:main\" \"yarn dev:renderer\"",
"dev-run": "nodemon --watch out/main.* --exec \"electron --inspect .\" $@", "dev-run": "nodemon --watch static/build/main.js --exec \"electron --inspect .\" $@",
"dev:main": "env DEBUG=true yarn compile:main --watch $@", "dev:main": "env DEBUG=true yarn compile:main --watch $@",
"dev:renderer": "env DEBUG=true yarn compile:renderer --watch $@", "dev:renderer": "env DEBUG=true yarn compile:renderer --watch $@",
"compile": "concurrently \"yarn i18n:compile\" \"yarn compile:main -p\" \"yarn compile:renderer -p\"", "compile": "env NODE_ENV=production concurrently yarn:compile:*",
"compile:main": "webpack --progress --config webpack.main.ts", "compile:main": "webpack --config webpack.main.ts",
"compile:renderer": "webpack --progress --config webpack.renderer.ts", "compile:renderer": "webpack --config webpack.renderer.ts",
"compile:dll": "webpack --config webpack.dll.ts", "compile:i18n": "lingui compile",
"build:linux": "yarn compile && electron-builder --linux --dir -c.productName=LensDev", "build:linux": "yarn compile && electron-builder --linux --dir -c.productName=Lens",
"build:mac": "yarn compile && electron-builder --mac --dir -c.productName=LensDev", "build:mac": "yarn compile && electron-builder --mac --dir -c.productName=Lens",
"build:win": "yarn compile && electron-builder --win --dir -c.productName=LensDev", "build:win": "yarn compile && electron-builder --win --dir -c.productName=Lens",
"test": "jest --env=jsdom src $@", "test": "jest --env=jsdom src $@",
"integration": "jest --coverage integration $@", "integration": "jest --coverage integration $@",
"dist": "yarn compile && electron-builder -p onTag", "dist": "yarn compile && electron-builder --publish onTag",
"dist:win": "yarn compile && electron-builder -p onTag --x64 --ia32", "dist:win": "yarn compile && electron-builder --publish onTag --x64 --ia32",
"dist:dir": "yarn dist --dir -c.compression=store -c.mac.identity=null", "dist:dir": "yarn dist --dir -c.compression=store -c.mac.identity=null",
"postinstall": "patch-package", "postinstall": "patch-package",
"i18n:extract": "lingui extract", "i18n:extract": "lingui extract",
"i18n:compile": "lingui compile",
"download-bins": "concurrently yarn:download:*", "download-bins": "concurrently yarn:download:*",
"download:kubectl": "yarn run ts-node build/download_kubectl.ts", "download:kubectl": "yarn run ts-node build/download_kubectl.ts",
"download:helm": "yarn run ts-node build/download_helm.ts", "download:helm": "yarn run ts-node build/download_helm.ts",
"lint": "eslint $@ --ext js,ts,tsx --max-warnings=0 src/", "lint": "eslint $@ --ext js,ts,tsx --max-warnings=0 src/",
"rebuild-pty": "electron-rebuild -f -w node-pty" "rebuild-pty": "yarn run electron-rebuild -f -w node-pty"
}, },
"config": { "config": {
"bundledKubectlVersion": "1.17.4", "bundledKubectlVersion": "1.17.4",
@@ -88,7 +87,7 @@
{ {
"from": "static/", "from": "static/",
"to": "static/", "to": "static/",
"filter": "**/*" "filter": "!**/main.js"
}, },
"LICENSE" "LICENSE"
], ],
@@ -187,11 +186,15 @@
"mac-ca": "^1.0.4", "mac-ca": "^1.0.4",
"marked": "^1.1.0", "marked": "^1.1.0",
"md5-file": "^5.0.0", "md5-file": "^5.0.0",
"mobx": "^5.15.5",
"mobx-observable-history": "^1.0.3",
"mock-fs": "^4.12.0", "mock-fs": "^4.12.0",
"node-machine-id": "^1.1.12", "node-machine-id": "^1.1.12",
"node-pty": "^0.9.0", "node-pty": "^0.9.0",
"openid-client": "^3.15.2", "openid-client": "^3.15.2",
"path-to-regexp": "^6.1.0",
"proper-lockfile": "^4.1.1", "proper-lockfile": "^4.1.1",
"react-router": "^5.2.0",
"request": "^2.88.2", "request": "^2.88.2",
"request-promise-native": "^1.0.8", "request-promise-native": "^1.0.8",
"semver": "^7.3.2", "semver": "^7.3.2",
@@ -233,7 +236,6 @@
"@types/md5-file": "^4.0.2", "@types/md5-file": "^4.0.2",
"@types/mini-css-extract-plugin": "^0.9.1", "@types/mini-css-extract-plugin": "^0.9.1",
"@types/react": "^16.9.35", "@types/react": "^16.9.35",
"@types/react-dom": "^16.9.8",
"@types/react-router-dom": "^5.1.5", "@types/react-router-dom": "^5.1.5",
"@types/react-select": "^3.0.13", "@types/react-select": "^3.0.13",
"@types/react-window": "^1.8.2", "@types/react-window": "^1.8.2",
@@ -265,7 +267,7 @@
"css-element-queries": "^1.2.3", "css-element-queries": "^1.2.3",
"css-loader": "^3.5.3", "css-loader": "^3.5.3",
"dompurify": "^2.0.11", "dompurify": "^2.0.11",
"electron": "^9.1.0", "electron": "^9.1.2",
"electron-builder": "^22.7.0", "electron-builder": "^22.7.0",
"electron-notarize": "^0.3.0", "electron-notarize": "^0.3.0",
"electron-rebuild": "^1.11.0", "electron-rebuild": "^1.11.0",
@@ -281,15 +283,12 @@
"make-plural": "^6.2.1", "make-plural": "^6.2.1",
"material-design-icons": "^3.0.1", "material-design-icons": "^3.0.1",
"mini-css-extract-plugin": "^0.9.0", "mini-css-extract-plugin": "^0.9.0",
"mobx": "^5.15.4",
"mobx-observable-history": "^1.0.3",
"mobx-react": "^6.2.2", "mobx-react": "^6.2.2",
"moment": "^2.26.0", "moment": "^2.26.0",
"node-loader": "^0.6.0", "node-loader": "^0.6.0",
"node-sass": "^4.14.1", "node-sass": "^4.14.1",
"nodemon": "^2.0.4", "nodemon": "^2.0.4",
"patch-package": "^6.2.2", "patch-package": "^6.2.2",
"path-to-regexp": "^6.1.0",
"postinstall-postinstall": "^2.1.0", "postinstall-postinstall": "^2.1.0",
"raw-loader": "^4.0.1", "raw-loader": "^4.0.1",
"react": "^16.13.1", "react": "^16.13.1",
@@ -298,7 +297,7 @@
"react-select": "^3.1.0", "react-select": "^3.1.0",
"react-window": "^1.8.5", "react-window": "^1.8.5",
"sass-loader": "^8.0.2", "sass-loader": "^8.0.2",
"spectron": "^8.0.0", "spectron": "11.0.0",
"style-loader": "^1.2.1", "style-loader": "^1.2.1",
"terser-webpack-plugin": "^3.0.3", "terser-webpack-plugin": "^3.0.3",
"ts-jest": "^26.1.0", "ts-jest": "^26.1.0",
+12 -3
View File
@@ -3,16 +3,26 @@ import { ClusterId, clusterStore } from "./cluster-store";
import { tracker } from "./tracker"; import { tracker } from "./tracker";
export const clusterIpc = { export const clusterIpc = {
init: createIpcChannel({
channel: "cluster:init",
handle: async (clusterId: ClusterId, frameId: number) => {
const cluster = clusterStore.getById(clusterId);
if (cluster) {
cluster.frameId = frameId; // save cluster's webFrame.routingId to be able to send push-updates
return cluster.pushState();
}
},
}),
activate: createIpcChannel({ activate: createIpcChannel({
channel: "cluster:activate", channel: "cluster:activate",
handle: async (clusterId: ClusterId = clusterStore.activeClusterId) => { handle: (clusterId: ClusterId) => {
return clusterStore.getById(clusterId)?.activate(); return clusterStore.getById(clusterId)?.activate();
}, },
}), }),
disconnect: createIpcChannel({ disconnect: createIpcChannel({
channel: "cluster:disconnect", channel: "cluster:disconnect",
handle: (clusterId: ClusterId = clusterStore.activeClusterId) => { handle: (clusterId: ClusterId) => {
tracker.event("cluster", "stop"); tracker.event("cluster", "stop");
return clusterStore.getById(clusterId)?.disconnect(); return clusterStore.getById(clusterId)?.disconnect();
}, },
@@ -23,7 +33,6 @@ export const clusterIpc = {
handle: async (clusterId: ClusterId, feature: string, config?: any) => { handle: async (clusterId: ClusterId, feature: string, config?: any) => {
tracker.event("cluster", "install", feature); tracker.event("cluster", "install", feature);
const cluster = clusterStore.getById(clusterId); const cluster = clusterStore.getById(clusterId);
if (cluster) { if (cluster) {
await cluster.installFeature(feature, config) await cluster.installFeature(feature, config)
} else { } else {
+13 -18
View File
@@ -1,10 +1,8 @@
import type { WorkspaceId } from "./workspace-store"; import type { WorkspaceId } from "./workspace-store";
import path from "path"; import path from "path";
import filenamify from "filenamify";
import { app, ipcRenderer, remote } from "electron"; 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 { action, computed, observable, toJS } from "mobx";
import { appProto, noClustersHost } from "./vars";
import { BaseStore } from "./base-store"; import { BaseStore } from "./base-store";
import { Cluster, ClusterState } from "../main/cluster"; import { Cluster, ClusterState } from "../main/cluster";
import migrations from "../migrations/cluster-store" import migrations from "../migrations/cluster-store"
@@ -64,11 +62,10 @@ export class ClusterStore extends BaseStore<ClusterStoreModel> {
migrations: migrations, migrations: migrations,
}); });
if (ipcRenderer) { if (ipcRenderer) {
ipcRenderer.on("cluster:state", (event, clusterState: ClusterState) => { ipcRenderer.on("cluster:state", (event, model: ClusterState) => {
this.applyWithoutSync(() => { this.applyWithoutSync(() => {
logger.debug(`[CLUSTER-STORE]: received state update for cluster=${clusterState.id}`, clusterState); logger.debug(`[CLUSTER-STORE]: received push-state at ${location.host}`, model);
const cluster = this.getById(clusterState.id); this.getById(model.id)?.updateModel(model);
if (cluster) cluster.updateModel(clusterState)
}) })
}) })
} }
@@ -86,6 +83,10 @@ export class ClusterStore extends BaseStore<ClusterStoreModel> {
return Array.from(this.clusters.values()); return Array.from(this.clusters.values());
} }
isActive(id: ClusterId) {
return this.activeClusterId === id;
}
setActive(id: ClusterId) { setActive(id: ClusterId) {
this.activeClusterId = id; this.activeClusterId = id;
} }
@@ -162,11 +163,6 @@ export class ClusterStore extends BaseStore<ClusterStoreModel> {
this.activeClusterId = newClusters.has(activeCluster) ? activeCluster : null; this.activeClusterId = newClusters.has(activeCluster) ? activeCluster : null;
this.clusters.replace(newClusters); this.clusters.replace(newClusters);
this.removedClusters.replace(removedClusters); this.removedClusters.replace(removedClusters);
// "auto-select" first cluster if available
if (!this.activeClusterId && newClusters.size) {
this.activeClusterId = Array.from(newClusters.values())[0].id;
}
} }
toJSON(): ClusterStoreModel { toJSON(): ClusterStoreModel {
@@ -181,12 +177,11 @@ export class ClusterStore extends BaseStore<ClusterStoreModel> {
export const clusterStore = ClusterStore.getInstance<ClusterStore>(); export const clusterStore = ClusterStore.getInstance<ClusterStore>();
export function isNoClustersView() { export function getHostedClusterId(): ClusterId {
return location.hostname === noClustersHost const clusterHost = location.hostname.match(/^(.*?)\.localhost/);
} if (clusterHost) {
return clusterHost[1]
export function getHostedClusterId() { }
return location.hostname.split(".")[0];
} }
export function getHostedCluster(): Cluster { export function getHostedCluster(): Cluster {
+333
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");
})
})
+48 -67
View File
@@ -7,23 +7,59 @@ import logger from "../main/logger";
export type IpcChannel = string; export type IpcChannel = string;
export interface IpcHandleOpts { export interface IpcChannelOptions {
timeout?: number; channel: IpcChannel; // main <-> renderer communication channel name
handle?: (...args: any[]) => Promise<any> | any; // message handler
autoBind?: boolean; // auto-bind message handler in main-process, default: true
timeout?: number; // timeout for waiting response from the sender
once?: boolean; // one-time event
} }
export interface IpcMessageHandler<T extends any[] = any> { export function createIpcChannel({ autoBind = true, once, timeout = 0, handle, channel }: IpcChannelOptions) {
(...args: T): any; const ipcChannel = {
channel: channel,
handleInMain: () => {
logger.info(`[IPC]: setup channel "${channel}"`);
const ipcHandler = once ? ipcMain.handleOnce : ipcMain.handle;
ipcHandler(channel, async (event, ...args) => {
let timerId: any;
try {
if (timeout > 0) {
timerId = setTimeout(() => {
throw new Error(`[IPC]: response timeout in ${timeout}ms`)
}, timeout);
}
return await handle(...args); // todo: maybe exec in separate thread/worker
} catch (error) {
throw error
} finally {
clearTimeout(timerId);
}
})
},
removeHandler() {
ipcMain.removeHandler(channel);
},
invokeFromRenderer: async <T>(...args: any[]): Promise<T> => {
return ipcRenderer.invoke(channel, ...args);
},
}
if (autoBind && ipcMain) {
ipcChannel.handleInMain();
}
return ipcChannel;
} }
export interface IpcMessageOpts<A extends any[] = any> { export interface IpcBroadcastParams<A extends any[] = any> {
channel: IpcChannel channel: IpcChannel
webContentId?: number; // sends to single webContents view webContentId?: number; // send to single webContents view
frameId?: number; // send to inner frame of webContents
filter?: (webContent: WebContents) => boolean filter?: (webContent: WebContents) => boolean
timeout?: number; // todo: add support timeout?: number; // todo: add support
args?: A; args?: A;
} }
export function broadcastIpc({ channel, webContentId, filter, args = [] }: IpcMessageOpts) { export function broadcastIpc({ channel, frameId, webContentId, filter, args = [] }: IpcBroadcastParams) {
const singleView = webContentId ? webContents.fromId(webContentId) : null; const singleView = webContentId ? webContents.fromId(webContentId) : null;
let views = singleView ? [singleView] : webContents.getAllWebContents(); let views = singleView ? [singleView] : webContents.getAllWebContents();
if (filter) { if (filter) {
@@ -31,65 +67,10 @@ export function broadcastIpc({ channel, webContentId, filter, args = [] }: IpcMe
} }
views.forEach(webContent => { views.forEach(webContent => {
const type = webContent.getType(); const type = webContent.getType();
logger.debug(`[IPC]: sending message "${channel}" to ${type}=${webContent.id}`, { args }); logger.debug(`[IPC]: broadcasting "${channel}" to ${type}=${webContent.id}`, { args });
webContent.send(channel, ...[args].flat()); webContent.send(channel, ...args);
if (frameId) {
webContent.sendToFrame(frameId, channel, ...args)
}
}) })
} }
// todo: support timeout + merge with sendMessage?
export async function invokeIpc<R = any>(channel: IpcChannel, ...args: any[]): Promise<R> {
logger.info(`[IPC]: invoke channel "${channel}"`, { args });
return ipcRenderer.invoke(channel, ...args);
}
// todo: make isomorphic api
export function handleIpc(channel: IpcChannel, handler: IpcMessageHandler, options: IpcHandleOpts = {}) {
const { timeout = 0 } = options;
logger.info(`[IPC]: setup to handle "${channel}"`);
ipcMain.handle(channel, async (event, ...args) => {
logger.info(`[IPC]: handle "${channel}"`, { args });
return new Promise(async (resolve, reject) => {
let timerId;
if (timeout) {
timerId = setTimeout(() => {
const timeoutError = new Error("[IPC]: response timeout");
reject(timeoutError);
}, timeout);
}
try {
const result = await handler(...args); // todo: maybe exec in separate thread/worker
resolve(result);
clearTimeout(timerId);
} catch (err) {
reject(err);
}
})
})
}
export interface IpcPairOptions {
channel: IpcChannel
handle?: IpcMessageHandler
autoBind?: boolean;
timeout?: number;
}
// todo: improve api
export function createIpcChannel({ channel, autoBind, ...initOpts }: IpcPairOptions) {
const bindHandler = (opts: { handler?: IpcMessageHandler, options?: IpcHandleOpts } = {}) => {
const handler = opts.handler || initOpts.handle || Function;
const options = opts.options || { timeout: initOpts.timeout };
handleIpc(channel, handler, options);
};
if (autoBind) {
bindHandler();
}
return {
channel: channel,
handleInMain: bindHandler,
invokeFromRenderer(...args: any[]) {
return invokeIpc(channel, ...args);
},
}
}
+1 -1
View File
@@ -153,7 +153,7 @@ export function saveConfigToAppFiles(clusterId: string, kubeConfig: KubeConfig |
export async function getKubeConfigLocal(): Promise<string> { export async function getKubeConfigLocal(): Promise<string> {
try { 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 file = await readFile(configFile, "utf8");
const obj = yaml.safeLoad(file); const obj = yaml.safeLoad(file);
if (obj.contexts) { if (obj.contexts) {
-1
View File
@@ -71,7 +71,6 @@ export class UserStore extends BaseStore<UserStoreModel> {
if (kubeConfig) { if (kubeConfig) {
this.newContexts.clear(); this.newContexts.clear();
const localContexts = loadConfig(kubeConfig).getContexts(); const localContexts = loadConfig(kubeConfig).getContexts();
console.log(localContexts)
localContexts localContexts
.filter(ctx => ctx.cluster) .filter(ctx => ctx.cluster)
.filter(ctx => !this.seenContexts.has(ctx.name)) .filter(ctx => !this.seenContexts.has(ctx.name))
+12
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);
}
+14 -8
View File
@@ -1,6 +1,7 @@
// App's common configuration for any process (main, renderer, build pipeline, etc.) // App's common configuration for any process (main, renderer, build pipeline, etc.)
import packageInfo from "../../package.json"
import path from "path"; import path from "path";
import packageInfo from "../../package.json"
import { defineGlobal } from "./utils/defineGlobal";
export const isMac = process.platform === "darwin" export const isMac = process.platform === "darwin"
export const isWindows = process.platform === "win32" export const isWindows = process.platform === "win32"
@@ -10,20 +11,25 @@ export const isDevelopment = isDebugging || !isProduction;
export const isTestEnv = !!process.env.JEST_WORKER_ID; export const isTestEnv = !!process.env.JEST_WORKER_ID;
export const appName = `${packageInfo.productName}${isDevelopment ? "Dev" : ""}` export const appName = `${packageInfo.productName}${isDevelopment ? "Dev" : ""}`
export const appProto = "lens" // app.getPath("userData") folder export const publicPath = "/build/"
export const staticProto = "static" // static folder (e.g. "static://RELEASE_NOTES.md")
// System paths // Webpack build paths
export const contextDir = process.cwd(); export const contextDir = process.cwd();
export const staticDir = path.join(contextDir, "static"); export const buildDir = path.join(contextDir, "static", publicPath);
export const outDir = path.join(contextDir, "out");
export const mainDir = path.join(contextDir, "src/main"); export const mainDir = path.join(contextDir, "src/main");
export const rendererDir = path.join(contextDir, "src/renderer"); export const rendererDir = path.join(contextDir, "src/renderer");
export const htmlTemplate = path.resolve(rendererDir, "template.html"); export const htmlTemplate = path.resolve(rendererDir, "template.html");
export const sassCommonVars = path.resolve(rendererDir, "components/vars.scss"); export const sassCommonVars = path.resolve(rendererDir, "components/vars.scss");
// System pages // Special runtime paths
export const noClustersHost = "no-clusters.localhost" defineGlobal("__static", {
get() {
if (isDevelopment) {
return path.resolve(contextDir, "static");
}
return path.resolve(process.resourcesPath, "static")
}
})
// Apis // Apis
export const apiPrefix = "/api" // local router apis export const apiPrefix = "/api" // local router apis
+2 -9
View File
@@ -1,10 +1,10 @@
import "../common/cluster-ipc";
import type http from "http" import type http from "http"
import { autorun } from "mobx"; import { autorun } from "mobx";
import { apiKubePrefix } from "../common/vars";
import { ClusterId, clusterStore } from "../common/cluster-store" import { ClusterId, clusterStore } from "../common/cluster-store"
import { Cluster } from "./cluster" import { Cluster } from "./cluster"
import { clusterIpc } from "../common/cluster-ipc";
import logger from "./logger"; import logger from "./logger";
import { apiKubePrefix } from "../common/vars";
export class ClusterManager { export class ClusterManager {
constructor(public readonly port: number) { constructor(public readonly port: number) {
@@ -30,13 +30,6 @@ export class ClusterManager {
}, { }, {
delay: 250 delay: 250
}); });
// listen for ipc-events that must/can be handled *only* in main-process (nodeIntegration=true)
clusterIpc.activate.handleInMain();
clusterIpc.disconnect.handleInMain();
clusterIpc.installFeature.handleInMain();
clusterIpc.uninstallFeature.handleInMain();
clusterIpc.upgradeFeature.handleInMain();
} }
stop() { stop() {
+28 -20
View File
@@ -1,7 +1,8 @@
import type { ClusterId, ClusterModel, ClusterPreferences } from "../common/cluster-store" 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 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 { apiKubePrefix } from "../common/vars";
import { broadcastIpc } from "../common/ipc"; import { broadcastIpc } from "../common/ipc";
import { ContextHandler } from "./context-handler" import { ContextHandler } from "./context-handler"
@@ -39,6 +40,7 @@ export interface ClusterState extends ClusterModel {
export class Cluster implements ClusterModel { export class Cluster implements ClusterModel {
public id: ClusterId; public id: ClusterId;
public frameId: number;
public kubeCtl: Kubectl public kubeCtl: Kubectl
public contextHandler: ContextHandler; public contextHandler: ContextHandler;
protected kubeconfigManager: KubeconfigManager; protected kubeconfigManager: KubeconfigManager;
@@ -52,7 +54,6 @@ export class Cluster implements ClusterModel {
@observable kubeConfigPath: string; @observable kubeConfigPath: string;
@observable apiUrl: string; // cluster server url @observable apiUrl: string; // cluster server url
@observable kubeProxyUrl: string; // lens-proxy to kube-api url @observable kubeProxyUrl: string; // lens-proxy to kube-api url
@observable webContentUrl: string; // page content url for loading in renderer
@observable online: boolean; @observable online: boolean;
@observable accessible: boolean; @observable accessible: boolean;
@observable disconnected: boolean; @observable disconnected: boolean;
@@ -67,6 +68,10 @@ export class Cluster implements ClusterModel {
@observable allowedNamespaces: string[] = []; @observable allowedNamespaces: string[] = [];
@observable allowedResources: string[] = []; @observable allowedResources: string[] = [];
@computed get available() {
return this.accessible && !this.disconnected;
}
constructor(model: ClusterModel) { constructor(model: ClusterModel) {
this.updateModel(model); this.updateModel(model);
} }
@@ -74,26 +79,21 @@ export class Cluster implements ClusterModel {
@action @action
updateModel(model: ClusterModel) { updateModel(model: ClusterModel) {
Object.assign(this, model); Object.assign(this, model);
this.apiUrl = this.getKubeconfig().getCurrentCluster().server; this.apiUrl = this.getKubeconfig().getCurrentCluster()?.server;
this.contextName = this.contextName || this.preferences.clusterName; this.contextName = this.contextName || this.preferences.clusterName;
} }
@action @action
async init(port: number) { async init(port: number) {
if (this.initialized) {
return;
}
try { try {
this.contextHandler = new ContextHandler(this); this.contextHandler = new ContextHandler(this);
this.kubeconfigManager = new KubeconfigManager(this, this.contextHandler); this.kubeconfigManager = new KubeconfigManager(this, this.contextHandler);
this.kubeProxyUrl = `http://localhost:${port}${apiKubePrefix}`; this.kubeProxyUrl = `http://localhost:${port}${apiKubePrefix}`;
this.webContentUrl = `http://${this.id}.localhost:${port}`;
this.initialized = true; this.initialized = true;
logger.info(`[CLUSTER]: init success`, { logger.info(`[CLUSTER]: "${this.contextName}" init success`, {
id: this.id, id: this.id,
serverUrl: this.apiUrl, context: this.contextName,
webContentUrl: this.webContentUrl, apiUrl: this.apiUrl
kubeProxyUrl: this.kubeProxyUrl,
}); });
} catch (err) { } catch (err) {
logger.error(`[CLUSTER]: init failed: ${err}`, { logger.error(`[CLUSTER]: init failed: ${err}`, {
@@ -155,7 +155,7 @@ export class Cluster implements ClusterModel {
@action @action
async refresh() { async refresh() {
logger.info(`[CLUSTER]: refresh`, this.getMeta()); logger.info(`[CLUSTER]: refresh`, this.getMeta());
await this.refreshConnectionStatus(); await this.refreshConnectionStatus(); // refresh "version", "online", etc.
if (this.accessible) { if (this.accessible) {
this.kubeCtl = new Kubectl(this.version) this.kubeCtl = new Kubectl(this.version)
this.distribution = this.detectKubernetesDistribution(this.version) this.distribution = this.detectKubernetesDistribution(this.version)
@@ -217,22 +217,30 @@ export class Cluster implements ClusterModel {
return uninstallFeature(name, this) return uninstallFeature(name, this)
} }
getPrometheusApiPrefix() { protected async k8sRequest<T = any>(path: string, options: RequestPromiseOptions = {}): Promise<T> {
return this.preferences.prometheus?.prefix || ""
}
protected async k8sRequest(path: string, options: RequestPromiseOptions = {}) {
const apiUrl = this.kubeProxyUrl + path; const apiUrl = this.kubeProxyUrl + path;
return request(apiUrl, { return request(apiUrl, {
json: true, json: true,
timeout: 5000, timeout: 5000,
...options,
headers: { headers: {
Host: `${this.id}.${new URL(this.kubeProxyUrl).host}`, // required in ClusterManager.getClusterForRequest()
...(options.headers || {}), ...(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> { protected async getConnectionStatus(): Promise<ClusterStatus> {
try { try {
const response = await this.k8sRequest("/version") const response = await this.k8sRequest("/version")
@@ -382,8 +390,8 @@ export class Cluster implements ClusterModel {
pushState = (state = this.getState()): ClusterState => { pushState = (state = this.getState()): ClusterState => {
logger.debug(`[CLUSTER]: push-state`, state); logger.debug(`[CLUSTER]: push-state`, state);
broadcastIpc({ broadcastIpc({
// webContentId: viewId, // todo: send to cluster-view only
channel: "cluster:state", channel: "cluster:state",
frameId: this.frameId,
args: [state], args: [state],
}); });
return state; return state;
+8 -9
View File
@@ -3,9 +3,8 @@
import "../common/system-ca" import "../common/system-ca"
import "../common/prometheus-providers" import "../common/prometheus-providers"
import { app, dialog } from "electron" import { app, dialog } from "electron"
import { appName, appProto, staticDir, staticProto } from "../common/vars"; import { appName } from "../common/vars";
import path from "path" import path from "path"
import { initMenu } from "./menu"
import { LensProxy } from "./lens-proxy" import { LensProxy } from "./lens-proxy"
import { WindowManager } from "./window-manager"; import { WindowManager } from "./window-manager";
import { ClusterManager } from "./cluster-manager"; import { ClusterManager } from "./cluster-manager";
@@ -20,6 +19,12 @@ import { workspaceStore } from "../common/workspace-store";
import { tracker } from "../common/tracker"; import { tracker } from "../common/tracker";
import logger from "./logger" import logger from "./logger"
const workingDir = path.join(app.getPath("appData"), appName);
app.setName(appName);
if(!process.env.CICD) {
app.setPath("userData", workingDir);
}
let windowManager: WindowManager; let windowManager: WindowManager;
let clusterManager: ClusterManager; let clusterManager: ClusterManager;
let proxyServer: LensProxy; let proxyServer: LensProxy;
@@ -31,18 +36,13 @@ if (app.commandLine.getSwitchValue("proxy-server") !== "") {
async function main() { async function main() {
await shellSync(); await shellSync();
const workingDir = path.join(app.getPath("appData"), appName);
app.setName(appName);
app.setPath("userData", workingDir);
logger.info(`🚀 Starting Lens from "${workingDir}"`) logger.info(`🚀 Starting Lens from "${workingDir}"`)
tracker.event("app", "start"); tracker.event("app", "start");
const updater = new AppUpdater() const updater = new AppUpdater()
updater.start(); updater.start();
registerFileProtocol(appProto, app.getPath("userData")); registerFileProtocol("static", __static);
registerFileProtocol(staticProto, staticDir);
// find free port // find free port
let proxyPort: number let proxyPort: number
@@ -74,7 +74,6 @@ async function main() {
// create window manager and open app // create window manager and open app
windowManager = new WindowManager(proxyPort); windowManager = new WindowManager(proxyPort);
initMenu(windowManager);
} }
app.on("ready", main); app.on("ready", main);
+13 -20
View File
@@ -7,10 +7,11 @@ import { openShell } from "./node-shell-session";
import { Router } from "./router" import { Router } from "./router"
import { ClusterManager } from "./cluster-manager" import { ClusterManager } from "./cluster-manager"
import { ContextHandler } from "./context-handler"; import { ContextHandler } from "./context-handler";
import { apiKubePrefix, noClustersHost } from "../common/vars"; import { apiKubePrefix } from "../common/vars";
import logger from "./logger" import logger from "./logger"
export class LensProxy { export class LensProxy {
protected origin: string
protected proxyServer: http.Server protected proxyServer: http.Server
protected router: Router protected router: Router
protected closed = false protected closed = false
@@ -21,12 +22,13 @@ export class LensProxy {
} }
private constructor(protected port: number, protected clusterManager: ClusterManager) { private constructor(protected port: number, protected clusterManager: ClusterManager) {
this.origin = `http://localhost:${port}`
this.router = new Router(); this.router = new Router();
} }
listen(port = this.port): this { listen(port = this.port): this {
this.proxyServer = this.buildCustomProxy().listen(port); 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; return this;
} }
@@ -117,26 +119,17 @@ export class LensProxy {
} }
protected async handleRequest(proxy: httpProxy, req: http.IncomingMessage, res: http.ServerResponse) { 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) const cluster = this.clusterManager.getClusterForRequest(req)
if (!cluster) { if (cluster) {
const reqId = this.getRequestId(req); await cluster.contextHandler.ensureServer();
logger.error("Got request to unknown cluster", { reqId }) const proxyTarget = await this.getProxyTarget(req, cluster.contextHandler)
res.statusCode = 503 if (proxyTarget) {
res.end() // allow to fetch apis in "clusterId.localhost:port" from "localhost:port"
return res.setHeader("Access-Control-Allow-Origin", this.origin);
} return proxy.web(req, res, proxyTarget);
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);
} }
this.router.route(cluster, req, res);
} }
protected async handleWsUpgrade(req: http.IncomingMessage, socket: net.Socket, head: Buffer) { protected async handleWsUpgrade(req: http.IncomingMessage, socket: net.Socket, head: Buffer) {
+112 -69
View File
@@ -1,9 +1,7 @@
import type { WindowManager } from "./window-manager";
import { app, BrowserWindow, dialog, Menu, MenuItem, MenuItemConstructorOptions, shell, webContents } from "electron" import { app, BrowserWindow, dialog, Menu, MenuItem, MenuItemConstructorOptions, shell, webContents } from "electron"
import { autorun } from "mobx"; import { autorun } from "mobx";
import { broadcastIpc } from "../common/ipc"; import { WindowManager } from "./window-manager";
import { appName, isMac, issuesTrackerUrl, isWindows, slackUrl } from "../common/vars"; import { appName, isMac, issuesTrackerUrl, isWindows, slackUrl } from "../common/vars";
import { clusterStore } from "../common/cluster-store";
import { addClusterURL } from "../renderer/components/+add-cluster/add-cluster.route"; import { addClusterURL } from "../renderer/components/+add-cluster/add-cluster.route";
import { preferencesURL } from "../renderer/components/+preferences/preferences.route"; import { preferencesURL } from "../renderer/components/+preferences/preferences.route";
import { whatsNewURL } from "../renderer/components/+whats-new/whats-new.route"; import { whatsNewURL } from "../renderer/components/+whats-new/whats-new.route";
@@ -11,45 +9,61 @@ import { clusterSettingsURL } from "../renderer/components/+cluster-settings/clu
import logger from "./logger"; import logger from "./logger";
export function initMenu(windowManager: WindowManager) { export function initMenu(windowManager: WindowManager) {
autorun(() => { autorun(() => buildMenu(windowManager), {
logger.debug(`[MENU]: building menu, cluster=${clusterStore.activeClusterId}`); delay: 100
buildMenu(windowManager);
}); });
} }
function buildMenu(windowManager: WindowManager) { export function buildMenu(windowManager: WindowManager) {
const hasClusters = clusterStore.hasClusters(); function ignoreOnMac(menuItems: MenuItemConstructorOptions[]) {
const activeClusterId = clusterStore.activeClusterId; if (isMac) return [];
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 [];
return menuItems; return menuItems;
} }
const fileMenu: MenuItemConstructorOptions = { function activeClusterOnly(menuItems: MenuItemConstructorOptions[]) {
label: isMac ? app.getName() : "File", if (!windowManager.activeClusterId) {
menuItems.forEach(item => {
item.enabled = false
});
}
return menuItems;
}
function navigate(url: string) {
logger.info(`[MENU]: navigating to ${url}`);
windowManager.navigate({
channel: "menu:navigate",
url: url,
})
}
function showAbout(browserWindow: BrowserWindow) {
const appInfo = [
`${appName}: ${app.getVersion()}`,
`Electron: ${process.versions.electron}`,
`Chrome: ${process.versions.chrome}`,
`Copyright 2020 Lakend Labs, Inc.`,
]
dialog.showMessageBoxSync(browserWindow, {
title: `${isWindows ? " ".repeat(2) : ""}${appName}`,
type: "info",
buttons: ["Close"],
message: `Lens`,
detail: appInfo.join("\r\n")
})
}
const mt: MenuItemConstructorOptions[] = [];
const macAppMenu: MenuItemConstructorOptions = {
label: app.getName(),
submenu: [ submenu: [
{ {
label: 'Add Cluster', label: "About Lens",
click() { click(menuItem: MenuItem, browserWindow: BrowserWindow) {
navigate(addClusterURL()) showAbout(browserWindow)
} }
}, },
...(hasClusters ? [{
label: 'Cluster Settings',
click() {
navigate(clusterSettingsURL())
}
}] : []),
{ type: 'separator' }, { type: 'separator' },
{ {
label: 'Preferences', label: 'Preferences',
@@ -57,19 +71,57 @@ function buildMenu(windowManager: WindowManager) {
navigate(preferencesURL()) navigate(preferencesURL())
} }
}, },
...macOnly([ { type: 'separator' },
{ type: 'separator' }, { role: 'services' },
{ role: 'services' }, { type: 'separator' },
{ type: 'separator' }, { role: 'hide' },
{ role: 'hide' }, { role: 'hideOthers' },
{ role: 'hideOthers' }, { role: 'unhide' },
{ role: 'unhide' },
]),
{ type: 'separator' }, { type: 'separator' },
{ role: 'quit' } { role: 'quit' }
] ]
}; };
if (isMac) {
mt.push(macAppMenu);
}
const fileMenu: MenuItemConstructorOptions = {
label: "File",
submenu: [
{
label: 'Add Cluster',
click() {
navigate(addClusterURL())
}
},
...activeClusterOnly([
{
label: 'Cluster Settings',
click() {
navigate(clusterSettingsURL({
params: {
clusterId: windowManager.activeClusterId
}
}))
}
}
]),
...ignoreOnMac([
{ type: 'separator' },
{
label: 'Preferences',
click() {
navigate(preferencesURL())
}
},
{ type: 'separator' },
{ role: 'quit' }
])
]
};
mt.push(fileMenu)
const editMenu: MenuItemConstructorOptions = { const editMenu: MenuItemConstructorOptions = {
label: 'Edit', label: 'Edit',
submenu: [ submenu: [
@@ -84,7 +136,7 @@ function buildMenu(windowManager: WindowManager) {
{ role: 'selectAll' }, { role: 'selectAll' },
] ]
}; };
mt.push(editMenu)
const viewMenu: MenuItemConstructorOptions = { const viewMenu: MenuItemConstructorOptions = {
label: 'View', label: 'View',
submenu: [ submenu: [
@@ -92,21 +144,21 @@ function buildMenu(windowManager: WindowManager) {
label: 'Back', label: 'Back',
accelerator: 'CmdOrCtrl+[', accelerator: 'CmdOrCtrl+[',
click() { click() {
webContents.getFocusedWebContents().executeJavaScript('window.history.back()') webContents.getFocusedWebContents()?.goBack();
} }
}, },
{ {
label: 'Forward', label: 'Forward',
accelerator: 'CmdOrCtrl+]', accelerator: 'CmdOrCtrl+]',
click() { click() {
webContents.getFocusedWebContents().executeJavaScript('window.history.forward()') webContents.getFocusedWebContents()?.goForward();
} }
}, },
{ {
label: 'Reload', label: 'Reload',
accelerator: 'CmdOrCtrl+R', accelerator: 'CmdOrCtrl+R',
click() { click() {
webContents.getFocusedWebContents().reload() webContents.getFocusedWebContents()?.reload();
} }
}, },
{ role: 'toggleDevTools' }, { role: 'toggleDevTools' },
@@ -118,16 +170,11 @@ function buildMenu(windowManager: WindowManager) {
{ role: 'togglefullscreen' } { role: 'togglefullscreen' }
] ]
}; };
mt.push(viewMenu)
const helpMenu: MenuItemConstructorOptions = { const helpMenu: MenuItemConstructorOptions = {
role: 'help', role: 'help',
submenu: [ submenu: [
{
label: "What's new?",
click() {
navigate(whatsNewURL())
},
},
{ {
label: "License", label: "License",
click: async () => { click: async () => {
@@ -147,27 +194,23 @@ function buildMenu(windowManager: WindowManager) {
}, },
}, },
{ {
label: "About Lens", label: "What's new?",
click(menuItem: MenuItem, browserWindow: BrowserWindow) { click() {
const appInfo = [ navigate(whatsNewURL())
`${appName}: ${app.getVersion()}`, },
`Electron: ${process.versions.electron}`, },
`Chrome: ${process.versions.chrome}`, ...ignoreOnMac([
`Copyright 2020 Lakend Labs, Inc.`, {
] label: "About Lens",
dialog.showMessageBoxSync(browserWindow, { click(menuItem: MenuItem, browserWindow: BrowserWindow) {
title: `${isWindows ? " ".repeat(2) : ""}${appName}`, showAbout(browserWindow)
type: "info", }
buttons: ["Close"],
message: `Lens`,
detail: appInfo.join("\r\n")
})
} }
} ])
] ]
}; };
Menu.setApplicationMenu(Menu.buildFromTemplate([ mt.push(helpMenu)
fileMenu, editMenu, viewMenu, helpMenu
])); Menu.setApplicationMenu(Menu.buildFromTemplate(mt));
} }
+3 -3
View File
@@ -4,7 +4,7 @@ import http from "http"
import path from "path" import path from "path"
import { readFile } from "fs-extra" import { readFile } from "fs-extra"
import { Cluster } from "./cluster" import { Cluster } from "./cluster"
import { apiPrefix, appName, outDir } from "../common/vars"; import { apiPrefix, appName, publicPath } from "../common/vars";
import { helmRoute, kubeconfigRoute, metricsRoute, portForwardRoute, resourceApplierRoute, watchRoute } from "./routes"; import { helmRoute, kubeconfigRoute, metricsRoute, portForwardRoute, resourceApplierRoute, watchRoute } from "./routes";
export interface RouterRequestOpts { export interface RouterRequestOpts {
@@ -95,14 +95,14 @@ export class Router {
} }
async handleStaticFile(filePath: string, res: http.ServerResponse) { async handleStaticFile(filePath: string, res: http.ServerResponse) {
const asset = path.join(outDir, filePath); const asset = path.join(__static, filePath);
try { try {
const data = await readFile(asset); const data = await readFile(asset);
res.setHeader("Content-Type", this.getMimeType(asset)); res.setHeader("Content-Type", this.getMimeType(asset));
res.write(data) res.write(data)
res.end() res.end()
} catch (err) { } catch (err) {
this.handleStaticFile(`${appName}.html`, res); this.handleStaticFile(`${publicPath}/${appName}.html`, res);
} }
} }
+9 -25
View File
@@ -1,7 +1,5 @@
import url from "url"
import { LensApiRequest } from "../router" import { LensApiRequest } from "../router"
import { LensApi } from "../lens-api" import { LensApi } from "../lens-api"
import requestPromise from "request-promise-native"
import { PrometheusClusterQuery, PrometheusIngressQuery, PrometheusNodeQuery, PrometheusPodQuery, PrometheusProvider, PrometheusPvcQuery, PrometheusQueryOpts } from "../prometheus/provider-registry" import { PrometheusClusterQuery, PrometheusIngressQuery, PrometheusNodeQuery, PrometheusPodQuery, PrometheusProvider, PrometheusPvcQuery, PrometheusQueryOpts } from "../prometheus/provider-registry"
export type IMetricsQuery = string | string[] | { export type IMetricsQuery = string | string[] | {
@@ -9,25 +7,19 @@ export type IMetricsQuery = string | string[] | {
} }
class MetricsRoute extends LensApi { class MetricsRoute extends LensApi {
async routeMetrics(request: LensApiRequest) {
public async routeMetrics(request: LensApiRequest) {
const { response, cluster, payload } = request 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 = {} const queryParams: IMetricsQuery = {}
request.query.forEach((value: string, key: string) => { request.query.forEach((value: string, key: string) => {
queryParams[key] = value queryParams[key] = value
}) })
let prometheusPath: string
let metricsUrl: string
let prometheusProvider: PrometheusProvider let prometheusProvider: PrometheusProvider
try { try {
const prometheusPath = await contextHandler.getPrometheusPath() [prometheusPath, prometheusProvider] = await Promise.all([
metricsUrl = `${kubeProxyUrl}/api/v1/namespaces/${prometheusPath}/proxy${cluster.getPrometheusApiPrefix()}/api/v1/query_range` cluster.contextHandler.getPrometheusPath(),
prometheusProvider = await contextHandler.getPrometheusProvider() cluster.contextHandler.getPrometheusProvider()
])
} catch { } catch {
this.respondJson(response, {}) this.respondJson(response, {})
return return
@@ -35,18 +27,10 @@ class MetricsRoute extends LensApi {
// prometheus metrics loader // prometheus metrics loader
const attempts: { [query: string]: number } = {}; const attempts: { [query: string]: number } = {};
const maxAttempts = 5; const maxAttempts = 5;
const loadMetrics = (orgQuery: string): Promise<any> => { const loadMetrics = (promQuery: string): Promise<any> => {
const query = orgQuery.trim() const query = promQuery.trim()
const attempt = attempts[query] = (attempts[query] || 0) + 1; const attempt = attempts[query] = (attempts[query] || 0) + 1;
return requestPromise(metricsUrl, { return cluster.getMetrics(prometheusPath, { query, ...queryParams }).catch(async error => {
resolveWithFullResponse: false,
headers: headers,
json: true,
qs: {
query: query,
...queryParams
}
}).catch(async (error) => {
if (attempt < maxAttempts && (error.statusCode && error.statusCode != 404)) { if (attempt < maxAttempts && (error.statusCode && error.statusCode != 404)) {
await new Promise(resolve => setTimeout(resolve, attempt * 1000)); // add delay before repeating request await new Promise(resolve => setTimeout(resolve, attempt * 1000)); // add delay before repeating request
return loadMetrics(query); return loadMetrics(query);
+54 -131
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 type { ClusterId } from "../common/cluster-store";
import { clusterStore } from "../common/cluster-store"; import { BrowserWindow, dialog, ipcMain, shell, WebContents, webContents } from "electron"
import { noClustersHost } from "../common/vars"; import windowStateKeeper from "electron-window-state"
import logger from "./logger"; import { observable } from "mobx";
import { initMenu } from "./menu";
export class WindowManager { export class WindowManager {
protected activeView: BrowserWindow; protected mainView: BrowserWindow;
protected splashWindow: BrowserWindow; protected splashWindow: BrowserWindow;
protected noClustersWindow: BrowserWindow;
protected views = new Map<ClusterId, BrowserWindow>();
protected disposers: CallableFunction[] = [];
protected windowState: windowStateKeeper.State; 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 // Manage main window size and position with state persistence
this.windowState = windowStateKeeper({ this.windowState = windowStateKeeper({
defaultHeight: 900, defaultHeight: 900,
defaultWidth: 1440, defaultWidth: 1440,
}); });
// Show while app not ready const { width, height, x, y } = this.windowState;
if (showSplash) { this.mainView = new BrowserWindow({
this.showSplash(); 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 // open external links in default browser (target=_blank, window.open)
this.disposers.push( this.mainView.webContents.on("new-window", (event, url) => {
// auto-show/hide "no-clusters" window when necessary event.preventDefault();
reaction(() => clusterStore.hasClusters(), hasClusters => { shell.openExternal(url);
this.handleNoClustersView({ activate: !hasClusters }); });
}, {
fireImmediately: true
}),
// auto-show active cluster window // track visible cluster from ui
reaction(() => clusterStore.activeClusterId, this.activateView, { ipcMain.on("cluster-view:change", (event, clusterId: ClusterId) => {
fireImmediately: true, this.activeClusterId = clusterId;
}), });
// auto-destroy views for removed clusters // load & show app
reaction(() => clusterStore.removedClusters.toJS(), removedClusters => { this.showMain();
removedClusters.forEach(cluster => { initMenu(this);
this.destroyClusterView(cluster.id);
});
}, {
delay: 25, // fix: destroy later and allow to use view's state in next activateView()
}),
);
} }
protected handleNoClustersView = async ({ activate = false } = {}) => { navigate({ url, channel, frameId }: { url: string, channel: string, frameId?: number }) {
if (!this.noClustersWindow) { if (frameId) {
this.noClustersWindow = this.initClusterView(null); this.mainView.webContents.sendToFrame(frameId, channel, url);
await this.noClustersWindow.loadURL(`http://${noClustersHost}:${this.proxyPort}`); } else {
this.mainView.webContents.send(channel, url);
} }
if (activate) { }
this.activeView = this.noClustersWindow;
this.noClustersWindow.show(); async showMain() {
this.hideSplash(); 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, frame: false,
resizable: false, resizable: false,
show: false, show: false,
webPreferences: {
nodeIntegration: true
}
}); });
await this.splashWindow.loadURL("static://splash.html"); await this.splashWindow.loadURL("static://splash.html");
} }
this.splashWindow.show(); 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() { destroy() {
this.windowState.unmanage(); 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.destroy();
this.splashWindow = null; this.mainView.destroy();
this.activeView = null;
} }
} }
+3 -3
View File
@@ -4,16 +4,16 @@ import { Notifications } from "../components/notifications";
import { apiKubePrefix, apiPrefix, isDevelopment } from "../../common/vars"; import { apiKubePrefix, apiPrefix, isDevelopment } from "../../common/vars";
export const apiBase = new JsonApi({ export const apiBase = new JsonApi({
apiBase: apiPrefix,
debug: isDevelopment, debug: isDevelopment,
apiPrefix: apiPrefix,
}); });
export const apiKube = new KubeJsonApi({ export const apiKube = new KubeJsonApi({
apiBase: apiKubePrefix,
debug: isDevelopment, debug: isDevelopment,
apiPrefix: apiKubePrefix,
}); });
// Common handler for HTTP api errors // Common handler for HTTP api errors
function onApiError(error: JsonApiErrorParsed, res: Response) { export function onApiError(error: JsonApiErrorParsed, res: Response) {
switch (res.status) { switch (res.status) {
case 403: case 403:
error.isUsedForNotification = true; error.isUsedForNotification = true;
+2 -2
View File
@@ -27,7 +27,7 @@ export interface JsonApiLog {
} }
export interface JsonApiConfig { export interface JsonApiConfig {
apiPrefix: string; apiBase: string;
debug?: boolean; debug?: boolean;
} }
@@ -72,7 +72,7 @@ export class JsonApi<D = JsonApiData, P extends JsonApiParams = JsonApiParams> {
} }
protected request<D>(path: string, params?: P, init: RequestInit = {}) { 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 reqInit: RequestInit = { ...this.reqInit, ...init };
const { data, query } = params || {} as P; const { data, query } = params || {} as P;
if (data && !reqInit.body) { if (data && !reqInit.body) {
+1 -1
View File
@@ -61,7 +61,7 @@ export class KubeWatchApi {
} }
protected getQuery(): Partial<IKubeWatchRouteQuery> { protected getQuery(): Partial<IKubeWatchRouteQuery> {
const { isAdmin, allowedNamespaces } = getHostedCluster(); const { isAdmin, allowedNamespaces } = getHostedCluster()
return { return {
api: this.activeApis.map(api => { api: this.activeApis.map(api => {
if (isAdmin) return api.getWatchUrl(); if (isAdmin) return api.getWatchUrl();
+38
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);
@@ -1,2 +1,11 @@
.AddCluster { .AddCluster {
.Select {
&__control {
box-shadow: 0 0 0 1px $borderFaintColor;
}
}
code {
color: $pink-400;
}
} }
@@ -15,8 +15,9 @@ import { getKubeConfigLocal, loadConfig, saveConfigToAppFiles, splitConfig, vali
import { clusterStore } from "../../../common/cluster-store"; import { clusterStore } from "../../../common/cluster-store";
import { workspaceStore } from "../../../common/workspace-store"; import { workspaceStore } from "../../../common/workspace-store";
import { v4 as uuid } from "uuid" import { v4 as uuid } from "uuid"
import { navigation } from "../../navigation"; import { navigate } from "../../navigation";
import { userStore } from "../../../common/user-store"; import { userStore } from "../../../common/user-store";
import { clusterViewURL } from "../cluster-manager/cluster-view.route";
@observer @observer
export class AddCluster extends React.Component { export class AddCluster extends React.Component {
@@ -70,8 +71,9 @@ export class AddCluster extends React.Component {
if (value instanceof KubeConfig) { if (value instanceof KubeConfig) {
const context = value.currentContext; const context = value.currentContext;
const isNew = userStore.newContexts.has(context); const isNew = userStore.newContexts.has(context);
const className = `${context} kube-context flex gaps align-center`
return ( return (
<div className="kube-context flex gaps align-center"> <div className={className}>
<span>{context}</span> <span>{context}</span>
{isNew && <Icon material="fiber_new"/>} {isNew && <Icon material="fiber_new"/>}
</div> </div>
@@ -102,7 +104,7 @@ export class AddCluster extends React.Component {
httpsProxy: proxyServer || undefined, httpsProxy: proxyServer || undefined,
}, },
}); });
navigation.goBack(); // return to previous opened page for the cluster view navigate(clusterViewURL({ params: { clusterId } }))
} catch (err) { } catch (err) {
this.error = String(err); this.error = String(err);
} finally { } finally {
@@ -165,12 +167,14 @@ export class AddCluster extends React.Component {
return ( return (
<WizardLayout className="AddCluster" infoPanel={this.renderInfo()}> <WizardLayout className="AddCluster" infoPanel={this.renderInfo()}>
<h2><Trans>Add Cluster</Trans></h2> <h2><Trans>Add Cluster</Trans></h2>
<p>Choose config:</p>
<Select <Select
placeholder={<Trans>Select kubeconfig</Trans>} placeholder={<Trans>Select kubeconfig</Trans>}
value={this.clusterConfig} value={this.clusterConfig}
options={this.clusterOptions} options={this.clusterOptions}
onChange={({ value }: SelectOption) => this.clusterConfig = value} onChange={({ value }: SelectOption) => this.clusterConfig = value}
formatOptionLabel={this.formatClusterContextLabel} formatOptionLabel={this.formatClusterContextLabel}
id="kubecontext-select"
/> />
<div className="cluster-settings"> <div className="cluster-settings">
<a href="#" onClick={() => this.showSettings = !this.showSettings}> <a href="#" onClick={() => this.showSettings = !this.showSettings}>
@@ -179,14 +183,15 @@ export class AddCluster extends React.Component {
</div> </div>
{this.showSettings && ( {this.showSettings && (
<div className="proxy-settings"> <div className="proxy-settings">
<p>HTTP Proxy server. Used for communicating with Kubernetes API.</p>
<Input <Input
autoFocus autoFocus
placeholder={_i18n._(t`A HTTP proxy server URL (format: http://<address>:<port>)`)}
value={this.proxyServer} value={this.proxyServer}
onChange={value => this.proxyServer = value} onChange={value => this.proxyServer = value}
theme="round-black"
/> />
<small className="hint"> <small className="hint">
<Trans>HTTP Proxy server. Used for communicating with Kubernetes API.</Trans> {'A HTTP proxy server URL (format: http://<address>:<port>).'}
</small> </small>
</div> </div>
)} )}
@@ -195,6 +200,7 @@ export class AddCluster extends React.Component {
<p>Kubeconfig:</p> <p>Kubeconfig:</p>
<AceEditor <AceEditor
autoFocus autoFocus
showGutter={false}
mode="yaml" mode="yaml"
value={this.customConfig} value={this.customConfig}
onChange={value => this.customConfig = value} onChange={value => this.customConfig = value}
@@ -207,7 +213,7 @@ export class AddCluster extends React.Component {
<div className="actions-panel"> <div className="actions-panel">
<Button <Button
primary primary
label={<Trans>Add cluster</Trans>} label={<Trans>Add cluster(s)</Trans>}
onClick={this.addCluster} onClick={this.addCluster}
waiting={this.isWaiting} waiting={this.isWaiting}
/> />
@@ -58,7 +58,7 @@ export class ReleaseStore extends ItemStore<HelmRelease> {
this.isLoading = true; this.isLoading = true;
let items; let items;
try { try {
const { isAdmin, allowedNamespaces } = getHostedCluster(); const { isAdmin, allowedNamespaces } = getHostedCluster()
items = await this.loadItems(!isAdmin ? allowedNamespaces : null); items = await this.loadItems(!isAdmin ? allowedNamespaces : null);
} finally { } finally {
if (items) { if (items) {
@@ -1,8 +1,12 @@
import type { IClusterViewRouteParams } from "../cluster-manager/cluster-view.route";
import { RouteProps } from "react-router"; import { RouteProps } from "react-router";
import { buildURL } from "../../navigation"; import { buildURL } from "../../navigation";
export const clusterSettingsRoute: RouteProps = { export interface IClusterSettingsRouteParams extends IClusterViewRouteParams {
path: "/cluster-settings"
} }
export const clusterSettingsURL = buildURL(clusterSettingsRoute.path) export const clusterSettingsRoute: RouteProps = {
path: `/cluster/:clusterId/settings`,
}
export const clusterSettingsURL = buildURL<IClusterSettingsRouteParams>(clusterSettingsRoute.path)
@@ -1,38 +1,25 @@
.ClusterSettings { .ClusterSettings {
grid-template-columns: unset; .WizardLayout {
padding: 0; grid-template-columns: unset;
grid-template-rows: 76px 1fr;
padding: 0;
.head-col { .head-col {
justify-content: space-between; justify-content: space-between;
:nth-child(2) { :nth-child(2) {
flex: 1 0 0; flex: 1 0 0;
}
} }
a { .content-col {
text-decoration: none; margin: 0;
color: $grey-600; padding-top: $padding * 3;
} background-color: transparent;
}
.info-col { .SubTitle {
display: none; text-transform: 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;
> div { > div {
margin-top: $margin * 5; margin-top: $margin * 5;
@@ -47,49 +34,50 @@
.button-area { .button-area {
margin-top: $margin * 2; 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 { .status-table {
margin-top: $margin * 2; margin: $margin * 3 0;
}
.hint { .Table {
font-size: smaller; border: 1px solid var(--drawerSubtitleBackground);
opacity: 0.8; border-radius: $radius;
}
p + p, .hint + p { .TableRow {
padding-top: $padding; &:not(:last-of-type) {
} border-bottom: 1px solid var(--drawerSubtitleBackground);
} }
.status-table { .value {
margin: $margin * 3 0; flex-grow: 2;
word-break: break-word;
.Table { color: var(--textColorSecondary);
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);
} }
} }
} }
}
.Input, .Select { .Input, .Select {
margin-top: 10px; margin-top: 10px;
} }
.Select { .Select {
&__control { &__control {
box-shadow: 0 0 0 1px $borderFaintColor; box-shadow: 0 0 0 1px $borderFaintColor;
}
} }
} }
} }
@@ -7,15 +7,17 @@ import { Features } from "./features";
import { Removal } from "./removal"; import { Removal } from "./removal";
import { Status } from "./status"; import { Status } from "./status";
import { General } from "./general"; import { General } from "./general";
import { getHostedCluster } from "../../../common/cluster-store";
import { WizardLayout } from "../layout/wizard-layout"; import { WizardLayout } from "../layout/wizard-layout";
import { ClusterIcon } from "../cluster-icon"; import { ClusterIcon } from "../cluster-icon";
import { Icon } from "../icon"; import { Icon } from "../icon";
import { getMatchedCluster } from "../cluster-manager/cluster-view.route";
import { navigate } from "../../navigation";
@observer @observer
export class ClusterSettings extends React.Component { export class ClusterSettings extends React.Component {
render() { render() {
const cluster = getHostedCluster(); const cluster = getMatchedCluster();
if (!cluster) return null;
const header = ( const header = (
<> <>
<ClusterIcon <ClusterIcon
@@ -24,20 +26,18 @@ export class ClusterSettings extends React.Component {
showTooltip={false} showTooltip={false}
/> />
<h2>{cluster.preferences.clusterName}</h2> <h2>{cluster.preferences.clusterName}</h2>
<Link to="/"> <Icon material="close" onClick={() => navigate("/")} big/>
<Icon material="close" big />
</Link>
</> </>
); );
return ( return (
<WizardLayout header={header} className="ClusterSettings"> <div className="ClusterSettings">
<div className="settings-wrapper"> <WizardLayout header={header} centered>
<Status cluster={cluster}></Status> <Status cluster={cluster}></Status>
<General cluster={cluster}></General> <General cluster={cluster}></General>
<Features cluster={cluster}></Features> <Features cluster={cluster}></Features>
<Removal cluster={cluster}></Removal> <Removal cluster={cluster}></Removal>
</div> </WizardLayout>
</WizardLayout> </div>
); );
} }
} }
@@ -11,7 +11,7 @@ export class Status extends React.Component<Props> {
renderStatusRows() { renderStatusRows() {
const { cluster } = this.props; const { cluster } = this.props;
const rows = [ 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], ["Distribution", cluster.distribution],
["Kerbel Version", cluster.version], ["Kerbel Version", cluster.version],
["API Address", cluster.apiUrl], ["API Address", cluster.apiUrl],
+1 -1
View File
@@ -1,2 +1,2 @@
export * from "./cluster.routes" export * from "./cluster.route"
@@ -1,16 +1,18 @@
import "./landing-page.scss" import "./landing-page.scss"
import React from "react"; import React from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { clusterStore } from "../../../common/cluster-store";
import { Trans } from "@lingui/macro"; import { Trans } from "@lingui/macro";
import { clusterStore } from "../../../common/cluster-store";
import { workspaceStore } from "../../../common/workspace-store";
@observer @observer
export class LandingPage extends React.Component { export class LandingPage extends React.Component {
render() { render() {
const noClusters = !clusterStore.hasClusters(); const clusters = clusterStore.getByWorkspaceId(workspaceStore.currentWorkspaceId);
const noClustersInScope = !clusters.length;
return ( return (
<div className="LandingPage flex"> <div className="LandingPage flex">
{noClusters && ( {noClustersInScope && (
<div className="no-clusters flex column gaps box center"> <div className="no-clusters flex column gaps box center">
<h1> <h1>
<Trans>Welcome!</Trans> <Trans>Welcome!</Trans>
@@ -1,23 +1,51 @@
.Preferences { .Preferences {
h2 { position: fixed!important; // Allows to cover ClustersMenu
&:not(:first-child) { z-index: 1;
margin-top: $padding * 3;
.WizardLayout {
grid-template-columns: unset;
grid-template-rows: 76px 1fr;
padding: 0;
.content-col {
background-color: transparent;
padding: $padding * 8 0;
h2 {
margin-bottom: $margin * 2;
&:not(:first-child) {
margin-top: $margin * 3;
}
}
.repos {
position: relative;
.Badge {
display: flex;
margin: 0;
margin-bottom: 1px;
padding: $padding $padding * 2;
}
}
.hint {
margin-top: -$margin;
}
} }
} }
.info-block { .is-mac & {
--flex-gap: #{$padding}; .WizardLayout .head-col {
padding-top: 32px;
overflow: hidden;
}
} }
.repos { .Select {
--flex-gap: #{$padding}; &__control {
box-shadow: 0 0 0 1px $borderFaintColor;
> .title {
font-style: italic;
}
.Badge {
margin: $padding / 2;
} }
} }
} }
@@ -1,5 +1,5 @@
import "./preferences.scss" import "./preferences.scss"
import React, { Fragment } from "react"; import React from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { action, computed, observable } from "mobx"; import { action, computed, observable } from "mobx";
import { t, Trans } from "@lingui/macro"; import { t, Trans } from "@lingui/macro";
@@ -15,11 +15,12 @@ import { Notifications } from "../notifications";
import { Badge } from "../badge"; import { Badge } from "../badge";
import { Spinner } from "../spinner"; import { Spinner } from "../spinner";
import { themeStore } from "../../theme.store"; import { themeStore } from "../../theme.store";
import { history } from "../../navigation";
import { Tooltip } from "../tooltip";
@observer @observer
export class Preferences extends React.Component { export class Preferences extends React.Component {
@observable helmLoading = false; @observable helmLoading = false;
@observable helmUpdating = false;
@observable helmRepos: HelmRepo[] = []; @observable helmRepos: HelmRepo[] = [];
@observable helmAddedRepos = observable.map<string, HelmRepo>(); @observable helmAddedRepos = observable.map<string, HelmRepo>();
@@ -88,9 +89,9 @@ export class Preferences extends React.Component {
Notifications.ok(<Trans>Helm branch <b>{repo.name}</b> already in use</Trans>) Notifications.ok(<Trans>Helm branch <b>{repo.name}</b> already in use</Trans>)
return; return;
} }
this.helmUpdating = false; this.helmLoading = true;
await this.addRepo(repo); await this.addRepo(repo);
this.helmUpdating = false; this.helmLoading = false;
} }
formatHelmOptionLabel = ({ value: repo }: SelectOption<HelmRepo>) => { formatHelmOptionLabel = ({ value: repo }: SelectOption<HelmRepo>) => {
@@ -103,104 +104,95 @@ export class Preferences extends React.Component {
) )
} }
renderInfo() {
return (
<Fragment>
<h2>
<Trans>Preferences</Trans>
</h2>
<div className="info-block flex gaps align-flex-start">
<Icon small material="info"/>
<p>
<Trans>Lens Global Settings</Trans> (<Trans>applicable to all clusters</Trans>)
</p>
</div>
</Fragment>
)
}
render() { render() {
const { preferences } = userStore; const { preferences } = userStore;
const header = (
<>
<h2>Preferences</h2>
<Icon material="close" big onClick={history.goBack}/>
</>
);
return ( return (
<WizardLayout className="Preferences" infoPanel={this.renderInfo()}> <div className="Preferences">
<h2><Trans>Color Theme</Trans></h2> <WizardLayout header={header} centered>
<Select <h2><Trans>Color Theme</Trans></h2>
options={this.themeOptions} <Select
value={preferences.colorTheme} options={this.themeOptions}
onChange={({ value }: SelectOption) => preferences.colorTheme = value} value={preferences.colorTheme}
/> onChange={({ value }: SelectOption) => preferences.colorTheme = value}
/>
<h2><Trans>Download Mirror</Trans></h2> <h2><Trans>Download Mirror</Trans></h2>
<Select <Select
placeholder={<Trans>Download mirror for kubectl</Trans>} placeholder={<Trans>Download mirror for kubectl</Trans>}
options={this.downloadMirrorOptions} options={this.downloadMirrorOptions}
value={preferences.downloadMirror} value={preferences.downloadMirror}
onChange={({ value }: SelectOption) => preferences.downloadMirror = value} onChange={({ value }: SelectOption) => preferences.downloadMirror = value}
/> />
<h2><Trans>Helm</Trans></h2> <h2><Trans>Helm</Trans></h2>
<Select <Select
placeholder={<Trans>Repositories</Trans>} placeholder={<Trans>Repositories</Trans>}
isLoading={this.helmLoading} isLoading={this.helmLoading}
isDisabled={this.helmUpdating} isDisabled={this.helmLoading}
options={this.helmOptions} options={this.helmOptions}
onChange={this.onRepoSelect} onChange={this.onRepoSelect}
formatOptionLabel={this.formatHelmOptionLabel} formatOptionLabel={this.formatHelmOptionLabel}
controlShouldRenderValue={false} controlShouldRenderValue={false}
/> />
<div className="repos flex gaps align-center"> <div className="repos flex gaps column">
<div className="title">
<Trans>Added repos:</Trans>
</div>
<div className="repos-list">
{this.helmLoading && <Spinner/>}
{Array.from(this.helmAddedRepos).map(([name, repo]) => { {Array.from(this.helmAddedRepos).map(([name, repo]) => {
const tooltipId = `message-${name}`;
return ( return (
<Badge key={name} className="added-repo flex gaps align-center" title={repo.url}> <Badge key={name} className="added-repo flex gaps align-center justify-space-between">
<span className="repo">{name}</span> <span id={tooltipId} className="repo">{name}</span>
<Icon <Icon
material="remove_circle_outline" material="delete"
onClick={() => this.removeRepo(repo)} onClick={() => this.removeRepo(repo)}
tooltip={<Trans>Remove</Trans>} tooltip={<Trans>Remove</Trans>}
/> />
<Tooltip targetId={tooltipId} formatters={{ narrow: true }}>
{repo.url}
</Tooltip>
</Badge> </Badge>
) )
})} })}
</div> </div>
</div>
<h2><Trans>HTTP Proxy</Trans></h2> <h2><Trans>HTTP Proxy</Trans></h2>
<Input <Input
placeholder={_i18n._(t`Type HTTP proxy url (example: http://proxy.acme.org:8080)`)} theme="round-black"
value={preferences.httpsProxy || ""} placeholder={_i18n._(t`Type HTTP proxy url (example: http://proxy.acme.org:8080)`)}
onChange={v => preferences.httpsProxy = v} value={preferences.httpsProxy || ""}
/> onChange={v => preferences.httpsProxy = v}
<small className="hint"> />
<Trans>Proxy is used only for non-cluster communication.</Trans> <small className="hint">
</small> <Trans>Proxy is used only for non-cluster communication.</Trans>
</small>
<h2><Trans>Certificate Trust</Trans></h2> <h2><Trans>Certificate Trust</Trans></h2>
<Checkbox <Checkbox
label={<Trans>Allow untrusted Certificate Authorities</Trans>} label={<Trans>Allow untrusted Certificate Authorities</Trans>}
value={preferences.allowUntrustedCAs} value={preferences.allowUntrustedCAs}
onChange={v => preferences.allowUntrustedCAs = v} onChange={v => preferences.allowUntrustedCAs = v}
/> />
<small className="hint"> <small className="hint">
<Trans>This will make Lens to trust ANY certificate authority without any validations.</Trans>{" "} <Trans>This will make Lens to trust ANY certificate authority without any validations.</Trans>{" "}
<Trans>Needed with some corporate proxies that do certificate re-writing.</Trans>{" "} <Trans>Needed with some corporate proxies that do certificate re-writing.</Trans>{" "}
<Trans>Does not affect cluster communications!</Trans> <Trans>Does not affect cluster communications!</Trans>
</small> </small>
<h2><Trans>Telemetry & Usage Tracking</Trans></h2> <h2><Trans>Telemetry & Usage Tracking</Trans></h2>
<Checkbox <Checkbox
label={<Trans>Allow telemetry & usage tracking</Trans>} label={<Trans>Allow telemetry & usage tracking</Trans>}
value={preferences.allowTelemetry} value={preferences.allowTelemetry}
onChange={v => preferences.allowTelemetry = v} onChange={v => preferences.allowTelemetry = v}
/> />
<small className="hint"> <small className="hint">
<Trans>Telemetry & usage data is collected to continuously improve the Lens experience.</Trans> <Trans>Telemetry & usage data is collected to continuously improve the Lens experience.</Trans>
</small> </small>
</WizardLayout> </WizardLayout>
) </div>
);
} }
} }
@@ -5,7 +5,7 @@ import { observer } from "mobx-react";
import { Trans } from "@lingui/macro"; import { Trans } from "@lingui/macro";
import { RouteComponentProps } from "react-router"; import { RouteComponentProps } from "react-router";
import { Icon } from "../icon"; 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 { KubeObjectMenu, KubeObjectMenuProps } from "../kube-object/kube-object-menu";
import { clusterRoleBindingApi, RoleBinding, roleBindingApi } from "../../api/endpoints"; import { clusterRoleBindingApi, RoleBinding, roleBindingApi } from "../../api/endpoints";
import { roleBindingsStore } from "./role-bindings.store"; import { roleBindingsStore } from "./role-bindings.store";
@@ -4,7 +4,7 @@ import React from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { Trans } from "@lingui/macro"; import { Trans } from "@lingui/macro";
import { RouteComponentProps } from "react-router"; 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 { KubeObjectMenu, KubeObjectMenuProps } from "../kube-object/kube-object-menu";
import { rolesStore } from "./roles.store"; import { rolesStore } from "./roles.store";
import { clusterRoleApi, Role, roleApi } from "../../api/endpoints"; import { clusterRoleApi, Role, roleApi } from "../../api/endpoints";
@@ -1,2 +1,2 @@
export * from "./user-management" export * from "./user-management"
export * from "./user-management.routes" export * from "./user-management.route"
@@ -8,7 +8,7 @@ import { MainLayout, TabRoute } from "../layout/main-layout";
import { Roles } from "../+user-management-roles"; import { Roles } from "../+user-management-roles";
import { RoleBindings } from "../+user-management-roles-bindings"; import { RoleBindings } from "../+user-management-roles-bindings";
import { ServiceAccounts } from "../+user-management-service-accounts"; 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 { namespaceStore } from "../+namespaces/namespace.store";
import { PodSecurityPolicies, podSecurityPoliciesRoute, podSecurityPoliciesURL } from "../+pod-security-policies"; import { PodSecurityPolicies, podSecurityPoliciesRoute, podSecurityPoliciesURL } from "../+pod-security-policies";
import { isAllowedResource } from "../../../common/rbac"; import { isAllowedResource } from "../../../common/rbac";
@@ -7,12 +7,11 @@ import { userStore } from "../../../common/user-store"
import { navigate } from "../../navigation"; import { navigate } from "../../navigation";
import { Button } from "../button"; import { Button } from "../button";
import { Trans } from "@lingui/macro"; import { Trans } from "@lingui/macro";
import { staticDir } from "../../../common/vars";
import marked from "marked" import marked from "marked"
@observer @observer
export class WhatsNew extends React.Component { export class WhatsNew extends React.Component {
releaseNotes = fs.readFileSync(path.join(staticDir, "RELEASE_NOTES.md")).toString(); releaseNotes = fs.readFileSync(path.join(__static, "RELEASE_NOTES.md")).toString();
ok = () => { ok = () => {
navigate("/"); navigate("/");
@@ -13,7 +13,6 @@ interface Props {
export class AppInit extends React.Component<Props> { export class AppInit extends React.Component<Props> {
static async start(rootElem: HTMLElement) { static async start(rootElem: HTMLElement) {
render(<AppInit/>, rootElem); // show loading indicator asap render(<AppInit/>, rootElem); // show loading indicator asap
await AppInit.readyStateCheck(rootElem); // wait while all good to run await AppInit.readyStateCheck(rootElem); // wait while all good to run
} }
+47 -80
View File
@@ -1,13 +1,14 @@
import "./app.scss";
import React from "react"; import React from "react";
import { disposeOnUnmount, observer } from "mobx-react"; import { observer } from "mobx-react";
import { observable, reaction } from "mobx"; import { Redirect, Route, Router, Switch } from "react-router";
import { Redirect, Route, Switch } from "react-router"; import { I18nProvider } from "@lingui/react";
import { _i18n } from "../i18n";
import { history } from "../navigation";
import { Notifications } from "./notifications"; import { Notifications } from "./notifications";
import { NotFound } from "./+404"; import { NotFound } from "./+404";
import { UserManagement } from "./+user-management/user-management"; import { UserManagement } from "./+user-management/user-management";
import { ConfirmDialog } from "./confirm-dialog"; 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 { clusterRoute, clusterURL } from "./+cluster";
import { KubeConfigDialog } from "./kubeconfig-dialog/kubeconfig-dialog"; import { KubeConfigDialog } from "./kubeconfig-dialog/kubeconfig-dialog";
import { Nodes, nodesRoute } from "./+nodes"; import { Nodes, nodesRoute } from "./+nodes";
@@ -27,94 +28,60 @@ import { DeploymentScaleDialog } from "./+workloads-deployments/deployment-scale
import { CustomResources } from "./+custom-resources/custom-resources"; import { CustomResources } from "./+custom-resources/custom-resources";
import { crdRoute } from "./+custom-resources"; import { crdRoute } from "./+custom-resources";
import { isAllowedResource } from "../../common/rbac"; import { isAllowedResource } from "../../common/rbac";
import { 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 { 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 { clusterIpc } from "../../common/cluster-ipc";
import { getHostedCluster } from "../../common/cluster-store"; import { webFrame } from "electron";
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";
@observer @observer
export class App extends React.Component { export class App extends React.Component {
@observable isReady = false; static async init() {
const clusterId = getHostedClusterId();
get cluster() { logger.info(`[APP]: Init dashboard, clusterId=${clusterId}`)
return getHostedCluster() await Terminal.preloadFonts()
} await clusterIpc.init.invokeFromRenderer(clusterId, webFrame.routingId);
await getHostedCluster().whenInitialized;
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);
}
} }
get startURL() { get startURL() {
if (this.cluster) { if (isAllowedResource(["events", "nodes", "pods"])) {
if (!this.cluster.accessible) { return clusterURL();
return clusterStatusURL();
}
if (isAllowedResource(["events", "nodes", "pods"])) {
return clusterURL();
}
return workloadsURL();
} }
return landingURL(); return workloadsURL();
} }
render() { render() {
if (!this.isReady) {
return <CubeSpinner className="box center"/>
}
return ( return (
<ErrorBoundary> <I18nProvider i18n={_i18n}>
<Switch> <Router history={history}>
<Route component={LandingPage} {...landingRoute}/> <ErrorBoundary>
<Route component={Preferences} {...preferencesRoute}/> <Switch>
<Route component={Workspaces} {...workspacesRoute}/> <Route component={Cluster} {...clusterRoute}/>
<Route component={AddCluster} {...addClusterRoute}/> <Route component={Nodes} {...nodesRoute}/>
<Route component={Cluster} {...clusterRoute}/> <Route component={Workloads} {...workloadsRoute}/>
<Route component={ClusterStatus} {...clusterStatusRoute}/> <Route component={Config} {...configRoute}/>
<Route component={ClusterSettings} {...clusterSettingsRoute}/> <Route component={Network} {...networkRoute}/>
<Route component={Nodes} {...nodesRoute}/> <Route component={Storage} {...storageRoute}/>
<Route component={Workloads} {...workloadsRoute}/> <Route component={Namespaces} {...namespacesRoute}/>
<Route component={Config} {...configRoute}/> <Route component={Events} {...eventRoute}/>
<Route component={Network} {...networkRoute}/> <Route component={CustomResources} {...crdRoute}/>
<Route component={Storage} {...storageRoute}/> <Route component={UserManagement} {...usersManagementRoute}/>
<Route component={Namespaces} {...namespacesRoute}/> <Route component={Apps} {...appsRoute}/>
<Route component={Events} {...eventRoute}/> <Redirect exact from="/" to={this.startURL}/>
<Route component={CustomResources} {...crdRoute}/> <Route component={NotFound}/>
<Route component={UserManagement} {...usersManagementRoute}/> </Switch>
<Route component={Apps} {...appsRoute}/> <Notifications/>
<Redirect exact from="/" to={this.startURL}/> <ConfirmDialog/>
<Route component={NotFound}/> <KubeObjectDetails/>
</Switch> <KubeConfigDialog/>
<KubeObjectDetails/> <AddRoleBindingDialog/>
<Notifications/> <PodLogsDialog/>
<ConfirmDialog/> <DeploymentScaleDialog/>
<KubeConfigDialog/> </ErrorBoundary>
<AddRoleBindingDialog/> </Router>
<PodLogsDialog/> </I18nProvider>
<DeploymentScaleDialog/>
</ErrorBoundary>
) )
} }
} }
@@ -1,20 +1,27 @@
.ClusterManager { .ClusterManager {
display: grid; display: grid;
grid-template-areas: "menu lens-view" "menu lens-view" "bottom-bar bottom-bar"; grid-template-areas: "menu main" "menu main" "bottom-bar bottom-bar";
grid-template-rows: auto 1fr min-content; grid-template-rows: auto 1fr min-content;
grid-template-columns: min-content 1fr; grid-template-columns: min-content 1fr;
height: 100%; height: 100%;
#lens-view { main {
grid-area: main;
position: relative; position: relative;
grid-area: lens-view; display: flex;
overflow: hidden;
&.inactive { > * {
opacity: .85; position: absolute;
filter: grayscale(1); left: 0;
user-select: none; top: 0;
pointer-events: none; right: 0;
bottom: 0;
display: flex;
background-color: $mainBackground;
> * {
flex: 1;
}
} }
} }
@@ -1,52 +1,70 @@
import "./cluster-manager.scss" import "./cluster-manager.scss"
import React from "react"; import React from "react";
import { computed } from "mobx"; import { Redirect, Route, Switch } from "react-router";
import { observer } from "mobx-react"; import { reaction } from "mobx";
import { App } from "../app"; import { disposeOnUnmount, observer } from "mobx-react";
import { ClustersMenu } from "./clusters-menu"; import { ClustersMenu } from "./clusters-menu";
import { BottomBar } from "./bottom-bar"; import { BottomBar } from "./bottom-bar";
import { cssNames, IClassName } from "../../utils"; import { LandingPage, landingRoute, landingURL } from "../+landing-page";
import { Terminal } from "../dock/terminal"; import { Preferences, preferencesRoute } from "../+preferences";
import { i18nStore } from "../../i18n"; import { Workspaces, workspacesRoute } from "../+workspaces";
import { themeStore } from "../../theme.store"; import { AddCluster, addClusterRoute } from "../+add-cluster";
import { clusterStore, getHostedClusterId, isNoClustersView } from "../../../common/cluster-store"; import { ClusterView } from "./cluster-view";
import { CubeSpinner } from "../spinner"; import { ClusterSettings, clusterSettingsRoute } from "../+cluster-settings";
import { clusterViewRoute, clusterViewURL, getMatchedCluster, getMatchedClusterId } from "./cluster-view.route";
interface Props { import { clusterStore } from "../../../common/cluster-store";
className?: IClassName; import { hasLoadedView, initView, lensViews, refreshViews } from "./lens-views";
contentClass?: IClassName;
}
@observer @observer
export class ClusterManager extends React.Component<Props> { export class ClusterManager extends React.Component {
static async init() { componentDidMount() {
await Promise.all([ disposeOnUnmount(this, [
i18nStore.init(), reaction(getMatchedClusterId, initView, {
themeStore.init(), fireImmediately: true
Terminal.preloadFonts(), }),
reaction(() => [
hasLoadedView(getMatchedClusterId()), // refresh when cluster's webview loaded
getMatchedCluster()?.available, // refresh on disconnect active-cluster
], refreshViews, {
fireImmediately: true
})
]) ])
} }
@computed get isInactive() { componentWillUnmount() {
const { activeCluster, activeClusterId, clusters } = clusterStore; lensViews.clear();
const isActivatedBefore = activeCluster?.initialized; }
return clusters.size > 0 && !isActivatedBefore && activeClusterId !== getHostedClusterId();
get startUrl() {
const { activeClusterId } = clusterStore;
if (activeClusterId) {
return clusterViewURL({
params: {
clusterId: activeClusterId
}
})
}
return landingURL()
} }
render() { render() {
const { className, contentClass } = this.props;
const lensViewClass = cssNames("flex column", contentClass, {
inactive: this.isInactive,
});
return ( return (
<div className={cssNames("ClusterManager", className)}> <div className="ClusterManager">
<div id="draggable-top"/> <div id="draggable-top"/>
<div id="lens-view" className={lensViewClass}> <main>
<App/> <div id="lens-views"/>
</div> <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/> <ClustersMenu/>
<BottomBar/> <BottomBar/>
{this.isInactive && <CubeSpinner center/>}
</div> </div>
) )
} }
@@ -1,8 +0,0 @@
import { RouteProps } from "react-router";
import { buildURL } from "../../navigation";
export const clusterStatusRoute: RouteProps = {
path: "/cluster-status"
}
export const clusterStatusURL = buildURL(clusterStatusRoute.path)
@@ -1,14 +1,17 @@
.ClusterStatus { .ClusterStatus {
--flex-gap: #{$padding * 2}; --flex-gap: #{$padding * 2};
position: relative;
min-width: 350px; min-width: 350px;
margin: auto; margin: auto;
text-align: center; text-align: center;
z-index: 1;
pre { pre {
@include hidden-scrollbar; @include hidden-scrollbar;
max-width: 70vw; max-width: 70vw;
max-height: 40vh; max-height: 40vh;
white-space: pre-line;
} }
.Icon { .Icon {
@@ -2,93 +2,107 @@ import type { KubeAuthProxyLog } from "../../../main/kube-auth-proxy";
import "./cluster-status.scss" import "./cluster-status.scss"
import React from "react"; import React from "react";
import { disposeOnUnmount, observer } from "mobx-react"; import { observer } from "mobx-react";
import { ipcRenderer } from "electron"; import { ipcRenderer } from "electron";
import { autorun, computed, observable } from "mobx"; import { computed, observable } from "mobx";
import { clusterIpc } from "../../../common/cluster-ipc"; import { clusterIpc } from "../../../common/cluster-ipc";
import { getHostedCluster } from "../../../common/cluster-store";
import { Icon } from "../icon"; import { Icon } from "../icon";
import { Button } from "../button"; import { Button } from "../button";
import { cssNames } from "../../utils"; import { cssNames, IClassName } from "../../utils";
import { navigate } from "../../navigation"; import { Cluster } from "../../../main/cluster";
import { ClusterId, clusterStore } from "../../../common/cluster-store";
import { CubeSpinner } from "../spinner";
interface Props {
className?: IClassName;
clusterId: ClusterId;
}
@observer @observer
export class ClusterStatus extends React.Component { export class ClusterStatus extends React.Component<Props> {
@observable authOutput: KubeAuthProxyLog[] = []; @observable authOutput: KubeAuthProxyLog[] = [];
@observable isReconnecting = false; @observable isReconnecting = false;
@computed get cluster() { get cluster(): Cluster {
return getHostedCluster(); return clusterStore.getById(this.props.clusterId);
} }
@computed get hasErrors(): boolean { @computed get hasErrors(): boolean {
return this.authOutput.some(({ error }) => error) || !!this.cluster.failureReason; return this.authOutput.some(({ error }) => error) || !!this.cluster.failureReason;
} }
@disposeOnUnmount
autoRedirectToMain = autorun(() => {
if (this.cluster.accessible && !this.hasErrors) {
navigate("/");
}
})
async componentDidMount() { async componentDidMount() {
this.authOutput = [{ data: "Connecting..." }];
ipcRenderer.on(`kube-auth:${this.cluster.id}`, (evt, res: KubeAuthProxyLog) => { ipcRenderer.on(`kube-auth:${this.cluster.id}`, (evt, res: KubeAuthProxyLog) => {
this.authOutput.push({ this.authOutput.push({
data: res.data.trimRight(), data: res.data.trimRight(),
error: res.error, error: res.error,
}); });
}) })
if (!this.cluster.initialized || this.cluster.disconnected) {
await this.refreshCluster();
}
} }
componentWillUnmount() { componentWillUnmount() {
ipcRenderer.removeAllListeners(`kube-auth:${this.cluster.id}`); ipcRenderer.removeAllListeners(`kube-auth:${this.props.clusterId}`);
}
refreshCluster = async () => {
await clusterIpc.activate.invokeFromRenderer(this.props.clusterId);
} }
reconnect = async () => { reconnect = async () => {
this.authOutput = [{ data: "Reconnecting..." }];
this.isReconnecting = true; this.isReconnecting = true;
await clusterIpc.activate.invokeFromRenderer(); await this.refreshCluster();
this.isReconnecting = false; this.isReconnecting = false;
} }
render() { renderContent() {
const { authOutput, cluster, hasErrors } = this; const { authOutput, cluster, hasErrors } = this;
const isDisconnected = !!cluster.disconnected;
const failureReason = cluster.failureReason; const failureReason = cluster.failureReason;
const isError = hasErrors || isDisconnected; if (!hasErrors || this.isReconnecting) {
return ( return (
<div className="ClusterStatus flex column gaps"> <>
{isError && ( <CubeSpinner />
<Icon
material="cloud_off"
className={cssNames({ error: hasErrors })}
/>
)}
<h2>
{cluster.contextName}
</h2>
{!isDisconnected && (
<pre className="kube-auth-out"> <pre className="kube-auth-out">
<p>{this.isReconnecting ? "Reconnecting..." : "Connecting..."}</p>
{authOutput.map(({ data, error }, index) => { {authOutput.map(({ data, error }, index) => {
return <p key={index} className={cssNames({ error })}>{data}</p> return <p key={index} className={cssNames({ error })}>{data}</p>
})} })}
</pre> </pre>
)} </>
);
}
return (
<>
<Icon material="cloud_off" className="error" />
<h2>
{cluster.preferences.clusterName}
</h2>
<pre className="kube-auth-out">
{authOutput.map(({ data, error }, index) => {
return <p key={index} className={cssNames({ error })}>{data}</p>
})}
</pre>
{failureReason && ( {failureReason && (
<div className="failure-reason error">{failureReason}</div> <div className="failure-reason error">{failureReason}</div>
)} )}
{isError && ( <Button
<Button primary
primary label="Reconnect"
label="Reconnect" className="box center"
className="box center" onClick={this.reconnect}
onClick={this.reconnect} waiting={this.isReconnecting}
waiting={this.isReconnecting} />
/> </>
)} );
}
render() {
return (
<div className={cssNames("ClusterStatus flex column gaps box center align-center justify-center", this.props.className)}>
{this.renderContent()}
</div> </div>
) );
} }
} }
@@ -0,0 +1,46 @@
import { reaction } from "mobx";
import { ipcRenderer } from "electron";
import { matchPath, RouteProps } from "react-router";
import { buildURL, navigation } from "../../navigation";
import { clusterStore, getHostedClusterId } from "../../../common/cluster-store";
import { clusterSettingsRoute } from "../+cluster-settings/cluster-settings.route";
export interface IClusterViewRouteParams {
clusterId: string;
}
export const clusterViewRoute: RouteProps = {
exact: true,
path: "/cluster/:clusterId",
}
export const clusterViewURL = buildURL<IClusterViewRouteParams>(clusterViewRoute.path)
export function getMatchedClusterId(): string {
const matched = matchPath<IClusterViewRouteParams>(navigation.location.pathname, {
exact: true,
path: [
clusterViewRoute.path,
clusterSettingsRoute.path,
].flat(),
})
if (matched) {
return matched.params.clusterId;
}
}
export function getMatchedCluster() {
return clusterStore.getById(getMatchedClusterId())
}
// Refresh global menu depending on active route's type (common/cluster view)
if (ipcRenderer) {
const isMainView = !getHostedClusterId();
if (isMainView) {
reaction(() => getMatchedClusterId(), clusterId => {
ipcRenderer.send("cluster-view:change", clusterId);
}, {
fireImmediately: true
})
}
}
@@ -0,0 +1,5 @@
.ClusterView {
&:empty {
display: none;
}
}
@@ -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>
)
}
}
@@ -42,6 +42,7 @@
> .add-cluster { > .add-cluster {
position: relative; position: relative;
margin-top: $padding; margin-top: $padding;
min-width: 43px;
.Icon { .Icon {
border-radius: $radius; border-radius: $radius;
@@ -20,7 +20,7 @@ import { landingURL } from "../+landing-page";
import { Tooltip } from "../tooltip"; import { Tooltip } from "../tooltip";
import { ConfirmDialog } from "../confirm-dialog"; import { ConfirmDialog } from "../confirm-dialog";
import { clusterIpc } from "../../../common/cluster-ipc"; import { clusterIpc } from "../../../common/cluster-ipc";
import { clusterStatusURL } from "./cluster-status.route"; import { clusterViewURL, getMatchedClusterId } from "./cluster-view.route";
// fixme: allow to rearrange clusters with drag&drop // fixme: allow to rearrange clusters with drag&drop
@@ -34,7 +34,7 @@ export class ClustersMenu extends React.Component<Props> {
showCluster = (clusterId: ClusterId) => { showCluster = (clusterId: ClusterId) => {
clusterStore.setActive(clusterId); clusterStore.setActive(clusterId);
navigate("/"); // redirect to index navigate(clusterViewURL({ params: { clusterId } }));
} }
addCluster = () => { addCluster = () => {
@@ -48,18 +48,21 @@ export class ClustersMenu extends React.Component<Props> {
menu.append(new MenuItem({ menu.append(new MenuItem({
label: _i18n._(t`Settings`), label: _i18n._(t`Settings`),
click: () => { click: () => {
clusterStore.setActive(cluster.id); navigate(clusterSettingsURL({
navigate(clusterSettingsURL()) params: {
clusterId: cluster.id
}
}))
} }
})); }));
if (cluster.online) { if (cluster.online) {
menu.append(new MenuItem({ menu.append(new MenuItem({
label: _i18n._(t`Disconnect`), label: _i18n._(t`Disconnect`),
click: async () => { click: async () => {
await clusterIpc.disconnect.invokeFromRenderer(cluster.id); if (clusterStore.isActive(cluster.id)) {
if (cluster.id === clusterStore.activeClusterId) { navigate(landingURL());
navigate(clusterStatusURL());
} }
await clusterIpc.disconnect.invokeFromRenderer(cluster.id);
} }
})) }))
} }
@@ -72,7 +75,10 @@ export class ClustersMenu extends React.Component<Props> {
accent: true, accent: true,
label: _i18n._(t`Remove`), label: _i18n._(t`Remove`),
}, },
ok: () => clusterStore.removeById(cluster.id), ok: () => {
clusterStore.removeById(cluster.id);
navigate(landingURL());
},
message: <p>Are you sure want to remove cluster <b title={cluster.id}>{cluster.contextName}</b>?</p>, message: <p>Are you sure want to remove cluster <b title={cluster.id}>{cluster.contextName}</b>?</p>,
}) })
} }
@@ -85,11 +91,10 @@ export class ClustersMenu extends React.Component<Props> {
render() { render() {
const { className } = this.props; const { className } = this.props;
const { newContexts } = userStore; const { newContexts } = userStore;
const { currentWorkspaceId } = workspaceStore; const clusters = clusterStore.getByWorkspaceId(workspaceStore.currentWorkspaceId);
const clusters = clusterStore.getByWorkspaceId(currentWorkspaceId); const noClustersInScope = clusters.length === 0;
const noClusters = !clusterStore.clusters.size;
const isLanding = navigation.getPath() === landingURL(); const isLanding = navigation.getPath() === landingURL();
const showStartupHint = this.showHint && isLanding && noClusters; const showStartupHint = this.showHint && isLanding && noClustersInScope;
return ( return (
<div <div
className={cssNames("ClustersMenu flex column gaps", className)} className={cssNames("ClustersMenu flex column gaps", className)}
@@ -111,7 +116,7 @@ export class ClustersMenu extends React.Component<Props> {
key={cluster.id} key={cluster.id}
showErrors={true} showErrors={true}
cluster={cluster} cluster={cluster}
isActive={cluster.id === clusterStore.activeClusterId} isActive={cluster.id === getMatchedClusterId()}
onClick={() => this.showCluster(cluster.id)} onClick={() => this.showCluster(cluster.id)}
onContextMenu={() => this.showContextMenu(cluster)} onContextMenu={() => this.showContextMenu(cluster)}
/> />
@@ -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"
})
}
@@ -38,7 +38,7 @@ export const isNumber: Validator = {
export const isUrl: Validator = { export const isUrl: Validator = {
condition: ({ type }) => type === "url", condition: ({ type }) => type === "url",
message: () => _i18n._(t`Wrong url format`), message: () => _i18n._(t`Wrong url format`),
validate: value => !!value.match(/^(?:http(s)?:\/\/)?[\w.-]+(?:\.[\w\.-]+)+[\w\-\._~:/?#[\]@!\$&'\(\)\*\+,;=.]+$/), validate: value => !!value.match(/^http(s)?:\/\/\w+(\.\w+)*(:[0-9]+)?\/?(\/[\w\-\._~:/?#[\]@!\$&'\(\)\*\+,;=.]*)*$/),
}; };
export const minLength: Validator = { export const minLength: Validator = {
@@ -10,8 +10,8 @@ import { Sidebar } from "./sidebar";
import { ErrorBoundary } from "../error-boundary"; import { ErrorBoundary } from "../error-boundary";
import { Dock } from "../dock"; import { Dock } from "../dock";
import { navigate, navigation } from "../../navigation"; import { navigate, navigation } from "../../navigation";
import { themeStore } from "../../theme.store";
import { getHostedCluster } from "../../../common/cluster-store"; import { getHostedCluster } from "../../../common/cluster-store";
import { themeStore } from "../../theme.store";
export interface TabRoute extends RouteProps { export interface TabRoute extends RouteProps {
title: React.ReactNode; title: React.ReactNode;
@@ -47,12 +47,14 @@ export class MainLayout extends React.Component<Props> {
render() { render() {
const { className, contentClass, headerClass, tabs, footer, footerClass, children } = this.props; const { className, contentClass, headerClass, tabs, footer, footerClass, children } = this.props;
const { contextName: clusterName } = getHostedCluster();
const routePath = navigation.location.pathname; const routePath = navigation.location.pathname;
const cluster = getHostedCluster();
return ( return (
<div className={cssNames("MainLayout", className, themeStore.activeTheme.type)}> <div className={cssNames("MainLayout", className, themeStore.activeTheme.type)}>
<header className={cssNames("flex gaps align-center", headerClass)}> <header className={cssNames("flex gaps align-center", headerClass)}>
<span className="cluster">{clusterName}</span> <span className="cluster">
{cluster.preferences?.clusterName || cluster.contextName}
</span>
</header> </header>
<aside className={cssNames("flex column", { pinned: this.isPinned, accessible: this.isAccessible })}> <aside className={cssNames("flex column", { pinned: this.isPinned, accessible: this.isAccessible })}>
+4 -2
View File
@@ -11,7 +11,7 @@ import { Icon } from "../icon";
import { workloadsRoute, workloadsURL } from "../+workloads/workloads.route"; import { workloadsRoute, workloadsURL } from "../+workloads/workloads.route";
import { namespacesURL } from "../+namespaces/namespaces.route"; import { namespacesURL } from "../+namespaces/namespaces.route";
import { nodesURL } from "../+nodes/nodes.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 { networkRoute, networkURL } from "../+network/network.route";
import { storageRoute, storageURL } from "../+storage/storage.route"; import { storageRoute, storageURL } from "../+storage/storage.route";
import { clusterURL } from "../+cluster"; import { clusterURL } from "../+cluster";
@@ -43,7 +43,9 @@ interface Props {
@observer @observer
export class Sidebar extends React.Component<Props> { export class Sidebar extends React.Component<Props> {
async componentDidMount() { async componentDidMount() {
if (!crdStore.isLoaded && isAllowedResource('customresourcedefinitions')) crdStore.loadAll() if (!crdStore.isLoaded && isAllowedResource('customresourcedefinitions')) {
crdStore.loadAll()
}
} }
renderCustomResources() { renderCustomResources() {
@@ -16,6 +16,7 @@
> .head-col { > .head-col {
position: sticky; position: sticky;
border-bottom: 1px solid $grey-800; border-bottom: 1px solid $grey-800;
justify-content: space-between;
} }
> .content-col { > .content-col {
@@ -23,6 +24,10 @@
background-color: var(--clusters-menu-bgc); background-color: var(--clusters-menu-bgc);
border-radius: $radius; border-radius: $radius;
> div {
flex: 1;
}
> .error { > .error {
border-radius: $radius; border-radius: $radius;
padding: $padding; padding: $padding;
@@ -41,4 +46,17 @@
a { a {
color: $colorInfo; color: $colorInfo;
} }
&.centered {
.content-col {
margin: 0;
> div {
margin: 0 auto;
width: 60%;
min-width: 570px;
max-width: 1000px;
}
}
}
} }
@@ -10,25 +10,30 @@ interface Props {
contentClass?: IClassName; contentClass?: IClassName;
infoPanelClass?: IClassName; infoPanelClass?: IClassName;
infoPanel?: React.ReactNode; infoPanel?: React.ReactNode;
centered?: boolean; // Centering content horizontally
} }
@observer @observer
export class WizardLayout extends React.Component<Props> { export class WizardLayout extends React.Component<Props> {
render() { render() {
const { className, contentClass, infoPanelClass, infoPanel, header, headerClass, children: content } = this.props; const { className, contentClass, infoPanelClass, infoPanel, header, headerClass, centered, children: content } = this.props;
return ( return (
<div className={cssNames("WizardLayout", className)}> <div className={cssNames("WizardLayout", { centered }, className)}>
{header && ( {header && (
<div className={cssNames("head-col flex gaps align-center", headerClass)}> <div className={cssNames("head-col flex gaps align-center", headerClass)}>
{header} {header}
</div> </div>
)} )}
<div className={cssNames("content-col flex column gaps", contentClass)}> <div className={cssNames("content-col flex column gaps", contentClass)}>
{content} <div className="flex column gaps">
</div> {content}
<div className={cssNames("info-col flex column gaps", infoPanelClass)}> </div>
{infoPanel}
</div> </div>
{infoPanel && (
<div className={cssNames("info-col flex column gaps", infoPanelClass)}>
{infoPanel}
</div>
)}
</div> </div>
) )
} }
+1 -1
View File
@@ -23,7 +23,7 @@ html {
&--is-disabled { &--is-disabled {
opacity: .75; opacity: .75;
cursor: not-allowed; cursor: not-allowed;
pointer-events: auto; pointer-events: none;
} }
&__control { &__control {
@@ -1,33 +1,19 @@
import "../common/system-ca" import "../common/system-ca"
import React from "react"; import React from "react";
import { render } from "react-dom";
import { Route, Router, Switch } from "react-router"; import { Route, Router, Switch } from "react-router";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { userStore } from "../common/user-store"; import { userStore } from "../common/user-store";
import { workspaceStore } from "../common/workspace-store";
import { clusterStore } from "../common/cluster-store";
import { I18nProvider } from "@lingui/react"; import { I18nProvider } from "@lingui/react";
import { history } from "./navigation"; import { history } from "./navigation";
import { isMac } from "../common/vars";
import { _i18n } from "./i18n"; import { _i18n } from "./i18n";
import { ClusterManager } from "./components/cluster-manager"; import { ClusterManager } from "./components/cluster-manager";
import { ErrorBoundary } from "./components/error-boundary"; import { ErrorBoundary } from "./components/error-boundary";
import { WhatsNew, whatsNewRoute } from "./components/+whats-new"; import { WhatsNew, whatsNewRoute } from "./components/+whats-new";
import { Notifications } from "./components/notifications";
import { ConfirmDialog } from "./components/confirm-dialog";
@observer @observer
class LensApp extends React.Component { export 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);
}
render() { render() {
return ( return (
<I18nProvider i18n={_i18n}> <I18nProvider i18n={_i18n}>
@@ -39,11 +25,10 @@ class LensApp extends React.Component {
<Route component={ClusterManager}/> <Route component={ClusterManager}/>
</Switch> </Switch>
</ErrorBoundary> </ErrorBoundary>
<Notifications/>
<ConfirmDialog/>
</Router> </Router>
</I18nProvider> </I18nProvider>
) )
} }
} }
// run
LensApp.init();
+10 -6
View File
@@ -2,21 +2,23 @@
import { ipcRenderer } from "electron"; import { ipcRenderer } from "electron";
import { compile } from "path-to-regexp" import { compile } from "path-to-regexp"
import { createBrowserHistory, createMemoryHistory, Location, LocationDescriptor } from "history"; import { createBrowserHistory, createMemoryHistory, LocationDescriptor } from "history";
import { createObservableHistory } from "mobx-observable-history"; import { createObservableHistory } from "mobx-observable-history";
import logger from "../main/logger";
export const history = typeof window !== "undefined" ? createBrowserHistory() : createMemoryHistory(); export const history = typeof window !== "undefined" ? createBrowserHistory() : createMemoryHistory();
export const navigation = createObservableHistory(history); export const navigation = createObservableHistory(history);
// handle navigation from other process (e.g. system menus in main, common->cluster view interactions)
if (ipcRenderer) { if (ipcRenderer) {
// subscribe for navigation via menu.ts ipcRenderer.on("menu:navigate", (event, location: LocationDescriptor) => {
ipcRenderer.on("menu:navigate", (event, path: string) => { logger.info(`[IPC]: ${event.type} ${JSON.stringify(location)}`, event);
navigate(path); navigate(location);
}); })
} }
export function navigate(location: LocationDescriptor) { export function navigate(location: LocationDescriptor) {
navigation.location = location as Location; navigation.push(location);
} }
export interface IURLParams<P = {}, Q = {}> { export interface IURLParams<P = {}, Q = {}> {
@@ -24,6 +26,8 @@ export interface IURLParams<P = {}, Q = {}> {
query?: IQueryParams & Q; query?: IQueryParams & Q;
} }
// todo: extract building urls to commons (also used in menu.ts)
// fixme: missing types validation for params & query
export function buildURL<P extends object, Q = object>(path: string | string[]) { export function buildURL<P extends object, Q = object>(path: string | string[]) {
const pathBuilder = compile(path.toString()); const pathBuilder = compile(path.toString());
return function ({ params, query }: IURLParams<P, Q> = {}) { return function ({ params, query }: IURLParams<P, Q> = {}) {
+1 -1
View File
@@ -1,5 +1,5 @@
import { computed, observable, reaction } from "mobx"; import { computed, observable, reaction } from "mobx";
import { autobind } from "./utils"; import { autobind } from "./utils/autobind";
import { userStore } from "../common/user-store"; import { userStore } from "../common/user-store";
import logger from "../main/logger"; import logger from "../main/logger";
+3
View File
@@ -4,6 +4,9 @@ declare module "win-ca"
declare module "@hapi/call" declare module "@hapi/call"
declare module "@hapi/subtext" declare module "@hapi/subtext"
// Global path to static assets
declare const __static: string;
// Support import for custom module extensions // Support import for custom module extensions
// https://www.typescriptlang.org/docs/handbook/modules.html#wildcard-module-declarations // https://www.typescriptlang.org/docs/handbook/modules.html#wildcard-module-declarations
declare module "*.scss" { declare module "*.scss" {
+2 -2
View File
@@ -1,10 +1,10 @@
import path from "path"; import path from "path";
import webpack, { LibraryTarget } from "webpack"; import webpack, { LibraryTarget } from "webpack";
import { isDevelopment, outDir } from "./src/common/vars"; import { isDevelopment, buildDir } from "./src/common/vars";
export const library = "dll" export const library = "dll"
export const libraryTarget: LibraryTarget = "commonjs2" export const libraryTarget: LibraryTarget = "commonjs2"
export const manifestPath = path.resolve(outDir, `${library}.manifest.json`); export const manifestPath = path.resolve(buildDir, `${library}.manifest.json`);
export const packages = [ export const packages = [
"react", "react-dom", "react", "react-dom",
+3 -2
View File
@@ -1,10 +1,11 @@
import path from "path"; import path from "path";
import webpack from "webpack"; import webpack from "webpack";
import ForkTsCheckerPlugin from "fork-ts-checker-webpack-plugin" import ForkTsCheckerPlugin from "fork-ts-checker-webpack-plugin"
import { isDevelopment, isProduction, mainDir, outDir } from "./src/common/vars"; import { isDevelopment, isProduction, mainDir, buildDir } from "./src/common/vars";
import nodeExternals from "webpack-node-externals"; import nodeExternals from "webpack-node-externals";
export default function (): webpack.Configuration { export default function (): webpack.Configuration {
console.info('WEBPACK:main', require("./src/common/vars"))
return { return {
context: __dirname, context: __dirname,
target: "electron-main", target: "electron-main",
@@ -15,7 +16,7 @@ export default function (): webpack.Configuration {
main: path.resolve(mainDir, "index.ts"), main: path.resolve(mainDir, "index.ts"),
}, },
output: { output: {
path: outDir, path: buildDir,
}, },
resolve: { resolve: {
extensions: ['.json', '.js', '.ts'] extensions: ['.json', '.js', '.ts']
+5 -5
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 path from "path";
import webpack from "webpack"; import webpack from "webpack";
import HtmlWebpackPlugin from "html-webpack-plugin"; import HtmlWebpackPlugin from "html-webpack-plugin";
import MiniCssExtractPlugin from "mini-css-extract-plugin"; import MiniCssExtractPlugin from "mini-css-extract-plugin";
import TerserPlugin from "terser-webpack-plugin"; import TerserPlugin from "terser-webpack-plugin";
import ForkTsCheckerPlugin from "fork-ts-checker-webpack-plugin" import ForkTsCheckerPlugin from "fork-ts-checker-webpack-plugin"
import CircularDependencyPlugin from "circular-dependency-plugin"
export default function (): webpack.Configuration { export default function (): webpack.Configuration {
console.info('WEBPACK:renderer', require("./src/common/vars"))
return { return {
context: __dirname, context: __dirname,
target: "electron-renderer", target: "electron-renderer",
@@ -15,11 +15,11 @@ export default function (): webpack.Configuration {
mode: isProduction ? "production" : "development", mode: isProduction ? "production" : "development",
cache: isDevelopment, cache: isDevelopment,
entry: { entry: {
[appName]: path.resolve(rendererDir, "index.tsx"), [appName]: path.resolve(rendererDir, "bootstrap.tsx"),
}, },
output: { output: {
publicPath: "/", publicPath: publicPath,
path: outDir, path: buildDir,
filename: '[name].js', filename: '[name].js',
chunkFilename: 'chunks/[name].js', chunkFilename: 'chunks/[name].js',
}, },
+50 -134
View File
@@ -950,7 +950,7 @@
ajv "^6.12.0" ajv "^6.12.0"
ajv-keywords "^3.4.1" ajv-keywords "^3.4.1"
"@electron/get@^1.0.1": "@electron/get@^1.0.1", "@electron/get@^1.12.2":
version "1.12.2" version "1.12.2"
resolved "https://registry.yarnpkg.com/@electron/get/-/get-1.12.2.tgz#6442066afb99be08cefb9a281e4b4692b33764f3" resolved "https://registry.yarnpkg.com/@electron/get/-/get-1.12.2.tgz#6442066afb99be08cefb9a281e4b4692b33764f3"
integrity sha512-vAuHUbfvBQpYTJ5wB7uVIDq5c/Ry0fiTBMs7lnEYAo/qXXppIVcWdfBr57u6eRnKdVso7KSiH6p/LbQAG6Izrg== integrity sha512-vAuHUbfvBQpYTJ5wB7uVIDq5c/Ry0fiTBMs7lnEYAo/qXXppIVcWdfBr57u6eRnKdVso7KSiH6p/LbQAG6Izrg==
@@ -1978,7 +1978,7 @@
dependencies: dependencies:
"@types/react" "*" "@types/react" "*"
"@types/react-dom@*", "@types/react-dom@^16.9.8": "@types/react-dom@*":
version "16.9.8" version "16.9.8"
resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-16.9.8.tgz#fe4c1e11dfc67155733dfa6aa65108b4971cb423" resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-16.9.8.tgz#fe4c1e11dfc67155733dfa6aa65108b4971cb423"
integrity sha512-ykkPQ+5nFknnlU6lDd947WbQ6TE3NNzbQAkInC2EKY1qeYdTKp7onFusmYZb+ityzx2YviqT6BXSu+LyWWJwcA== integrity sha512-ykkPQ+5nFknnlU6lDd947WbQ6TE3NNzbQAkInC2EKY1qeYdTKp7onFusmYZb+ityzx2YviqT6BXSu+LyWWJwcA==
@@ -2157,7 +2157,7 @@
resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-8.0.0.tgz#165aae4819ad2174a17476dbe66feebd549556c0" resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-8.0.0.tgz#165aae4819ad2174a17476dbe66feebd549556c0"
integrity sha512-xSQfNcvOiE5f9dyd4Kzxbof1aTrLobL278pGLKOZI6esGfZ7ts9Ka16CzIN6Y8hFHE1C7jIBZokULhK1bOgjRw== integrity sha512-xSQfNcvOiE5f9dyd4Kzxbof1aTrLobL278pGLKOZI6esGfZ7ts9Ka16CzIN6Y8hFHE1C7jIBZokULhK1bOgjRw==
"@types/webdriverio@^4.13.0": "@types/webdriverio@^4.13.0", "@types/webdriverio@^4.8.0":
version "4.13.3" version "4.13.3"
resolved "https://registry.yarnpkg.com/@types/webdriverio/-/webdriverio-4.13.3.tgz#c1571c4e62724135c0b11e7d7e36b07af5168856" resolved "https://registry.yarnpkg.com/@types/webdriverio/-/webdriverio-4.13.3.tgz#c1571c4e62724135c0b11e7d7e36b07af5168856"
integrity sha512-AfSQM1xTO9Ax+u9uSQPDuw69DQ0qA2RMoKHn86jCgWNcwKVUjGMSP4sfSl3JOfcZN8X/gWvn7znVPp2/g9zcJA== integrity sha512-AfSQM1xTO9Ax+u9uSQPDuw69DQ0qA2RMoKHn86jCgWNcwKVUjGMSP4sfSl3JOfcZN8X/gWvn7znVPp2/g9zcJA==
@@ -2216,6 +2216,13 @@
dependencies: dependencies:
"@types/yargs-parser" "*" "@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": "@typescript-eslint/eslint-plugin@^3.4.0":
version "3.4.0" version "3.4.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-3.4.0.tgz#8378062e6be8a1d049259bdbcf27ce5dfbeee62b" 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: dependencies:
ms "^2.1.1" 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" version "2.6.9"
resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f" resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f"
integrity sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA== integrity sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==
@@ -4552,28 +4559,13 @@ electron-builder@^22.7.0:
update-notifier "^4.1.0" update-notifier "^4.1.0"
yargs "^15.3.1" yargs "^15.3.1"
electron-chromedriver@^6.0.0: electron-chromedriver@^9.0.0:
version "6.0.0" version "9.0.0"
resolved "https://registry.yarnpkg.com/electron-chromedriver/-/electron-chromedriver-6.0.0.tgz#a91b940c83f1c42ced52c9ef0605d8721613a8a2" resolved "https://registry.yarnpkg.com/electron-chromedriver/-/electron-chromedriver-9.0.0.tgz#c7629fe6b9721140f3a380144f99960c2bc3b5c1"
integrity sha512-UIhRl0sN5flfUjqActXsFrZQU1NmBObvlxzPnyeud8vhR67TllXCoqfvhQJmIrJAJJK+5M1DFhJ5iTGT++dvkg== integrity sha512-+MuukzicyfduXO/4yQv9ygLKaScttJNbWtg77A9fs2YhbkISjObWaCF3eJNZL+edZXRfaF/6D4XuXvklQCmwQg==
dependencies: dependencies:
electron-download "^4.1.1" "@electron/get" "^1.12.2"
extract-zip "^1.6.7" extract-zip "^2.0.0"
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-notarize@^0.3.0: electron-notarize@^0.3.0:
version "0.3.0" version "0.3.0"
@@ -4647,10 +4639,10 @@ electron@*:
"@types/node" "^12.0.12" "@types/node" "^12.0.12"
extract-zip "^1.0.3" extract-zip "^1.0.3"
electron@^9.1.0: electron@^9.1.2:
version "9.1.0" version "9.1.2"
resolved "https://registry.yarnpkg.com/electron/-/electron-9.1.0.tgz#ca77600c9e4cd591298c340e013384114d3d8d05" resolved "https://registry.yarnpkg.com/electron/-/electron-9.1.2.tgz#bfa26d6b192ea13abb6f1461371fd731a8358988"
integrity sha512-VRAF8KX1m0py9I9sf0kw1kWfeC87mlscfFcbcRdLBsNJ44/GrJhi3+E8rKbpHUeZNQxsPaVA5Zu5Lxb6dV/scQ== integrity sha512-xEYadr3XqIqJ4ktBPo0lhzPdovv4jLCpiUUGc2M1frUhFhwqXokwhPaTUcE+zfu5+uf/ONDnQApwjzznBsRrgQ==
dependencies: dependencies:
"@electron/get" "^1.0.1" "@electron/get" "^1.0.1"
"@types/node" "^12.0.12" "@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" resolved "https://registry.yarnpkg.com/entities/-/entities-2.0.3.tgz#5c487e5742ab93c15abb5da22759b8590ec03b7f"
integrity sha512-MyoZ0jgnLvB2X3Lg5HqpFmn1kybDiIfEQmKzTb5apr51Rb+T3KdmMiqa70T+bhGnyv7bQ6WMj2QMHpGMmlrUYQ== 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: env-paths@^2.2.0:
version "2.2.0" version "2.2.0"
resolved "https://registry.yarnpkg.com/env-paths/-/env-paths-2.2.0.tgz#cdca557dc009152917d6166e2febe1f039685e43" 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" snapdragon "^0.8.1"
to-regex "^3.0.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" version "1.7.0"
resolved "https://registry.yarnpkg.com/extract-zip/-/extract-zip-1.7.0.tgz#556cc3ae9df7f452c493a0cfb51cc30277940927" resolved "https://registry.yarnpkg.com/extract-zip/-/extract-zip-1.7.0.tgz#556cc3ae9df7f452c493a0cfb51cc30277940927"
integrity sha512-xoh5G1W/PB0/27lXgMQyIhP5DSY/LhoCsOyZgb+6iMmRtCwVBo55uKaMoEYrDCKQhWvqEip5ZPKAc6eFNyf/MA== 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" mkdirp "^0.5.4"
yauzl "^2.10.0" 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: extsprintf@1.3.0:
version "1.3.0" version "1.3.0"
resolved "https://registry.yarnpkg.com/extsprintf/-/extsprintf-1.3.0.tgz#96918440e3041a7a414f8c52e3c574eb3c3e1e05" 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" resolved "https://registry.yarnpkg.com/fs-constants/-/fs-constants-1.0.0.tgz#6be0de9be998ce16af8afc24497b9ee9b7ccd9ad"
integrity sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow== 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" version "4.0.3"
resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-4.0.3.tgz#0d852122e5bc5beb453fb028e9c0c9bf36340c94" resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-4.0.3.tgz#0d852122e5bc5beb453fb028e9c0c9bf36340c94"
integrity sha512-q6rbdDd1o2mAnQreO7YADIxf/Whx4AHBiRf6d+/cVT8h44ss+lHgxf1FemcqDnQt9X3ct4McHr+JMGlYSsK7Cg== integrity sha512-q6rbdDd1o2mAnQreO7YADIxf/Whx4AHBiRf6d+/cVT8h44ss+lHgxf1FemcqDnQt9X3ct4McHr+JMGlYSsK7Cg==
@@ -7726,7 +7724,7 @@ memory-fs@^0.5.0:
errno "^0.1.3" errno "^0.1.3"
readable-stream "^2.0.1" readable-stream "^2.0.1"
meow@^3.1.0, meow@^3.7.0: meow@^3.7.0:
version "3.7.0" version "3.7.0"
resolved "https://registry.yarnpkg.com/meow/-/meow-3.7.0.tgz#72cb668b425228290abbfa856892587308a801fb" resolved "https://registry.yarnpkg.com/meow/-/meow-3.7.0.tgz#72cb668b425228290abbfa856892587308a801fb"
integrity sha1-cstmi0JSKCkKu/qFaJJYcwioAfs= integrity sha1-cstmi0JSKCkKu/qFaJJYcwioAfs=
@@ -7859,7 +7857,7 @@ minimatch@^3.0.4, minimatch@~3.0.2:
dependencies: dependencies:
brace-expansion "^1.1.7" 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" version "1.2.5"
resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.5.tgz#67d66014b66a6a8aaa0c083c5fd58df4e4e97602" resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.5.tgz#67d66014b66a6a8aaa0c083c5fd58df4e4e97602"
integrity sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw== 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" resolved "https://registry.yarnpkg.com/mobx/-/mobx-5.15.4.tgz#9da1a84e97ba624622f4e55a0bf3300fb931c2ab"
integrity sha512-xRFJxSU2Im3nrGCdjSuOTFmxVDGeqOHL+TyADCGbT0k4HHqGmx5u2yaHNryvoORpI4DfbzjJ5jPmuv+d7sioFw== 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: mock-fs@^4.12.0:
version "4.12.0" version "4.12.0"
resolved "https://registry.yarnpkg.com/mock-fs/-/mock-fs-4.12.0.tgz#a5d50b12d2d75e5bec9dac3b67ffe3c41d31ade4" resolved "https://registry.yarnpkg.com/mock-fs/-/mock-fs-4.12.0.tgz#a5d50b12d2d75e5bec9dac3b67ffe3c41d31ade4"
@@ -8361,19 +8364,6 @@ nth-check@~1.0.1:
dependencies: dependencies:
boolbase "~1.0.0" 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: number-is-nan@^1.0.0:
version "1.0.1" version "1.0.1"
resolved "https://registry.yarnpkg.com/number-is-nan/-/number-is-nan-1.0.1.tgz#097b602b53422a522c1afb8790318336941a011d" 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" resolved "https://registry.yarnpkg.com/object-keys/-/object-keys-1.1.1.tgz#1c47f272df277f3b1daf061677d9c82e2322c60e"
integrity sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA== 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: object-visit@^1.0.0:
version "1.0.1" version "1.0.1"
resolved "https://registry.yarnpkg.com/object-visit/-/object-visit-1.0.1.tgz#f79c4493af0c5377b59fe39d395e41042dd045bb" 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" resolved "https://registry.yarnpkg.com/prepend-http/-/prepend-http-2.0.0.tgz#e92434bfa5ea8c19f41cdfd401d741a3c819d897"
integrity sha1-6SQ0v6XqjBn0HN/UAddBo8gZ2Jc= 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: pretty-error@^2.1.1:
version "2.1.1" version "2.1.1"
resolved "https://registry.yarnpkg.com/pretty-error/-/pretty-error-2.1.1.tgz#5f4f87c8f91e5ae3f3ba87ab4cf5e03b1a17f1a3" 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" resolved "https://registry.yarnpkg.com/process/-/process-0.11.10.tgz#7332300e840161bda3e69a1d1d91a7d4bc16f182"
integrity sha1-czIwDoQBYb2j5podHZGn1LwW8YI= 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: progress@^2.0.0, progress@^2.0.3:
version "2.0.3" version "2.0.3"
resolved "https://registry.yarnpkg.com/progress/-/progress-2.0.3.tgz#7e8cf8d8f5b8f239c1bc68beb4eb78567d572ef8" 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" loader-utils "^2.0.0"
schema-utils "^2.6.5" schema-utils "^2.6.5"
rc@^1.2.1, rc@^1.2.8: rc@^1.2.8:
version "1.2.8" version "1.2.8"
resolved "https://registry.yarnpkg.com/rc/-/rc-1.2.8.tgz#cd924bf5200a075b83c188cd6b9e211b7fc0d3ed" resolved "https://registry.yarnpkg.com/rc/-/rc-1.2.8.tgz#cd924bf5200a075b83c188cd6b9e211b7fc0d3ed"
integrity sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw== integrity sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==
@@ -9420,7 +9389,7 @@ react-router-dom@^5.2.0:
tiny-invariant "^1.0.2" tiny-invariant "^1.0.2"
tiny-warning "^1.0.0" tiny-warning "^1.0.0"
react-router@5.2.0: react-router@5.2.0, react-router@^5.2.0:
version "5.2.0" version "5.2.0"
resolved "https://registry.yarnpkg.com/react-router/-/react-router-5.2.0.tgz#424e75641ca8747fbf76e5ecca69781aa37ea293" resolved "https://registry.yarnpkg.com/react-router/-/react-router-5.2.0.tgz#424e75641ca8747fbf76e5ecca69781aa37ea293"
integrity sha512-smz1DUuFHRKdcJC0jobGo8cVbhO3x50tCL4icacOlcwDOEQPq4TMqwx3sY1TP+DvtTgz4nm3thuo7A+BK2U0Dw== integrity sha512-smz1DUuFHRKdcJC0jobGo8cVbhO3x50tCL4icacOlcwDOEQPq4TMqwx3sY1TP+DvtTgz4nm3thuo7A+BK2U0Dw==
@@ -9560,16 +9529,6 @@ readable-stream@^3.1.1, readable-stream@^3.6.0:
string_decoder "^1.1.1" string_decoder "^1.1.1"
util-deprecate "^1.0.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: readdirp@^2.2.1:
version "2.2.1" version "2.2.1"
resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-2.2.1.tgz#0e87622a3325aa33e892285caf8b4e846529a525" 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" stealthy-require "^1.1.1"
tough-cookie "^2.3.3" 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" version "2.88.2"
resolved "https://registry.yarnpkg.com/request/-/request-2.88.2.tgz#d73c918731cb5a87da047e207234146f664d12b3" resolved "https://registry.yarnpkg.com/request/-/request-2.88.2.tgz#d73c918731cb5a87da047e207234146f664d12b3"
integrity sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw== integrity sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw==
@@ -10219,13 +10178,6 @@ simple-swizzle@^0.2.2:
dependencies: dependencies:
is-arrayish "^0.3.1" 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: sisteransi@^1.0.4:
version "1.0.5" version "1.0.5"
resolved "https://registry.yarnpkg.com/sisteransi/-/sisteransi-1.0.5.tgz#134d681297756437cc05ca01370d3a7a571075ed" 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" resolved "https://registry.yarnpkg.com/spdx-license-ids/-/spdx-license-ids-3.0.5.tgz#3694b5804567a458d3c8045842a6358632f62654"
integrity sha512-J+FWzZoynJEXGphVIS+XEh3kFSjZX/1i9gFBaWQcB+/tmpe2qUsSBABpcxqxnAxFdiUFEgAX1bjYGQvIZmoz9Q== integrity sha512-J+FWzZoynJEXGphVIS+XEh3kFSjZX/1i9gFBaWQcB+/tmpe2qUsSBABpcxqxnAxFdiUFEgAX1bjYGQvIZmoz9Q==
spectron@^8.0.0: spectron@11.0.0:
version "8.0.0" version "11.0.0"
resolved "https://registry.yarnpkg.com/spectron/-/spectron-8.0.0.tgz#86e83c5dccb174850c052e2e718d5b1158764a52" resolved "https://registry.yarnpkg.com/spectron/-/spectron-11.0.0.tgz#79d785e6b8898638e77b5186711e3910ed4ca09b"
integrity sha512-MI9+lAamDnw7S0vKaxXjU3g5qaW5KANaFLc+Hgq+QmMCkQbZLt6ukFFGfalmwIuYrmq+yWQPCD4CXgt3VSHrLA== integrity sha512-YRiB0TTpJa8ofNML/k1fJShe+m7U/E2HnFZsdZK57ekWIzlTHF+Lq7ZvuKGxMbpooU/OZkLObZfitemxhBVH4w==
dependencies: dependencies:
"@types/webdriverio" "^4.8.0"
dev-null "^0.1.1" dev-null "^0.1.1"
electron-chromedriver "^6.0.0" electron-chromedriver "^9.0.0"
request "^2.87.0" request "^2.87.0"
split "^1.0.0" split "^1.0.0"
webdriverio "^4.13.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: split-string@^3.0.1, split-string@^3.0.2:
version "3.1.0" version "3.1.0"
resolved "https://registry.yarnpkg.com/split-string/-/split-string-3.1.0.tgz#7cb09dda3a86585705c64b39a6466038682e8fe2" 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: dependencies:
safe-buffer "~5.2.0" 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: string_decoder@~1.1.1:
version "1.1.1" version "1.1.1"
resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.1.1.tgz#9cf1611ba62685d7030ae9e4ba34149c3af03fc8" 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" loader-utils "^2.0.0"
schema-utils "^2.6.6" 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: sumchecker@^3.0.1:
version "3.0.1" version "3.0.1"
resolved "https://registry.yarnpkg.com/sumchecker/-/sumchecker-3.0.1.tgz#6377e996795abb0b6d348e9b3e1dfb24345a8e42" 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" resolved "https://registry.yarnpkg.com/throat/-/throat-5.0.0.tgz#c5199235803aad18754a667d659b5e72ce16764b"
integrity sha512-fcwX4mndzpLQKBS1DVYhGAcYaYt7vsHNIvQV+WXMvnow5cgjPphq5CaayLaGsjRdSCKZFNGt7/GYAuXaNOiYCA== 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: through2@^2.0.0:
version "2.0.5" version "2.0.5"
resolved "https://registry.yarnpkg.com/through2/-/through2-2.0.5.tgz#01c1e39eb31d07cb7d03a96a70823260b23132cd" 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" readable-stream "~2.3.6"
xtend "~4.0.1" 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: through@2, through@^2.3.6:
version "2.3.8" version "2.3.8"
resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5" 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" resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54"
integrity sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ== 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: xterm-addon-fit@^0.4.0:
version "0.4.0" version "0.4.0"
resolved "https://registry.yarnpkg.com/xterm-addon-fit/-/xterm-addon-fit-0.4.0.tgz#06e0c5d0a6aaacfb009ef565efa1c81e93d90193" resolved "https://registry.yarnpkg.com/xterm-addon-fit/-/xterm-addon-fit-0.4.0.tgz#06e0c5d0a6aaacfb009ef565efa1c81e93d90193"