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
+1808 -1355

No files matched your search

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