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

Merge branch 'master' into extensions-api

# Conflicts:
#	src/renderer/components/app.tsx
#	src/renderer/components/cluster-manager/clusters-menu.scss
#	src/renderer/components/cluster-manager/clusters-menu.tsx
#	src/renderer/components/layout/main-layout.tsx
#	src/renderer/components/layout/sidebar.tsx
This commit is contained in:
Roman 2020-09-23 11:49:16 +03:00
commit 91b4823ec6
54 changed files with 619 additions and 333 deletions

12
build/installer.nsh Normal file
View File

@ -0,0 +1,12 @@
!macro customInit
; Workaround for installer handing when the app directory is removed manually
${ifNot} ${FileExists} "$INSTDIR"
DeleteRegKey HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\{${UNINSTALL_APP_KEY}}"
${EndIf}
; Workaround for the old-format uninstall registry key (some people report it causes hangups, too)
ReadRegStr $0 HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${UNINSTALL_APP_KEY}" "QuietUninstallString"
StrCmp $0 "" proceed 0
DeleteRegKey HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${UNINSTALL_APP_KEY}"
proceed:
!macroend

View File

@ -17,15 +17,12 @@ describe("app start", () => {
const addMinikubeCluster = async (app: Application) => { const addMinikubeCluster = async (app: Application) => {
await app.client.click("div.add-cluster") await app.client.click("div.add-cluster")
await app.client.waitUntilTextExists("div", "Select kubeconfig file") await app.client.waitUntilTextExists("div", "Select kubeconfig file")
await app.client.click("div.Select__control")
await app.client.waitUntilTextExists("div", "minikube")
await app.client.click("div.minikube")
await app.client.click("button.primary") await app.client.click("button.primary")
} }
const waitForMinikubeDashboard = async (app: Application) => { const waitForMinikubeDashboard = async (app: Application) => {
await app.client.waitUntilTextExists("pre.kube-auth-out", "Authentication proxy started") await app.client.waitUntilTextExists("pre.kube-auth-out", "Authentication proxy started")
let windowCount = await app.client.getWindowCount() await app.client.getWindowCount()
await app.client.waitForExist(`iframe[name="minikube"]`) await app.client.waitForExist(`iframe[name="minikube"]`)
await app.client.frame("minikube") await app.client.frame("minikube")
await app.client.waitUntilTextExists("span.link-text", "Cluster") await app.client.waitUntilTextExists("span.link-text", "Cluster")
@ -35,10 +32,8 @@ describe("app start", () => {
app = util.setup() app = util.setup()
await app.start() await app.start()
await app.client.waitUntilWindowLoaded() await app.client.waitUntilWindowLoaded()
let windowCount = await app.client.getWindowCount() // Wait for splash screen to be closed
while (windowCount > 1) { // Wait for splash screen to be closed while (await app.client.getWindowCount() > 1);
windowCount = await app.client.getWindowCount()
}
await app.client.windowByIndex(0) await app.client.windowByIndex(0)
await app.client.waitUntilWindowLoaded() await app.client.waitUntilWindowLoaded()
}, 20000) }, 20000)
@ -48,7 +43,7 @@ describe("app start", () => {
}) })
it('allows to add a cluster', async () => { it('allows to add a cluster', async () => {
const status = spawnSync("minikube status", {shell: true}) const status = spawnSync("minikube status", { shell: true })
if (status.status !== 0) { if (status.status !== 0) {
console.warn("minikube not running, skipping test") console.warn("minikube not running, skipping test")
return return
@ -61,7 +56,7 @@ describe("app start", () => {
}) })
it('allows to create a pod', async () => { it('allows to create a pod', async () => {
const status = spawnSync("minikube status", {shell: true}) const status = spawnSync("minikube status", { shell: true })
if (status.status !== 0) { if (status.status !== 0) {
console.warn("minikube not running, skipping test") console.warn("minikube not running, skipping test")
return return

View File

@ -2,7 +2,7 @@
"name": "kontena-lens", "name": "kontena-lens",
"productName": "Lens", "productName": "Lens",
"description": "Lens - The Kubernetes IDE", "description": "Lens - The Kubernetes IDE",
"version": "3.6.0-rc.2", "version": "3.6.4",
"main": "static/build/main.js", "main": "static/build/main.js",
"copyright": "© 2020, Mirantis, Inc.", "copyright": "© 2020, Mirantis, Inc.",
"license": "MIT", "license": "MIT",
@ -151,6 +151,9 @@
} }
] ]
}, },
"nsis": {
"include": "build/installer.nsh"
},
"publish": [ "publish": [
{ {
"provider": "github", "provider": "github",
@ -214,6 +217,7 @@
"semver": "^7.3.2", "semver": "^7.3.2",
"serializr": "^2.0.3", "serializr": "^2.0.3",
"shell-env": "^3.0.0", "shell-env": "^3.0.0",
"spdy": "^4.0.2",
"tar": "^6.0.2", "tar": "^6.0.2",
"tcp-port-used": "^1.0.1", "tcp-port-used": "^1.0.1",
"tempy": "^0.5.0", "tempy": "^0.5.0",
@ -258,6 +262,7 @@
"@types/request-promise-native": "^1.0.17", "@types/request-promise-native": "^1.0.17",
"@types/semver": "^7.2.0", "@types/semver": "^7.2.0",
"@types/shelljs": "^0.8.8", "@types/shelljs": "^0.8.8",
"@types/spdy": "^3.4.4",
"@types/tcp-port-used": "^1.0.0", "@types/tcp-port-used": "^1.0.0",
"@types/tempy": "^0.3.0", "@types/tempy": "^0.3.0",
"@types/terser-webpack-plugin": "^3.0.0", "@types/terser-webpack-plugin": "^3.0.0",

View File

@ -77,7 +77,7 @@ export class BaseStore<T = any> extends Singleton {
); );
if (ipcMain) { if (ipcMain) {
const callback = (event: IpcMainEvent, model: T) => { const callback = (event: IpcMainEvent, model: T) => {
logger.debug(`[STORE]: SYNC ${this.name} from renderer`, { model }); logger.silly(`[STORE]: SYNC ${this.name} from renderer`, { model });
this.onSync(model); this.onSync(model);
}; };
ipcMain.on(this.syncChannel, callback); ipcMain.on(this.syncChannel, callback);
@ -85,7 +85,7 @@ export class BaseStore<T = any> extends Singleton {
} }
if (ipcRenderer) { if (ipcRenderer) {
const callback = (event: IpcRendererEvent, model: T) => { const callback = (event: IpcRendererEvent, model: T) => {
logger.debug(`[STORE]: SYNC ${this.name} from main`, { model }); logger.silly(`[STORE]: SYNC ${this.name} from main`, { model });
this.onSync(model); this.onSync(model);
}; };
ipcRenderer.on(this.syncChannel, callback); ipcRenderer.on(this.syncChannel, callback);

View File

@ -14,6 +14,14 @@ export const clusterIpc = {
}, },
}), }),
refresh: createIpcChannel({
channel: "cluster:refresh",
handle: (clusterId: ClusterId) => {
const cluster = clusterStore.getById(clusterId);
if (cluster) return cluster.refresh();
},
}),
disconnect: createIpcChannel({ disconnect: createIpcChannel({
channel: "cluster:disconnect", channel: "cluster:disconnect",
handle: (clusterId: ClusterId) => { handle: (clusterId: ClusterId) => {

View File

@ -76,7 +76,7 @@ export class ClusterStore extends BaseStore<ClusterStoreModel> {
if (ipcRenderer) { if (ipcRenderer) {
ipcRenderer.on("cluster:state", (event, model: ClusterState) => { ipcRenderer.on("cluster:state", (event, model: ClusterState) => {
this.applyWithoutSync(() => { this.applyWithoutSync(() => {
logger.debug(`[CLUSTER-STORE]: received push-state at ${location.host}`, model); logger.silly(`[CLUSTER-STORE]: received push-state at ${location.host}`, model);
this.getById(model.id)?.updateModel(model); this.getById(model.id)?.updateModel(model);
}) })
}) })

View File

@ -68,7 +68,7 @@ export function broadcastIpc({ channel, frameId, frameOnly, webContentId, filter
} }
views.forEach(webContent => { views.forEach(webContent => {
const type = webContent.getType(); const type = webContent.getType();
logger.debug(`[IPC]: broadcasting "${channel}" to ${type}=${webContent.id}`, { args }); logger.silly(`[IPC]: broadcasting "${channel}" to ${type}=${webContent.id}`, { args });
if (!frameOnly) { if (!frameOnly) {
webContent.send(channel, ...args); webContent.send(channel, ...args);
} }

View File

@ -44,7 +44,7 @@ export class ClusterManager {
// lens-server is connecting to 127.0.0.1:<port>/<uid> // lens-server is connecting to 127.0.0.1:<port>/<uid>
if (req.headers.host.startsWith("127.0.0.1")) { if (req.headers.host.startsWith("127.0.0.1")) {
const clusterId = req.url.split("/")[1] const clusterId = req.url.split("/")[1]
const cluster = clusterStore.getById(clusterId) cluster = clusterStore.getById(clusterId)
if (cluster) { if (cluster) {
// we need to swap path prefix so that request is proxied to kube api // we need to swap path prefix so that request is proxied to kube api
req.url = req.url.replace(`/${clusterId}`, apiKubePrefix) req.url = req.url.replace(`/${clusterId}`, apiKubePrefix)

View File

@ -92,7 +92,7 @@ export class Cluster implements ClusterModel {
async init(port: number) { async init(port: number) {
try { try {
this.contextHandler = new ContextHandler(this); this.contextHandler = new ContextHandler(this);
this.kubeconfigManager = new KubeconfigManager(this, this.contextHandler); this.kubeconfigManager = new KubeconfigManager(this, this.contextHandler, port);
this.kubeProxyUrl = `http://localhost:${port}${apiKubePrefix}`; this.kubeProxyUrl = `http://localhost:${port}${apiKubePrefix}`;
this.initialized = true; this.initialized = true;
logger.info(`[CLUSTER]: "${this.contextName}" init success`, { logger.info(`[CLUSTER]: "${this.contextName}" init success`, {
@ -111,12 +111,10 @@ export class Cluster implements ClusterModel {
protected bindEvents() { protected bindEvents() {
logger.info(`[CLUSTER]: bind events`, this.getMeta()); logger.info(`[CLUSTER]: bind events`, this.getMeta());
const refreshTimer = setInterval(() => this.online && this.refresh(), 30000); // every 30s const refreshTimer = setInterval(() => this.online && this.refresh(), 30000); // every 30s
const refreshEventsTimer = setInterval(() => this.online && this.refreshEvents(), 3000); // every 3s
this.eventDisposers.push( this.eventDisposers.push(
reaction(this.getState, this.pushState), reaction(this.getState, this.pushState),
() => clearInterval(refreshTimer), () => clearInterval(refreshTimer),
() => clearInterval(refreshEventsTimer),
); );
} }
@ -135,7 +133,13 @@ export class Cluster implements ClusterModel {
if (this.disconnected || (!init && !this.accessible)) { if (this.disconnected || (!init && !this.accessible)) {
await this.reconnect(); await this.reconnect();
} }
await this.refresh(); await this.refreshConnectionStatus()
if (this.accessible) {
await this.refreshAllowedResources()
this.ready = true
this.kubeCtl = new Kubectl(this.version)
this.kubeCtl.ensureKubectl() // download kubectl in background, so it's not blocking dashboard
}
return this.pushState(); return this.pushState();
} }
@ -161,15 +165,14 @@ export class Cluster implements ClusterModel {
@action @action
async refresh() { async refresh() {
logger.info(`[CLUSTER]: refresh`, this.getMeta()); logger.info(`[CLUSTER]: refresh`, this.getMeta());
await this.refreshConnectionStatus(); // refresh "version", "online", etc. await this.whenInitialized;
await this.refreshConnectionStatus();
if (this.accessible) { if (this.accessible) {
this.kubeCtl = new Kubectl(this.version)
this.distribution = this.detectKubernetesDistribution(this.version) this.distribution = this.detectKubernetesDistribution(this.version)
const [features, isAdmin, nodesCount] = await Promise.all([ const [features, isAdmin, nodesCount] = await Promise.all([
getFeatures(this), getFeatures(this),
this.isClusterAdmin(), this.isClusterAdmin(),
this.getNodeCount(), this.getNodeCount(),
this.kubeCtl.ensureKubectl()
]); ]);
this.features = features; this.features = features;
this.isAdmin = isAdmin; this.isAdmin = isAdmin;
@ -178,8 +181,8 @@ export class Cluster implements ClusterModel {
this.refreshEvents(), this.refreshEvents(),
this.refreshAllowedResources(), this.refreshAllowedResources(),
]); ]);
this.ready = true
} }
this.pushState();
} }
@action @action
@ -396,7 +399,7 @@ export class Cluster implements ClusterModel {
} }
pushState = (state = this.getState()): ClusterState => { pushState = (state = this.getState()): ClusterState => {
logger.debug(`[CLUSTER]: push-state`, state); logger.silly(`[CLUSTER]: push-state`, state);
broadcastIpc({ broadcastIpc({
channel: "cluster:state", channel: "cluster:state",
frameId: this.frameId, frameId: this.frameId,

View File

@ -70,7 +70,8 @@ export class ContextHandler {
async resolveAuthProxyUrl() { async resolveAuthProxyUrl() {
const proxyPort = await this.ensurePort(); const proxyPort = await this.ensurePort();
return `http://127.0.0.1:${proxyPort}`; const path = this.clusterUrl.path !== "/" ? this.clusterUrl.path : ""
return `http://127.0.0.1:${proxyPort}${path}`;
} }
async getApiTarget(isWatchRequest = false): Promise<httpProxy.ServerOptions> { async getApiTarget(isWatchRequest = false): Promise<httpProxy.ServerOptions> {
@ -88,7 +89,7 @@ export class ContextHandler {
protected async newApiTarget(timeout: number): Promise<httpProxy.ServerOptions> { protected async newApiTarget(timeout: number): Promise<httpProxy.ServerOptions> {
const proxyUrl = await this.resolveAuthProxyUrl(); const proxyUrl = await this.resolveAuthProxyUrl();
return { return {
target: proxyUrl + this.clusterUrl.path, target: proxyUrl,
changeOrigin: true, changeOrigin: true,
timeout: timeout, timeout: timeout,
headers: { headers: {

View File

@ -11,7 +11,7 @@ export class KubeconfigManager {
protected configDir = app.getPath("temp") protected configDir = app.getPath("temp")
protected tempFile: string; protected tempFile: string;
constructor(protected cluster: Cluster, protected contextHandler: ContextHandler) { constructor(protected cluster: Cluster, protected contextHandler: ContextHandler, protected port: number) {
this.init(); this.init();
} }
@ -28,6 +28,10 @@ export class KubeconfigManager {
return this.tempFile; return this.tempFile;
} }
protected resolveProxyUrl() {
return `http://127.0.0.1:${this.port}/${this.cluster.id}`
}
/** /**
* Creates new "temporary" kubeconfig that point to the kubectl-proxy. * Creates new "temporary" kubeconfig that point to the kubectl-proxy.
* This way any user of the config does not need to know anything about the auth etc. details. * This way any user of the config does not need to know anything about the auth etc. details.
@ -42,7 +46,7 @@ export class KubeconfigManager {
clusters: [ clusters: [
{ {
name: contextName, name: contextName,
server: await contextHandler.resolveAuthProxyUrl(), server: this.resolveProxyUrl(),
skipTLSVerify: undefined, skipTLSVerify: undefined,
} }
], ],
@ -62,7 +66,7 @@ export class KubeconfigManager {
// write // write
const configYaml = dumpConfigYaml(proxyConfig); const configYaml = dumpConfigYaml(proxyConfig);
fs.ensureDir(path.dirname(tempFile)); fs.ensureDir(path.dirname(tempFile));
fs.writeFileSync(tempFile, configYaml); fs.writeFileSync(tempFile, configYaml, { mode: 0o600 });
this.tempFile = tempFile; this.tempFile = tempFile;
logger.debug(`Created temp kubeconfig "${contextName}" at "${tempFile}": \n${configYaml}`); logger.debug(`Created temp kubeconfig "${contextName}" at "${tempFile}": \n${configYaml}`);
return tempFile; return tempFile;

View File

@ -1,13 +1,14 @@
import net from "net"; import net from "net";
import http from "http"; import http from "http";
import spdy from "spdy";
import httpProxy from "http-proxy"; import httpProxy from "http-proxy";
import url from "url"; import url from "url";
import * as WebSocket from "ws" import * as WebSocket from "ws"
import { apiPrefix, apiKubePrefix } from "../common/vars"
import { openShell } from "./node-shell-session"; import { openShell } from "./node-shell-session";
import { Router } from "./router" import { Router } from "./router"
import { ClusterManager } from "./cluster-manager" import { ClusterManager } from "./cluster-manager"
import { ContextHandler } from "./context-handler"; import { ContextHandler } from "./context-handler";
import { apiKubePrefix } from "../common/vars";
import logger from "./logger" import logger from "./logger"
export class LensProxy { export class LensProxy {
@ -40,37 +41,49 @@ export class LensProxy {
protected buildCustomProxy(): http.Server { protected buildCustomProxy(): http.Server {
const proxy = this.createProxy(); const proxy = this.createProxy();
const customProxy = http.createServer((req: http.IncomingMessage, res: http.ServerResponse) => { const spdyProxy = spdy.createServer({
this.handleRequest(proxy, req, res); spdy: {
}); plain: true,
customProxy.on("upgrade", (req: http.IncomingMessage, socket: net.Socket, head: Buffer) => { connection: {
autoSpdy31: true
}
}
}, (req: http.IncomingMessage, res: http.ServerResponse) => {
this.handleRequest(proxy, req, res)
})
spdyProxy.on("upgrade", (req: http.IncomingMessage, socket: net.Socket, head: Buffer) => {
if (req.url.startsWith(`${apiPrefix}?`)) {
this.handleWsUpgrade(req, socket, head) this.handleWsUpgrade(req, socket, head)
}); } else {
customProxy.on("error", (err) => { if (req.headers.upgrade?.startsWith("SPDY")) {
this.handleSpdyProxy(proxy, req, socket, head)
} else {
socket.end()
}
}
})
spdyProxy.on("error", (err) => {
logger.error("proxy error", err) logger.error("proxy error", err)
}); })
return customProxy; return spdyProxy
}
protected async handleSpdyProxy(proxy: httpProxy, req: http.IncomingMessage, socket: net.Socket, head: Buffer) {
const cluster = this.clusterManager.getClusterForRequest(req)
if (cluster) {
const proxyUrl = await cluster.contextHandler.resolveAuthProxyUrl() + req.url.replace(apiKubePrefix, "")
const apiUrl = url.parse(cluster.apiUrl)
const res = new http.ServerResponse(req)
res.assignSocket(socket)
res.setHeader("Location", proxyUrl)
res.setHeader("Host", apiUrl.hostname)
res.statusCode = 302
res.end()
}
} }
protected createProxy(): httpProxy { protected createProxy(): httpProxy {
const proxy = httpProxy.createProxyServer(); const proxy = httpProxy.createProxyServer();
proxy.on("proxyRes", (proxyRes, req, res) => {
if (req.method !== "GET") {
return;
}
if (proxyRes.statusCode === 502) {
const cluster = this.clusterManager.getClusterForRequest(req)
const proxyError = cluster?.contextHandler.proxyLastError;
if (proxyError) {
return res.writeHead(502).end(proxyError);
}
}
const reqId = this.getRequestId(req);
if (this.retryCounters.has(reqId)) {
logger.debug(`Resetting proxy retry cache for url: ${reqId}`);
this.retryCounters.delete(reqId)
}
})
proxy.on("error", (error, req, res, target) => { proxy.on("error", (error, req, res, target) => {
if (this.closed) { if (this.closed) {
return; return;

View File

@ -2,14 +2,16 @@ import { app, remote } from "electron";
import winston from "winston" import winston from "winston"
import { isDebugging } from "../common/vars"; import { isDebugging } from "../common/vars";
const logLevel = process.env.LOG_LEVEL ? process.env.LOG_LEVEL : isDebugging ? "debug" : "info"
const consoleOptions: winston.transports.ConsoleTransportOptions = { const consoleOptions: winston.transports.ConsoleTransportOptions = {
handleExceptions: false, handleExceptions: false,
level: isDebugging ? "debug" : "info", level: logLevel,
} }
const fileOptions: winston.transports.FileTransportOptions = { const fileOptions: winston.transports.FileTransportOptions = {
handleExceptions: false, handleExceptions: false,
level: isDebugging ? "debug" : "info", level: logLevel,
filename: "lens.log", filename: "lens.log",
dirname: (app ?? remote?.app)?.getPath("logs"), dirname: (app ?? remote?.app)?.getPath("logs"),
maxsize: 16 * 1024, maxsize: 16 * 1024,

View File

@ -39,7 +39,7 @@ export class ShellSession extends EventEmitter {
public async open() { public async open() {
this.kubectlBinDir = await this.kubectl.binDir() this.kubectlBinDir = await this.kubectl.binDir()
const pathFromPreferences = userStore.preferences.kubectlBinariesPath || Kubectl.bundledKubectlPath const pathFromPreferences = userStore.preferences.kubectlBinariesPath || Kubectl.bundledKubectlPath
this.kubectlPathDir = userStore.preferences.downloadKubectlBinaries ? await this.kubectl.binDir() : path.dirname(pathFromPreferences) this.kubectlPathDir = userStore.preferences.downloadKubectlBinaries ? this.kubectlBinDir : path.dirname(pathFromPreferences)
this.helmBinDir = helmCli.getBinaryDir() this.helmBinDir = helmCli.getBinaryDir()
const env = await this.getCachedShellEnv() const env = await this.getCachedShellEnv()
const shell = env.PTYSHELL const shell = env.PTYSHELL

View File

@ -23,8 +23,8 @@ export class WindowManager {
this.mainView = new BrowserWindow({ this.mainView = new BrowserWindow({
x, y, width, height, x, y, width, height,
show: false, show: false,
minWidth: 900, minWidth: 700, // accommodate 800 x 600 display minimum
minHeight: 760, minHeight: 500, // accommodate 800 x 600 display minimum
titleBarStyle: "hidden", titleBarStyle: "hidden",
backgroundColor: "#1e2124", backgroundColor: "#1e2124",
webPreferences: { webPreferences: {

View File

@ -1,15 +1,15 @@
import React from "react"; import React from "react";
import { Trans } from "@lingui/macro"; import { Trans } from "@lingui/macro";
import { MainLayout } from "../layout/main-layout"; import { TabLayout } from "../layout/tab-layout";
export class NotFound extends React.Component { export class NotFound extends React.Component {
render() { render() {
return ( return (
<MainLayout className="NotFound" contentClass="flex" footer={null}> <TabLayout className="NotFound" contentClass="flex">
<p className="box center"> <p className="box center">
<Trans>Page not found</Trans> <Trans>Page not found</Trans>
</p> </p>
</MainLayout> </TabLayout>
) );
} }
} }

View File

@ -80,7 +80,6 @@ export class AddCluster extends React.Component {
const contexts = this.getContexts(this.kubeConfigLocal); const contexts = this.getContexts(this.kubeConfigLocal);
this.kubeContexts.replace(contexts); this.kubeContexts.replace(contexts);
break; break;
case KubeConfigSourceTab.TEXT: case KubeConfigSourceTab.TEXT:
try { try {
this.error = "" this.error = ""
@ -91,6 +90,10 @@ export class AddCluster extends React.Component {
} }
break; break;
} }
if (this.kubeContexts.size === 1) {
this.selectedContexts.push(this.kubeContexts.keys().next().value)
}
} }
getContexts(config: KubeConfig): Map<string, KubeConfig> { getContexts(config: KubeConfig): Map<string, KubeConfig> {
@ -206,7 +209,7 @@ export class AddCluster extends React.Component {
<Tab <Tab
value={KubeConfigSourceTab.FILE} value={KubeConfigSourceTab.FILE}
label={<Trans>Select kubeconfig file</Trans>} label={<Trans>Select kubeconfig file</Trans>}
active={this.sourceTab == KubeConfigSourceTab.FILE}/> active={this.sourceTab == KubeConfigSourceTab.FILE} />
<Tab <Tab
value={KubeConfigSourceTab.TEXT} value={KubeConfigSourceTab.TEXT}
label={<Trans>Paste as text</Trans>} label={<Trans>Paste as text</Trans>}
@ -320,13 +323,15 @@ export class AddCluster extends React.Component {
return ( return (
<div className={cssNames("kube-context flex gaps align-center", context)}> <div className={cssNames("kube-context flex gaps align-center", context)}>
<span>{context}</span> <span>{context}</span>
{isNew && <Icon small material="fiber_new"/>} {isNew && <Icon small material="fiber_new" />}
{isSelected && <Icon small material="check" className="box right"/>} {isSelected && <Icon small material="check" className="box right" />}
</div> </div>
) )
}; };
render() { render() {
const addDisabled = this.selectedContexts.length === 0
return ( return (
<WizardLayout <WizardLayout
className="AddCluster" className="AddCluster"
@ -374,9 +379,12 @@ export class AddCluster extends React.Component {
<div className="actions-panel"> <div className="actions-panel">
<Button <Button
primary primary
disabled={addDisabled}
label={<Trans>Add cluster(s)</Trans>} label={<Trans>Add cluster(s)</Trans>}
onClick={this.addClusters} onClick={this.addClusters}
waiting={this.isWaiting} waiting={this.isWaiting}
tooltip={addDisabled ? _i18n._("Select at least one cluster to add.") : undefined}
tooltipOverrideDisabled
/> />
</div> </div>
</WizardLayout> </WizardLayout>

View File

@ -2,7 +2,7 @@ import React from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { Redirect, Route, Switch } from "react-router"; import { Redirect, Route, Switch } from "react-router";
import { Trans } from "@lingui/macro"; import { Trans } from "@lingui/macro";
import { MainLayout, TabRoute } from "../layout/main-layout"; import { TabLayout, TabRoute } from "../layout/tab-layout";
import { HelmCharts, helmChartsRoute, helmChartsURL } from "../+apps-helm-charts"; import { HelmCharts, helmChartsRoute, helmChartsURL } from "../+apps-helm-charts";
import { HelmReleases, releaseRoute, releaseURL } from "../+apps-releases"; import { HelmReleases, releaseRoute, releaseURL } from "../+apps-releases";
import { namespaceStore } from "../+namespaces/namespace.store"; import { namespaceStore } from "../+namespaces/namespace.store";
@ -30,12 +30,12 @@ export class Apps extends React.Component {
render() { render() {
const tabRoutes = Apps.tabRoutes; const tabRoutes = Apps.tabRoutes;
return ( return (
<MainLayout className="Apps" tabs={tabRoutes}> <TabLayout className="Apps" tabs={tabRoutes}>
<Switch> <Switch>
{tabRoutes.map((route, index) => <Route key={index} {...route}/>)} {tabRoutes.map((route, index) => <Route key={index} {...route}/>)}
<Redirect to={tabRoutes[0].url}/> <Redirect to={tabRoutes[0].url}/>
</Switch> </Switch>
</MainLayout> </TabLayout>
) )
} }
} }

View File

@ -1,21 +1,38 @@
import "./cluster-settings.scss"; import "./cluster-settings.scss";
import React from "react"; import React from "react";
import { observer } from "mobx-react"; import { observer, disposeOnUnmount } from "mobx-react";
import { Features } from "./features"; import { Features } from "./features";
import { Removal } from "./removal"; import { Removal } from "./removal";
import { Status } from "./status"; import { Status } from "./status";
import { General } from "./general"; import { General } from "./general";
import { Cluster } from "../../../main/cluster";
import { WizardLayout } from "../layout/wizard-layout"; import { WizardLayout } from "../layout/wizard-layout";
import { ClusterIcon } from "../cluster-icon"; import { ClusterIcon } from "../cluster-icon";
import { Icon } from "../icon"; import { Icon } from "../icon";
import { getMatchedCluster } from "../cluster-manager/cluster-view.route";
import { navigate } from "../../navigation"; import { navigate } from "../../navigation";
import { IClusterSettingsRouteParams } from "./cluster-settings.route";
import { clusterStore } from "../../../common/cluster-store";
import { RouteComponentProps } from "react-router";
import { clusterIpc } from "../../../common/cluster-ipc";
import { autorun } from "mobx";
interface Props extends RouteComponentProps<IClusterSettingsRouteParams> {
}
@observer @observer
export class ClusterSettings extends React.Component { export class ClusterSettings extends React.Component<Props> {
get cluster(): Cluster {
return clusterStore.getById(this.props.match.params.clusterId);
}
async componentDidMount() { async componentDidMount() {
window.addEventListener('keydown', this.onEscapeKey); window.addEventListener('keydown', this.onEscapeKey);
disposeOnUnmount(this,
autorun(() => {
this.refreshCluster();
})
)
} }
componentWillUnmount() { componentWillUnmount() {
@ -29,12 +46,18 @@ export class ClusterSettings extends React.Component {
} }
} }
refreshCluster = () => {
if(this.cluster) {
clusterIpc.refresh.invokeFromRenderer(this.cluster.id);
}
}
close() { close() {
navigate("/"); navigate("/");
} }
render() { render() {
const cluster = getMatchedCluster(); const cluster = this.cluster
if (!cluster) return null; if (!cluster) return null;
const header = ( const header = (
<> <>

View File

@ -3,7 +3,7 @@ import "./cluster.scss"
import React from "react"; import React from "react";
import { computed, reaction } from "mobx"; import { computed, reaction } from "mobx";
import { disposeOnUnmount, observer } from "mobx-react"; import { disposeOnUnmount, observer } from "mobx-react";
import { MainLayout } from "../layout/main-layout"; import { TabLayout } from "../layout/tab-layout";
import { ClusterIssues } from "./cluster-issues"; import { ClusterIssues } from "./cluster-issues";
import { Spinner } from "../spinner"; import { Spinner } from "../spinner";
import { cssNames, interval, isElectron } from "../../utils"; import { cssNames, interval, isElectron } from "../../utils";
@ -54,7 +54,7 @@ export class Cluster extends React.Component {
render() { render() {
const { isLoaded } = this; const { isLoaded } = this;
return ( return (
<MainLayout> <TabLayout>
<div className="Cluster"> <div className="Cluster">
{!isLoaded && <Spinner center/>} {!isLoaded && <Spinner center/>}
{isLoaded && ( {isLoaded && (
@ -65,7 +65,7 @@ export class Cluster extends React.Component {
</> </>
)} )}
</div> </div>
</MainLayout> </TabLayout>
) )
} }
} }

View File

@ -2,7 +2,7 @@ import React from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { Redirect, Route, Switch } from "react-router"; import { Redirect, Route, Switch } from "react-router";
import { Trans } from "@lingui/macro"; import { Trans } from "@lingui/macro";
import { MainLayout, TabRoute } from "../layout/main-layout"; import { TabLayout, TabRoute } from "../layout/tab-layout";
import { ConfigMaps, configMapsRoute, configMapsURL } from "../+config-maps"; import { ConfigMaps, configMapsRoute, configMapsURL } from "../+config-maps";
import { Secrets, secretsRoute, secretsURL } from "../+config-secrets"; import { Secrets, secretsRoute, secretsURL } from "../+config-secrets";
import { namespaceStore } from "../+namespaces/namespace.store"; import { namespaceStore } from "../+namespaces/namespace.store";
@ -68,12 +68,12 @@ export class Config extends React.Component {
render() { render() {
const tabRoutes = Config.tabRoutes; const tabRoutes = Config.tabRoutes;
return ( return (
<MainLayout className="Config" tabs={tabRoutes}> <TabLayout className="Config" tabs={tabRoutes}>
<Switch> <Switch>
{tabRoutes.map((route, index) => <Route key={index} {...route}/>)} {tabRoutes.map((route, index) => <Route key={index} {...route}/>)}
<Redirect to={configURL({ query: namespaceStore.getContextParams() })}/> <Redirect to={configURL({ query: namespaceStore.getContextParams() })}/>
</Switch> </Switch>
</MainLayout> </TabLayout>
) )
} }
} }

View File

@ -2,7 +2,7 @@ import React from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { Redirect, Route, Switch } from "react-router"; import { Redirect, Route, Switch } from "react-router";
import { Trans } from "@lingui/macro"; import { Trans } from "@lingui/macro";
import { MainLayout, TabRoute } from "../layout/main-layout"; import { TabLayout, TabRoute } from "../layout/tab-layout";
import { crdResourcesRoute, crdRoute, crdURL, crdDefinitionsRoute } from "./crd.route"; import { crdResourcesRoute, crdRoute, crdURL, crdDefinitionsRoute } from "./crd.route";
import { CrdList } from "./crd-list"; import { CrdList } from "./crd-list";
import { CrdResources } from "./crd-resources"; import { CrdResources } from "./crd-resources";
@ -25,13 +25,13 @@ export class CustomResources extends React.Component {
render() { render() {
return ( return (
<MainLayout> <TabLayout>
<Switch> <Switch>
<Route component={CrdList} {...crdDefinitionsRoute} exact/> <Route component={CrdList} {...crdDefinitionsRoute} exact/>
<Route component={CrdResources} {...crdResourcesRoute}/> <Route component={CrdResources} {...crdResourcesRoute}/>
<Redirect to={crdURL()}/> <Redirect to={crdURL()}/>
</Switch> </Switch>
</MainLayout> </TabLayout>
); );
} }
} }

View File

@ -2,7 +2,7 @@ import "./events.scss";
import React, { Fragment } from "react"; import React, { Fragment } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { MainLayout } from "../layout/main-layout"; import { TabLayout } from "../layout/tab-layout";
import { eventStore } from "./event.store"; import { eventStore } from "./event.store";
import { KubeObjectListLayout, KubeObjectListLayoutProps } from "../kube-object"; import { KubeObjectListLayout, KubeObjectListLayoutProps } from "../kube-object";
import { Trans } from "@lingui/macro"; import { Trans } from "@lingui/macro";
@ -118,9 +118,9 @@ export class Events extends React.Component<Props> {
return events; return events;
} }
return ( return (
<MainLayout> <TabLayout>
{events} {events}
</MainLayout> </TabLayout>
) )
} }
} }

View File

@ -87,6 +87,11 @@ export class NamespaceStore extends KubeObjectStore<Namespace> {
super.reset(); super.reset();
this.contextNs.clear(); this.contextNs.clear();
} }
async remove(item: Namespace) {
await super.remove(item);
this.contextNs.remove(item.getName());
}
} }
export const namespaceStore = new NamespaceStore(); export const namespaceStore = new NamespaceStore();

View File

@ -4,7 +4,7 @@ import React from "react";
import { Trans } from "@lingui/macro"; import { Trans } from "@lingui/macro";
import { Namespace, namespacesApi, NamespaceStatus } from "../../api/endpoints"; import { Namespace, namespacesApi, NamespaceStatus } from "../../api/endpoints";
import { AddNamespaceDialog } from "./add-namespace-dialog"; import { AddNamespaceDialog } from "./add-namespace-dialog";
import { MainLayout } from "../layout/main-layout"; import { TabLayout } from "../layout/tab-layout";
import { Badge } from "../badge"; import { Badge } from "../badge";
import { RouteComponentProps } from "react-router"; import { RouteComponentProps } from "react-router";
import { KubeObjectMenu, KubeObjectMenuProps } from "../kube-object/kube-object-menu"; import { KubeObjectMenu, KubeObjectMenuProps } from "../kube-object/kube-object-menu";
@ -26,7 +26,7 @@ interface Props extends RouteComponentProps<INamespacesRouteParams> {
export class Namespaces extends React.Component<Props> { export class Namespaces extends React.Component<Props> {
render() { render() {
return ( return (
<MainLayout> <TabLayout>
<KubeObjectListLayout <KubeObjectListLayout
isClusterScoped isClusterScoped
className="Namespaces" store={namespaceStore} className="Namespaces" store={namespaceStore}
@ -65,7 +65,7 @@ export class Namespaces extends React.Component<Props> {
})} })}
/> />
<AddNamespaceDialog/> <AddNamespaceDialog/>
</MainLayout> </TabLayout>
) )
} }
} }

View File

@ -67,7 +67,7 @@ export class IngressDetails extends React.Component<Props> {
} }
renderIngressPoints(ingressPoints: ILoadBalancerIngress[]) { renderIngressPoints(ingressPoints: ILoadBalancerIngress[]) {
if (ingressPoints.length === 0) return null if (!ingressPoints || ingressPoints.length === 0) return null
return ( return (
<div> <div>
<Table className="ingress-points"> <Table className="ingress-points">

View File

@ -5,7 +5,7 @@ import { observer } from "mobx-react";
import { Redirect, Route, Switch } from "react-router"; import { Redirect, Route, Switch } from "react-router";
import { RouteComponentProps } from "react-router-dom"; import { RouteComponentProps } from "react-router-dom";
import { Trans } from "@lingui/macro"; import { Trans } from "@lingui/macro";
import { MainLayout, TabRoute } from "../layout/main-layout"; import { TabLayout, TabRoute } from "../layout/tab-layout";
import { Services, servicesRoute, servicesURL } from "../+network-services"; import { Services, servicesRoute, servicesURL } from "../+network-services";
import { Endpoints, endpointRoute, endpointURL } from "../+network-endpoints"; import { Endpoints, endpointRoute, endpointURL } from "../+network-endpoints";
import { Ingresses, ingressRoute, ingressURL } from "../+network-ingresses"; import { Ingresses, ingressRoute, ingressURL } from "../+network-ingresses";
@ -60,12 +60,12 @@ export class Network extends React.Component<Props> {
render() { render() {
const tabRoutes = Network.tabRoutes; const tabRoutes = Network.tabRoutes;
return ( return (
<MainLayout className="Network" tabs={tabRoutes}> <TabLayout className="Network" tabs={tabRoutes}>
<Switch> <Switch>
{tabRoutes.map((route, index) => <Route key={index} {...route}/>)} {tabRoutes.map((route, index) => <Route key={index} {...route}/>)}
<Redirect to={networkURL({ query: namespaceStore.getContextParams() })}/> <Redirect to={networkURL({ query: namespaceStore.getContextParams() })}/>
</Switch> </Switch>
</MainLayout> </TabLayout>
) )
} }
} }

View File

@ -4,7 +4,7 @@ import { observer } from "mobx-react";
import { RouteComponentProps } from "react-router"; import { RouteComponentProps } from "react-router";
import { t, Trans } from "@lingui/macro"; import { t, Trans } from "@lingui/macro";
import { cssNames, interval } from "../../utils"; import { cssNames, interval } from "../../utils";
import { MainLayout } from "../layout/main-layout"; import { TabLayout } from "../layout/tab-layout";
import { nodesStore } from "./nodes.store"; import { nodesStore } from "./nodes.store";
import { podsStore } from "../+workloads-pods/pods.store"; import { podsStore } from "../+workloads-pods/pods.store";
import { KubeObjectListLayout } from "../kube-object"; import { KubeObjectListLayout } from "../kube-object";
@ -123,7 +123,7 @@ export class Nodes extends React.Component<Props> {
render() { render() {
return ( return (
<MainLayout> <TabLayout>
<KubeObjectListLayout <KubeObjectListLayout
className="Nodes" className="Nodes"
store={nodesStore} isClusterScoped store={nodesStore} isClusterScoped
@ -182,7 +182,7 @@ export class Nodes extends React.Component<Props> {
return <NodeMenu object={item}/> return <NodeMenu object={item}/>
}} }}
/> />
</MainLayout> </TabLayout>
) )
} }
} }

View File

@ -5,7 +5,7 @@ import { observer } from "mobx-react";
import { Redirect, Route, Switch } from "react-router"; import { Redirect, Route, Switch } from "react-router";
import { RouteComponentProps } from "react-router-dom"; import { RouteComponentProps } from "react-router-dom";
import { Trans } from "@lingui/macro"; import { Trans } from "@lingui/macro";
import { MainLayout, TabRoute } from "../layout/main-layout"; import { TabLayout, TabRoute } from "../layout/tab-layout";
import { PersistentVolumes, volumesRoute, volumesURL } from "../+storage-volumes"; import { PersistentVolumes, volumesRoute, volumesURL } from "../+storage-volumes";
import { StorageClasses, storageClassesRoute, storageClassesURL } from "../+storage-classes"; import { StorageClasses, storageClassesRoute, storageClassesURL } from "../+storage-classes";
import { PersistentVolumeClaims, volumeClaimsRoute, volumeClaimsURL } from "../+storage-volume-claims"; import { PersistentVolumeClaims, volumeClaimsRoute, volumeClaimsURL } from "../+storage-volume-claims";
@ -52,12 +52,12 @@ export class Storage extends React.Component<Props> {
render() { render() {
const tabRoutes = Storage.tabRoutes; const tabRoutes = Storage.tabRoutes;
return ( return (
<MainLayout className="Storage" tabs={tabRoutes}> <TabLayout className="Storage" tabs={tabRoutes}>
<Switch> <Switch>
{tabRoutes.map((route, index) => <Route key={index} {...route}/>)} {tabRoutes.map((route, index) => <Route key={index} {...route}/>)}
<Redirect to={storageURL({ query: namespaceStore.getContextParams() })}/> <Redirect to={storageURL({ query: namespaceStore.getContextParams() })}/>
</Switch> </Switch>
</MainLayout> </TabLayout>
) )
} }
} }

View File

@ -15,6 +15,7 @@ import { getDetailsUrl } from "../../navigation";
import { KubeObjectDetailsProps } from "../kube-object"; import { KubeObjectDetailsProps } from "../kube-object";
import { apiManager } from "../../api/api-manager"; import { apiManager } from "../../api/api-manager";
import { KubeObjectMeta } from "../kube-object/kube-object-meta"; import { KubeObjectMeta } from "../kube-object/kube-object-meta";
import { Icon } from "../icon";
interface Props extends KubeObjectDetailsProps<ServiceAccount> { interface Props extends KubeObjectDetailsProps<ServiceAccount> {
} }
@ -22,21 +23,25 @@ interface Props extends KubeObjectDetailsProps<ServiceAccount> {
@observer @observer
export class ServiceAccountsDetails extends React.Component<Props> { export class ServiceAccountsDetails extends React.Component<Props> {
@observable secrets: Secret[]; @observable secrets: Secret[];
@observable imagePullSecrets: Secret[];
@disposeOnUnmount @disposeOnUnmount
loadSecrets = autorun(async () => { loadSecrets = autorun(async () => {
this.secrets = null; this.secrets = null;
this.imagePullSecrets = null;
const { object: serviceAccount } = this.props; const { object: serviceAccount } = this.props;
if (!serviceAccount) { if (!serviceAccount) {
return; return;
} }
const namespace = serviceAccount.getNs(); const namespace = serviceAccount.getNs();
const secrets = serviceAccount.getSecrets().map(({ name }) => { const secrets = serviceAccount.getSecrets().map(({ name }) => {
const secret = secretsStore.getByName(name, namespace); return secretsStore.load({ name, namespace });
if (!secret) return secretsStore.load({ name, namespace });
return secret;
}); });
this.secrets = await Promise.all(secrets); this.secrets = await Promise.all(secrets);
const imagePullSecrets = serviceAccount.getImagePullSecrets().map(async({ name }) => {
return secretsStore.load({ name, namespace }).catch(_err => { return this.generateDummySecretObject(name) });
});
this.imagePullSecrets = await Promise.all(imagePullSecrets)
}) })
renderSecrets() { renderSecrets() {
@ -49,15 +54,46 @@ export class ServiceAccountsDetails extends React.Component<Props> {
) )
} }
renderImagePullSecrets() {
const { imagePullSecrets } = this;
if (!imagePullSecrets) {
return <Spinner center/>
}
return this.renderSecretLinks(imagePullSecrets)
}
renderSecretLinks(secrets: Secret[]) { renderSecretLinks(secrets: Secret[]) {
return secrets.map(secret => { return secrets.map((secret) => {
if (secret.getId() === null) {
return (
<div key={secret.getName()}>
{secret.getName()}
<Icon
small material="warning"
tooltip={<Trans>Secret is not found</Trans>}
/>
</div>
)
}
return ( return (
<Link key={secret.getId()} to={getDetailsUrl(secret.selfLink)}> <Link key={secret.getId()} to={getDetailsUrl(secret.selfLink)}>
{secret.getName()} {secret.getName()}
</Link> </Link>
) )
})
} }
)
generateDummySecretObject(name: string) {
return new Secret({
apiVersion: "v1",
kind: "Secret",
metadata: {
name: name,
uid: null,
selfLink: null,
resourceVersion: null
}
})
} }
render() { render() {
@ -69,9 +105,7 @@ export class ServiceAccountsDetails extends React.Component<Props> {
secret.getNs() == serviceAccount.getNs() && secret.getNs() == serviceAccount.getNs() &&
secret.getAnnotations().some(annot => annot == `kubernetes.io/service-account.name: ${serviceAccount.getName()}`) secret.getAnnotations().some(annot => annot == `kubernetes.io/service-account.name: ${serviceAccount.getName()}`)
) )
const imagePullSecrets = serviceAccount.getImagePullSecrets().map(({ name }) => const imagePullSecrets = serviceAccount.getImagePullSecrets()
secretsStore.getByName(name, serviceAccount.getNs())
)
return ( return (
<div className="ServiceAccountsDetails"> <div className="ServiceAccountsDetails">
<KubeObjectMeta object={serviceAccount}/> <KubeObjectMeta object={serviceAccount}/>
@ -83,7 +117,7 @@ export class ServiceAccountsDetails extends React.Component<Props> {
} }
{imagePullSecrets.length > 0 && {imagePullSecrets.length > 0 &&
<DrawerItem name={<Trans>ImagePullSecrets</Trans>} className="links"> <DrawerItem name={<Trans>ImagePullSecrets</Trans>} className="links">
{this.renderSecretLinks(imagePullSecrets)} {this.renderImagePullSecrets()}
</DrawerItem> </DrawerItem>
} }

View File

@ -4,7 +4,7 @@ import { observer } from "mobx-react";
import { Redirect, Route, Switch } from "react-router"; import { Redirect, Route, Switch } from "react-router";
import { RouteComponentProps } from "react-router-dom"; import { RouteComponentProps } from "react-router-dom";
import { Trans } from "@lingui/macro"; import { Trans } from "@lingui/macro";
import { MainLayout, TabRoute } from "../layout/main-layout"; import { TabLayout, TabRoute } from "../layout/tab-layout";
import { Roles } from "../+user-management-roles"; import { Roles } from "../+user-management-roles";
import { RoleBindings } from "../+user-management-roles-bindings"; import { RoleBindings } from "../+user-management-roles-bindings";
import { ServiceAccounts } from "../+user-management-service-accounts"; import { ServiceAccounts } from "../+user-management-service-accounts";
@ -55,12 +55,12 @@ export class UserManagement extends React.Component<Props> {
render() { render() {
const tabRoutes = UserManagement.tabRoutes; const tabRoutes = UserManagement.tabRoutes;
return ( return (
<MainLayout className="UserManagement" tabs={tabRoutes}> <TabLayout className="UserManagement" tabs={tabRoutes}>
<Switch> <Switch>
{tabRoutes.map((route, index) => <Route key={index} {...route}/>)} {tabRoutes.map((route, index) => <Route key={index} {...route}/>)}
<Redirect to={usersManagementURL({ query: namespaceStore.getContextParams() })}/> <Redirect to={usersManagementURL({ query: namespaceStore.getContextParams() })}/>
</Switch> </Switch>
</MainLayout> </TabLayout>
) )
} }
} }

View File

@ -1,10 +1,24 @@
.WhatsNew { .WhatsNew {
$spacing: $padding * 2; $spacing: $padding * 2;
background: $mainBackground url(../../components/icon/crane.svg) no-repeat; &::after {
content: "";
background: url(../../components/icon/crane.svg) no-repeat;
background-position: 0 35%; background-position: 0 35%;
background-size: 85%; background-size: 85%;
background-clip: content-box; background-clip: content-box;
opacity: .75;
top: 0;
left: 0;
bottom: 0;
right: 0;
position: absolute;
z-index: -1;
.theme-light & {
opacity: 0.2;
}
}
.logo { .logo {
width: 200px; width: 200px;

View File

@ -5,7 +5,7 @@ import { observer } from "mobx-react";
import { Redirect, Route, Switch } from "react-router"; import { Redirect, Route, Switch } from "react-router";
import { RouteComponentProps } from "react-router-dom"; import { RouteComponentProps } from "react-router-dom";
import { Trans } from "@lingui/macro"; import { Trans } from "@lingui/macro";
import { MainLayout, TabRoute } from "../layout/main-layout"; import { TabLayout, TabRoute } from "../layout/tab-layout";
import { WorkloadsOverview } from "../+workloads-overview/overview"; import { WorkloadsOverview } from "../+workloads-overview/overview";
import { cronJobsRoute, cronJobsURL, daemonSetsRoute, daemonSetsURL, deploymentsRoute, deploymentsURL, jobsRoute, jobsURL, overviewRoute, overviewURL, podsRoute, podsURL, statefulSetsRoute, statefulSetsURL, workloadsURL } from "./workloads.route"; import { cronJobsRoute, cronJobsURL, daemonSetsRoute, daemonSetsURL, deploymentsRoute, deploymentsURL, jobsRoute, jobsURL, overviewRoute, overviewURL, podsRoute, podsURL, statefulSetsRoute, statefulSetsURL, workloadsURL } from "./workloads.route";
import { namespaceStore } from "../+namespaces/namespace.store"; import { namespaceStore } from "../+namespaces/namespace.store";
@ -86,12 +86,12 @@ export class Workloads extends React.Component<Props> {
render() { render() {
const tabRoutes = Workloads.tabRoutes; const tabRoutes = Workloads.tabRoutes;
return ( return (
<MainLayout className="Workloads" tabs={tabRoutes}> <TabLayout className="Workloads" tabs={tabRoutes}>
<Switch> <Switch>
{tabRoutes.map((route, index) => <Route key={index} {...route}/>)} {tabRoutes.map((route, index) => <Route key={index} {...route}/>)}
<Redirect to={workloadsURL({ query: namespaceStore.getContextParams() })}/> <Redirect to={workloadsURL({ query: namespaceStore.getContextParams() })}/>
</Switch> </Switch>
</MainLayout> </TabLayout>
) )
} }
} }

View File

@ -29,6 +29,7 @@ import { CronJobTriggerDialog } from "./+workloads-cronjobs/cronjob-trigger-dial
import { CustomResources } from "./+custom-resources/custom-resources"; import { CustomResources } from "./+custom-resources/custom-resources";
import { crdRoute } from "./+custom-resources"; import { crdRoute } from "./+custom-resources";
import { isAllowedResource } from "../../common/rbac"; import { isAllowedResource } from "../../common/rbac";
import { MainLayout } from "./layout/main-layout";
import { ErrorBoundary } from "./error-boundary"; import { ErrorBoundary } from "./error-boundary";
import { Terminal } from "./dock/terminal"; import { Terminal } from "./dock/terminal";
import { getHostedCluster, getHostedClusterId } from "../../common/cluster-store"; import { getHostedCluster, getHostedClusterId } from "../../common/cluster-store";
@ -60,6 +61,7 @@ export class App extends React.Component {
<I18nProvider i18n={_i18n}> <I18nProvider i18n={_i18n}>
<Router history={history}> <Router history={history}>
<ErrorBoundary> <ErrorBoundary>
<MainLayout>
<Switch> <Switch>
<Route component={Cluster} {...clusterRoute}/> <Route component={Cluster} {...clusterRoute}/>
<Route component={Nodes} {...nodesRoute}/> <Route component={Nodes} {...nodesRoute}/>
@ -77,7 +79,7 @@ export class App extends React.Component {
})} })}
<Redirect exact from="/" to={this.startURL}/> <Redirect exact from="/" to={this.startURL}/>
<Route component={NotFound}/> <Route component={NotFound}/>
</Switch> </Switch></MainLayout>
<Notifications/> <Notifications/>
<ConfirmDialog/> <ConfirmDialog/>
<KubeObjectDetails/> <KubeObjectDetails/>

View File

@ -13,11 +13,18 @@ interface Props extends React.HTMLAttributes<any>, TooltipDecoratorProps {
export class Badge extends React.Component<Props> { export class Badge extends React.Component<Props> {
render() { render() {
const { className, label, small, children, ...elemProps } = this.props; const { className, label, small, children, ...elemProps } = this.props;
return ( return <>
<span className={cssNames("Badge", { small }, className)} {...elemProps}> <span className={cssNames("Badge", { small }, className)} {...elemProps}>
{label} {label}
{children} {children}
</span> </span>
); { /**
* This is a zero-width-space. It makes there be a word seperation
* between adjacent Badge's because <span>'s are ignored for browers'
* word detection algorithmns use for determining the extent of the
* text to highlight on multi-click sequences.
*/}
&#8203;
</>
} }
} }

View File

@ -54,8 +54,7 @@
form:not([novalidate]):invalid &[type=submit]:not(.active), form:not([novalidate]):invalid &[type=submit]:not(.active),
&:disabled { &:disabled {
color: silver; opacity: 50%;
background: $buttonDisabledBackground;
pointer-events: none; pointer-events: none;
} }

View File

@ -39,7 +39,7 @@ export class ClusterStatus extends React.Component<Props> {
}); });
}) })
if (this.cluster.disconnected) { if (this.cluster.disconnected) {
await this.refreshCluster(); await this.activateCluster();
} }
} }
@ -47,13 +47,13 @@ export class ClusterStatus extends React.Component<Props> {
ipcRenderer.removeAllListeners(`kube-auth:${this.props.clusterId}`); ipcRenderer.removeAllListeners(`kube-auth:${this.props.clusterId}`);
} }
refreshCluster = async () => { activateCluster = async () => {
await clusterIpc.activate.invokeFromRenderer(this.props.clusterId); await clusterIpc.activate.invokeFromRenderer(this.props.clusterId);
} }
reconnect = async () => { reconnect = async () => {
this.isReconnecting = true; this.isReconnecting = true;
await this.refreshCluster(); await this.activateCluster();
this.isReconnecting = false; this.isReconnecting = false;
} }

View File

@ -3,7 +3,6 @@ import { ipcRenderer } from "electron";
import { matchPath, RouteProps } from "react-router"; import { matchPath, RouteProps } from "react-router";
import { buildURL, navigation } from "../../navigation"; import { buildURL, navigation } from "../../navigation";
import { clusterStore } from "../../../common/cluster-store"; import { clusterStore } from "../../../common/cluster-store";
import { clusterSettingsRoute } from "../+cluster-settings/cluster-settings.route";
export interface IClusterViewRouteParams { export interface IClusterViewRouteParams {
clusterId: string; clusterId: string;
@ -19,10 +18,7 @@ export const clusterViewURL = buildURL<IClusterViewRouteParams>(clusterViewRoute
export function getMatchedClusterId(): string { export function getMatchedClusterId(): string {
const matched = matchPath<IClusterViewRouteParams>(navigation.location.pathname, { const matched = matchPath<IClusterViewRouteParams>(navigation.location.pathname, {
exact: true, exact: true,
path: [ path: clusterViewRoute.path
clusterViewRoute.path,
clusterSettingsRoute.path,
].flat(),
}) })
if (matched) { if (matched) {
return matched.params.clusterId; return matched.params.clusterId;

View File

@ -49,12 +49,11 @@
.Badge { .Badge {
$boxSize: 17px; $boxSize: 17px;
$offset: -7px;
position: absolute; position: absolute;
bottom: 0px;
transform: translateX(-50%) translateY(50%);
font-size: $font-size-small; font-size: $font-size-small;
right: $offset;
bottom: $offset;
line-height: $boxSize; line-height: $boxSize;
min-width: $boxSize; min-width: $boxSize;
min-height: $boxSize; min-height: $boxSize;

View File

@ -1,16 +1,17 @@
import type { Cluster } from "../../../main/cluster";
import "./clusters-menu.scss" import "./clusters-menu.scss"
import { remote } from "electron" import { remote } from "electron"
import React from "react"; import React from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { _i18n } from "../../i18n"; import { _i18n } from "../../i18n";
import { t, Trans } from "@lingui/macro"; import { t, Trans } from "@lingui/macro";
import type { Cluster } from "../../../main/cluster";
import { userStore } from "../../../common/user-store"; import { userStore } from "../../../common/user-store";
import { ClusterId, clusterStore } from "../../../common/cluster-store"; import { ClusterId, clusterStore } from "../../../common/cluster-store";
import { workspaceStore } from "../../../common/workspace-store"; import { workspaceStore } from "../../../common/workspace-store";
import { ClusterIcon } from "../cluster-icon"; import { ClusterIcon } from "../cluster-icon";
import { Icon } from "../icon"; import { Icon } from "../icon";
import { autobind, cssNames, IClassName } from "../../utils"; import { cssNames, IClassName, autobind } from "../../utils";
import { Badge } from "../badge"; import { Badge } from "../badge";
import { navigate } from "../../navigation"; import { navigate } from "../../navigation";
import { addClusterURL } from "../+add-cluster"; import { addClusterURL } from "../+add-cluster";
@ -19,8 +20,8 @@ import { landingURL } from "../+landing-page";
import { Tooltip } from "../tooltip"; import { Tooltip } from "../tooltip";
import { ConfirmDialog } from "../confirm-dialog"; import { ConfirmDialog } from "../confirm-dialog";
import { clusterIpc } from "../../../common/cluster-ipc"; import { clusterIpc } from "../../../common/cluster-ipc";
import { clusterViewURL, getMatchedClusterId } from "./cluster-view.route"; import { clusterViewURL } from "./cluster-view.route";
import { DragDropContext, Draggable, DraggableProvided, Droppable, DroppableProvided, DropResult } from "react-beautiful-dnd"; import { DragDropContext, Droppable, Draggable, DropResult, DroppableProvided, DraggableProvided } from "react-beautiful-dnd";
import { dynamicPages } from "../../../extensions/register-page"; import { dynamicPages } from "../../../extensions/register-page";
interface Props { interface Props {
@ -36,6 +37,7 @@ export class ClustersMenu extends React.Component<Props> {
addCluster = () => { addCluster = () => {
navigate(addClusterURL()); navigate(addClusterURL());
clusterStore.setActive(null);
} }
showContextMenu = (cluster: Cluster) => { showContextMenu = (cluster: Cluster) => {
@ -45,6 +47,7 @@ export class ClustersMenu extends React.Component<Props> {
menu.append(new MenuItem({ menu.append(new MenuItem({
label: _i18n._(t`Settings`), label: _i18n._(t`Settings`),
click: () => { click: () => {
clusterStore.setActive(cluster.id);
navigate(clusterSettingsURL({ navigate(clusterSettingsURL({
params: { params: {
clusterId: cluster.id clusterId: cluster.id
@ -58,6 +61,7 @@ export class ClustersMenu extends React.Component<Props> {
click: async () => { click: async () => {
if (clusterStore.isActive(cluster.id)) { if (clusterStore.isActive(cluster.id)) {
navigate(landingURL()); navigate(landingURL());
clusterStore.setActive(null);
} }
await clusterIpc.disconnect.invokeFromRenderer(cluster.id); await clusterIpc.disconnect.invokeFromRenderer(cluster.id);
} }
@ -113,7 +117,9 @@ export class ClustersMenu extends React.Component<Props> {
ref={provided.innerRef} ref={provided.innerRef}
{...provided.droppableProps} {...provided.droppableProps}
> >
{clusters.map((cluster, index) => ( {clusters.map((cluster, index) => {
const isActive = cluster.id === clusterStore.activeClusterId;
return (
<Draggable draggableId={cluster.id} index={index} key={cluster.id}> <Draggable draggableId={cluster.id} index={index} key={cluster.id}>
{(provided: DraggableProvided) => ( {(provided: DraggableProvided) => (
<div <div
@ -125,14 +131,15 @@ export class ClustersMenu extends React.Component<Props> {
key={cluster.id} key={cluster.id}
showErrors={true} showErrors={true}
cluster={cluster} cluster={cluster}
isActive={cluster.id === getMatchedClusterId()} isActive={isActive}
onClick={() => this.showCluster(cluster.id)} onClick={() => this.showCluster(cluster.id)}
onContextMenu={() => this.showContextMenu(cluster)} onContextMenu={() => this.showContextMenu(cluster)}
/> />
</div> </div>
)} )}
</Draggable> </Draggable>
))} )}
)}
{provided.placeholder} {provided.placeholder}
</div> </div>
)} )}
@ -143,9 +150,9 @@ export class ClustersMenu extends React.Component<Props> {
<Tooltip targetId="add-cluster-icon"> <Tooltip targetId="add-cluster-icon">
<Trans>Add Cluster</Trans> <Trans>Add Cluster</Trans>
</Tooltip> </Tooltip>
<Icon big material="add" id="add-cluster-icon"/> <Icon big material="add" id="add-cluster-icon" />
{newContexts.size > 0 && ( {newContexts.size > 0 && (
<Badge className="counter" label={newContexts.size} tooltip={<Trans>new</Trans>}/> <Badge className="counter" label={newContexts.size} tooltip={<Trans>new</Trans>} />
)} )}
</div> </div>
<div className="dynamic-pages"> <div className="dynamic-pages">

View File

@ -48,6 +48,7 @@ export class EditorPanel extends React.Component<Props> {
onResize = () => { onResize = () => {
this.editor.resize(); this.editor.resize();
this.editor.focus();
} }
onCursorPosChange = (pos: Ace.Point) => { onCursorPosChange = (pos: Ace.Point) => {

View File

@ -62,14 +62,13 @@ export class InfoPanel extends Component<Props> {
this.error = ""; this.error = "";
this.waiting = true; this.waiting = true;
try { try {
this.result = await this.props.submit().finally(() => { this.result = await this.props.submit()
this.waiting = false;
});
if (showNotifications) Notifications.ok(this.result); if (showNotifications) Notifications.ok(this.result);
} catch (error) { } catch (error) {
this.error = error.toString(); this.error = error.toString();
if (showNotifications) Notifications.error(this.error); if (showNotifications) Notifications.error(this.error);
throw error; } finally {
this.waiting = false
} }
} }
@ -91,12 +90,13 @@ export class InfoPanel extends Component<Props> {
<> <>
{result && ( {result && (
<div className="success flex align-center"> <div className="success flex align-center">
<Icon material="done"/> <span>{result}</span> <Icon material="done" />
<span>{result}</span>
</div> </div>
)} )}
{errorInfo && ( {errorInfo && (
<div className="error flex align-center"> <div className="error flex align-center">
<Icon material="error_outline"/> <Icon material="error_outline" />
<span>{errorInfo}</span> <span>{errorInfo}</span>
</div> </div>
)} )}
@ -114,9 +114,9 @@ export class InfoPanel extends Component<Props> {
{controls} {controls}
</div> </div>
<div className="info flex gaps align-center"> <div className="info flex gaps align-center">
{waiting ? <><Spinner/> {submittingMessage}</> : this.renderInfo()} {waiting ? <><Spinner /> {submittingMessage}</> : this.renderInfo()}
</div> </div>
<Button plain label={<Trans>Cancel</Trans>} onClick={close}/> <Button plain label={<Trans>Cancel</Trans>} onClick={close} />
<Button <Button
primary active primary active
label={submitLabel} label={submitLabel}

View File

@ -152,6 +152,7 @@ export class Terminal {
onResize = () => { onResize = () => {
if (!this.isActive) return; if (!this.isActive) return;
this.fitLazy(); this.fitLazy();
this.focus();
} }
onActivate = () => { onActivate = () => {

View File

@ -44,7 +44,7 @@ export const isUrl: Validator = {
export const isPath: Validator = { export const isPath: Validator = {
condition: ({ type }) => type === "text", condition: ({ type }) => type === "text",
message: () => _i18n._(t`This field must be a path to an existing file`), message: () => _i18n._(t`This field must be a valid path`),
validate: value => !value || fse.pathExistsSync(value), validate: value => !value || fse.pathExistsSync(value),
} }

View File

@ -1,19 +1,18 @@
.MainLayout { .MainLayout {
--sidebar-max-size: 200px; --sidebar-max-size: 200px;
display: grid; display: grid;
grid-template-areas: "aside header" "aside tabs" "aside main" "aside footer"; grid-template-areas:
"aside header"
"aside tabs"
"aside main"
"aside footer";
grid-template-rows: [header] var(--main-layout-header) [tabs] min-content [main] 1fr [footer] auto; grid-template-rows: [header] var(--main-layout-header) [tabs] min-content [main] 1fr [footer] auto;
grid-template-columns: [sidebar] minmax(var(--main-layout-header), min-content) [main] 1fr; grid-template-columns: [sidebar] minmax(var(--main-layout-header), min-content) [main] 1fr;
height: 100%; height: 100%;
> .Tabs { > header {
grid-area: tabs;
background: $layoutTabsBackground;
}
header {
grid-area: header; grid-area: header;
background: $layoutBackground; background: $layoutBackground;
padding: $padding $padding * 2; padding: $padding $padding * 2;
@ -28,7 +27,7 @@
} }
} }
aside { > aside {
grid-area: aside; grid-area: aside;
position: relative; position: relative;
background: $sidebarBackground; background: $sidebarBackground;
@ -48,25 +47,14 @@
&.accessible:hover { &.accessible:hover {
width: var(--sidebar-max-size); width: var(--sidebar-max-size);
transition-delay: 750ms; transition-delay: 750ms;
box-shadow: 3px 3px 16px rgba(0, 0, 0, .35); box-shadow: 3px 3px 16px rgba(0, 0, 0, 0.35);
z-index: $zIndex-sidebar-hover; z-index: $zIndex-sidebar-hover;
} }
} }
} }
main { > main {
@include custom-scrollbar; display: contents;
$spacing: $margin * 2;
.theme-light & {
@include custom-scrollbar(dark);
}
grid-area: main;
overflow-y: scroll; // always reserve space for scrollbar (17px)
overflow-x: auto;
margin: $spacing;
margin-right: 0;
} }
footer { footer {

View File

@ -3,26 +3,16 @@ import "./main-layout.scss";
import React from "react"; import React from "react";
import { observable, reaction } from "mobx"; import { observable, reaction } from "mobx";
import { disposeOnUnmount, observer } from "mobx-react"; import { disposeOnUnmount, observer } from "mobx-react";
import { matchPath, RouteProps } from "react-router-dom";
import { createStorage, cssNames } from "../../utils"; import { createStorage, cssNames } from "../../utils";
import { Tab, Tabs } from "../tabs";
import { Sidebar } from "./sidebar"; import { Sidebar } from "./sidebar";
import { ErrorBoundary } from "../error-boundary"; import { ErrorBoundary } from "../error-boundary";
import { Dock } from "../dock"; import { Dock } from "../dock";
import { navigate, navigation } from "../../navigation";
import { getHostedCluster } from "../../../common/cluster-store"; import { getHostedCluster } from "../../../common/cluster-store";
export interface TabRoute extends RouteProps {
title: React.ReactNode;
url: string;
}
export interface MainLayoutProps { export interface MainLayoutProps {
className?: any; className?: any;
tabs?: TabRoute[];
footer?: React.ReactNode; footer?: React.ReactNode;
headerClass?: string; headerClass?: string;
contentClass?: string;
footerClass?: string; footerClass?: string;
} }
@ -35,18 +25,17 @@ export class MainLayout extends React.Component<MainLayoutProps> {
@disposeOnUnmount syncPinnedStateWithStorage = reaction( @disposeOnUnmount syncPinnedStateWithStorage = reaction(
() => this.isPinned, () => this.isPinned,
isPinned => this.storage.merge({ pinnedSidebar: isPinned }) (isPinned) => this.storage.merge({ pinnedSidebar: isPinned })
); );
toggleSidebar = () => { toggleSidebar = () => {
this.isPinned = !this.isPinned; this.isPinned = !this.isPinned;
this.isAccessible = false; this.isAccessible = false;
setTimeout(() => this.isAccessible = true, 250); setTimeout(() => (this.isAccessible = true), 250);
} };
render() { render() {
const { className, contentClass, headerClass, tabs, footer, footerClass, children } = this.props; const { className, headerClass, footer, footerClass, children } = this.props;
const routePath = navigation.location.pathname;
const cluster = getHostedCluster(); const cluster = getHostedCluster();
if (!cluster) { if (!cluster) {
return null; // fix: skip render when removing active (visible) cluster return null; // fix: skip render when removing active (visible) cluster
@ -54,37 +43,18 @@ export class MainLayout extends React.Component<MainLayoutProps> {
return ( return (
<div className={cssNames("MainLayout", className)}> <div className={cssNames("MainLayout", className)}>
<header className={cssNames("flex gaps align-center", headerClass)}> <header className={cssNames("flex gaps align-center", headerClass)}>
<span className="cluster"> <span className="cluster">{cluster.preferences.clusterName || cluster.contextName}</span>
{cluster.preferences.clusterName || cluster.contextName}
</span>
</header> </header>
<aside className={cssNames("flex column", { pinned: this.isPinned, accessible: this.isAccessible })}> <aside className={cssNames("flex column", { pinned: this.isPinned, accessible: this.isAccessible })}>
<Sidebar <Sidebar className="box grow" isPinned={this.isPinned} toggle={this.toggleSidebar} />
className="box grow"
isPinned={this.isPinned}
toggle={this.toggleSidebar}
/>
</aside> </aside>
{tabs && ( <main>
<Tabs center onChange={url => navigate(url)}> <ErrorBoundary>{children}</ErrorBoundary>
{tabs.map(({ title, path, url, ...routeProps }) => {
const isActive = !!matchPath(routePath, { path, ...routeProps });
return <Tab key={url} label={title} value={url} active={isActive}/>
})}
</Tabs>
)}
<main className={contentClass}>
<ErrorBoundary>
{children}
</ErrorBoundary>
</main> </main>
<footer className={footerClass}> <footer className={footerClass}>{footer === undefined ? <Dock /> : footer}</footer>
{footer === undefined ? <Dock/> : footer}
</footer>
</div> </div>
); );
} }

View File

@ -1,4 +1,4 @@
import type { TabRoute } from "./main-layout"; import type { TabRoute } from "./tab-layout";
import "./sidebar.scss"; import "./sidebar.scss";
import React from "react"; import React from "react";
@ -44,21 +44,21 @@ interface Props {
@observer @observer
export class Sidebar extends React.Component<Props> { export class Sidebar extends React.Component<Props> {
async componentDidMount() { async componentDidMount() {
if (!crdStore.isLoaded && isAllowedResource('customresourcedefinitions')) { if (!crdStore.isLoaded && isAllowedResource("customresourcedefinitions")) {
crdStore.loadAll() crdStore.loadAll();
} }
} }
renderCustomResources() { renderCustomResources() {
return Object.entries(crdStore.groups).map(([group, crds]) => { return Object.entries(crdStore.groups).map(([group, crds]) => {
const submenus = crds.map(crd => { const submenus = crds.map((crd) => {
return { return {
title: crd.getResourceKind(), title: crd.getResourceKind(),
component: CrdList, component: CrdList,
url: crd.getResourceUrl(), url: crd.getResourceUrl(),
path: crdResourcesRoute.path, path: crdResourcesRoute.path,
} };
}) });
return ( return (
<SidebarNavItem <SidebarNavItem
key={group} key={group}
@ -68,8 +68,8 @@ export class Sidebar extends React.Component<Props> {
subMenus={submenus} subMenus={submenus}
text={group} text={group}
/> />
) );
}) });
} }
render() { render() {
@ -80,7 +80,7 @@ export class Sidebar extends React.Component<Props> {
<div className={cssNames("Sidebar flex column", className, { pinned: isPinned })}> <div className={cssNames("Sidebar flex column", className, { pinned: isPinned })}>
<div className="header flex align-center"> <div className="header flex align-center">
<NavLink exact to="/" className="box grow"> <NavLink exact to="/" className="box grow">
<Icon svg="logo-full" className="logo-icon"/> <Icon svg="logo-full" className="logo-icon" />
<div className="logo-text">Lens</div> <div className="logo-text">Lens</div>
</NavLink> </NavLink>
<Icon <Icon
@ -94,17 +94,17 @@ export class Sidebar extends React.Component<Props> {
<div className="sidebar-nav flex column box grow-fixed"> <div className="sidebar-nav flex column box grow-fixed">
<SidebarNavItem <SidebarNavItem
id="cluster" id="cluster"
isHidden={!isAllowedResource('nodes')} isHidden={!isAllowedResource("nodes")}
url={clusterURL()} url={clusterURL()}
text={<Trans>Cluster</Trans>} text={<Trans>Cluster</Trans>}
icon={<Icon svg="kube"/>} icon={<Icon svg="kube" />}
/> />
<SidebarNavItem <SidebarNavItem
id="nodes" id="nodes"
isHidden={!isAllowedResource('nodes')} isHidden={!isAllowedResource("nodes")}
url={nodesURL()} url={nodesURL()}
text={<Trans>Nodes</Trans>} text={<Trans>Nodes</Trans>}
icon={<Icon svg="nodes"/>} icon={<Icon svg="nodes" />}
/> />
<SidebarNavItem <SidebarNavItem
id="workloads" id="workloads"
@ -113,7 +113,7 @@ export class Sidebar extends React.Component<Props> {
routePath={workloadsRoute.path} routePath={workloadsRoute.path}
subMenus={Workloads.tabRoutes} subMenus={Workloads.tabRoutes}
text={<Trans>Workloads</Trans>} text={<Trans>Workloads</Trans>}
icon={<Icon svg="workloads"/>} icon={<Icon svg="workloads" />}
/> />
<SidebarNavItem <SidebarNavItem
id="config" id="config"
@ -122,7 +122,7 @@ export class Sidebar extends React.Component<Props> {
routePath={configRoute.path} routePath={configRoute.path}
subMenus={Config.tabRoutes} subMenus={Config.tabRoutes}
text={<Trans>Configuration</Trans>} text={<Trans>Configuration</Trans>}
icon={<Icon material="list"/>} icon={<Icon material="list" />}
/> />
<SidebarNavItem <SidebarNavItem
id="networks" id="networks"
@ -131,7 +131,7 @@ export class Sidebar extends React.Component<Props> {
routePath={networkRoute.path} routePath={networkRoute.path}
subMenus={Network.tabRoutes} subMenus={Network.tabRoutes}
text={<Trans>Network</Trans>} text={<Trans>Network</Trans>}
icon={<Icon material="device_hub"/>} icon={<Icon material="device_hub" />}
/> />
<SidebarNavItem <SidebarNavItem
id="storage" id="storage"
@ -139,22 +139,22 @@ export class Sidebar extends React.Component<Props> {
url={storageURL({ query })} url={storageURL({ query })}
routePath={storageRoute.path} routePath={storageRoute.path}
subMenus={Storage.tabRoutes} subMenus={Storage.tabRoutes}
icon={<Icon svg="storage"/>} icon={<Icon svg="storage" />}
text={<Trans>Storage</Trans>} text={<Trans>Storage</Trans>}
/> />
<SidebarNavItem <SidebarNavItem
id="namespaces" id="namespaces"
isHidden={!isAllowedResource('namespaces')} isHidden={!isAllowedResource("namespaces")}
url={namespacesURL()} url={namespacesURL()}
icon={<Icon material="layers"/>} icon={<Icon material="layers" />}
text={<Trans>Namespaces</Trans>} text={<Trans>Namespaces</Trans>}
/> />
<SidebarNavItem <SidebarNavItem
id="events" id="events"
isHidden={!isAllowedResource('events')} isHidden={!isAllowedResource("events")}
url={eventsURL({ query })} url={eventsURL({ query })}
routePath={eventRoute.path} routePath={eventRoute.path}
icon={<Icon material="access_time"/>} icon={<Icon material="access_time" />}
text={<Trans>Events</Trans>} text={<Trans>Events</Trans>}
/> />
<SidebarNavItem <SidebarNavItem
@ -162,7 +162,7 @@ export class Sidebar extends React.Component<Props> {
url={appsURL({ query })} url={appsURL({ query })}
subMenus={Apps.tabRoutes} subMenus={Apps.tabRoutes}
routePath={appsRoute.path} routePath={appsRoute.path}
icon={<Icon material="apps"/>} icon={<Icon material="apps" />}
text={<Trans>Apps</Trans>} text={<Trans>Apps</Trans>}
/> />
<SidebarNavItem <SidebarNavItem
@ -170,16 +170,16 @@ export class Sidebar extends React.Component<Props> {
url={usersManagementURL({ query })} url={usersManagementURL({ query })}
routePath={usersManagementRoute.path} routePath={usersManagementRoute.path}
subMenus={UserManagement.tabRoutes} subMenus={UserManagement.tabRoutes}
icon={<Icon material="security"/>} icon={<Icon material="security" />}
text={<Trans>Access Control</Trans>} text={<Trans>Access Control</Trans>}
/> />
<SidebarNavItem <SidebarNavItem
id="custom-resources" id="custom-resources"
isHidden={!isAllowedResource('customresourcedefinitions')} isHidden={!isAllowedResource("customresourcedefinitions")}
url={crdURL()} url={crdURL()}
subMenus={CustomResources.tabRoutes} subMenus={CustomResources.tabRoutes}
routePath={crdRoute.path} routePath={crdRoute.path}
icon={<Icon material="extension"/>} icon={<Icon material="extension" />}
text={<Trans>Custom Resources</Trans>} text={<Trans>Custom Resources</Trans>}
> >
{this.renderCustomResources()} {this.renderCustomResources()}
@ -199,7 +199,7 @@ export class Sidebar extends React.Component<Props> {
</div> </div>
</div> </div>
</SidebarContext.Provider> </SidebarContext.Provider>
) );
} }
} }
@ -216,7 +216,10 @@ interface SidebarNavItemProps {
const navItemStorage = createStorage<[string, boolean][]>("sidebar_menu_item", []); const navItemStorage = createStorage<[string, boolean][]>("sidebar_menu_item", []);
const navItemState = observable.map<string, boolean>(navItemStorage.get()); const navItemState = observable.map<string, boolean>(navItemStorage.get());
reaction(() => [...navItemState], value => navItemStorage.set(value)); reaction(
() => [...navItemState],
(value) => navItemStorage.set(value)
);
@observer @observer
class SidebarNavItem extends React.Component<SidebarNavItemProps> { class SidebarNavItem extends React.Component<SidebarNavItemProps> {
@ -229,15 +232,15 @@ class SidebarNavItem extends React.Component<SidebarNavItemProps> {
toggleSubMenu = () => { toggleSubMenu = () => {
navItemState.set(this.props.id, !this.isExpanded); navItemState.set(this.props.id, !this.isExpanded);
} };
isActive = () => { isActive = () => {
const { routePath, url } = this.props; const { routePath, url } = this.props;
const { pathname } = navigation.location; const { pathname } = navigation.location;
return !!matchPath(pathname, { return !!matchPath(pathname, {
path: routePath || url path: routePath || url,
}); });
} };
render() { render() {
const { id, isHidden, subMenus = [], icon, text, url, children, className } = this.props; const { id, isHidden, subMenus = [], icon, text, url, children, className } = this.props;
@ -252,10 +255,7 @@ class SidebarNavItem extends React.Component<SidebarNavItemProps> {
<div className={cssNames("nav-item", { active: isActive })} onClick={this.toggleSubMenu}> <div className={cssNames("nav-item", { active: isActive })} onClick={this.toggleSubMenu}>
{icon} {icon}
<span className="link-text">{text}</span> <span className="link-text">{text}</span>
<Icon <Icon className="expand-icon" material={this.isExpanded ? "keyboard_arrow_up" : "keyboard_arrow_down"} />
className="expand-icon"
material={this.isExpanded ? "keyboard_arrow_up" : "keyboard_arrow_down"}
/>
</div> </div>
<ul className={cssNames("sub-menu", { active: isActive })}> <ul className={cssNames("sub-menu", { active: isActive })}>
{subMenus.map(({ title, url }) => ( {subMenus.map(({ title, url }) => (
@ -265,18 +265,18 @@ class SidebarNavItem extends React.Component<SidebarNavItemProps> {
))} ))}
{React.Children.toArray(children).map((child: React.ReactElement<any>) => { {React.Children.toArray(children).map((child: React.ReactElement<any>) => {
return React.cloneElement(child, { return React.cloneElement(child, {
className: cssNames(child.props.className, { visible: this.isExpanded }) className: cssNames(child.props.className, { visible: this.isExpanded }),
}); });
})} })}
</ul> </ul>
</div> </div>
) );
} }
return ( return (
<NavLink className={cssNames("SidebarNavItem", className)} to={url} isActive={this.isActive}> <NavLink className={cssNames("SidebarNavItem", className)} to={url} isActive={this.isActive}>
{icon} {icon}
<span className="link-text">{text}</span> <span className="link-text">{text}</span>
</NavLink> </NavLink>
) );
} }
} }

View File

@ -0,0 +1,25 @@
.TabLayout {
display: contents;
> .Tabs {
grid-area: tabs;
background: $layoutTabsBackground;
}
main {
@include custom-scrollbar;
$spacing: $margin * 2;
.theme-light & {
@include custom-scrollbar(dark);
}
grid-area: main;
overflow-y: scroll; // always reserve space for scrollbar (17px)
overflow-x: auto;
margin: $spacing;
margin-right: 0;
}
}

View File

@ -0,0 +1,45 @@
import "./tab-layout.scss";
import React, { ReactNode } from "react";
import { matchPath, RouteProps } from "react-router-dom";
import { observer } from "mobx-react";
import { cssNames } from "../../utils";
import { Tab, Tabs } from "../tabs";
import { ErrorBoundary } from "../error-boundary";
import { navigate, navigation } from "../../navigation";
import { getHostedCluster } from "../../../common/cluster-store";
export interface TabRoute extends RouteProps {
title: React.ReactNode;
url: string;
}
interface Props {
children: ReactNode;
className?: any;
tabs?: TabRoute[];
contentClass?: string;
}
export const TabLayout = observer(({ className, contentClass, tabs, children }: Props) => {
const routePath = navigation.location.pathname;
const cluster = getHostedCluster();
if (!cluster) {
return null; // fix: skip render when removing active (visible) cluster
}
return (
<div className={cssNames("TabLayout", className)}>
{tabs && (
<Tabs center onChange={(url) => navigate(url)}>
{tabs.map(({ title, path, url, ...routeProps }) => {
const isActive = !!matchPath(routePath, { path, ...routeProps });
return <Tab key={url} label={title} value={url} active={isActive} />;
})}
</Tabs>
)}
<main className={contentClass}>
<ErrorBoundary>{children}</ErrorBoundary>
</main>
</div>
);
});

View File

@ -3,10 +3,11 @@
position: absolute; position: absolute;
right: 0; right: 0;
bottom: 0; top: 0;
padding: $padding * 2; padding: $padding * 2;
max-height: 100vh; max-height: 100vh;
z-index: 100000; z-index: 100000;
height: min-content!important;
&:empty { &:empty {
display: none; display: none;

View File

@ -15,6 +15,7 @@ export enum TooltipPosition {
export interface TooltipProps { export interface TooltipProps {
targetId: string; // html-id of target element to bind for targetId: string; // html-id of target element to bind for
tooltipOnParentHover?: boolean; // detect hover on parent of target
visible?: boolean; // initial visibility visible?: boolean; // initial visibility
offset?: number; // offset from target element in pixels (all sides) offset?: number; // offset from target element in pixels (all sides)
usePortal?: boolean; // renders element outside of parent (in body), disable for "easy-styling", default: true usePortal?: boolean; // renders element outside of parent (in body), disable for "easy-styling", default: true
@ -50,14 +51,22 @@ export class Tooltip extends React.Component<TooltipProps> {
return document.getElementById(this.props.targetId) return document.getElementById(this.props.targetId)
} }
get hoverTarget(): HTMLElement {
if (this.props.tooltipOnParentHover) {
return this.targetElem.parentElement
}
return this.targetElem
}
componentDidMount() { componentDidMount() {
this.targetElem.addEventListener("mouseenter", this.onEnterTarget) this.hoverTarget.addEventListener("mouseenter", this.onEnterTarget)
this.targetElem.addEventListener("mouseleave", this.onLeaveTarget) this.hoverTarget.addEventListener("mouseleave", this.onLeaveTarget)
} }
componentWillUnmount() { componentWillUnmount() {
this.targetElem.removeEventListener("mouseenter", this.onEnterTarget) this.hoverTarget.removeEventListener("mouseenter", this.onEnterTarget)
this.targetElem.removeEventListener("mouseleave", this.onLeaveTarget) this.hoverTarget.removeEventListener("mouseleave", this.onLeaveTarget)
} }
@autobind() @autobind()

View File

@ -8,6 +8,11 @@ import uniqueId from "lodash/uniqueId"
export interface TooltipDecoratorProps { export interface TooltipDecoratorProps {
tooltip?: ReactNode | Omit<TooltipProps, "targetId">; tooltip?: ReactNode | Omit<TooltipProps, "targetId">;
/**
* forces tooltip to detect the target's parent for mouse events. This is
* useful for displaying tooltips even when the target is "disabled"
*/
tooltipOverrideDisabled?: boolean;
} }
export function withTooltip<T extends React.ComponentType<any>>(Target: T): T { export function withTooltip<T extends React.ComponentType<any>>(Target: T): T {
@ -17,22 +22,25 @@ export function withTooltip<T extends React.ComponentType<any>>(Target: T): T {
protected tooltipId = uniqueId("tooltip_target_"); protected tooltipId = uniqueId("tooltip_target_");
render() { render() {
const { tooltip, ...targetProps } = this.props; const { tooltip, tooltipOverrideDisabled, ...targetProps } = this.props;
if (tooltip) { if (tooltip) {
const tooltipId = targetProps.id || this.tooltipId; const tooltipId = targetProps.id || this.tooltipId;
const tooltipProps: TooltipProps = { const tooltipProps: TooltipProps = {
targetId: tooltipId, targetId: tooltipId,
tooltipOnParentHover: tooltipOverrideDisabled,
...(isReactNode(tooltip) ? { children: tooltip } : tooltip), ...(isReactNode(tooltip) ? { children: tooltip } : tooltip),
}; };
targetProps.id = tooltipId; targetProps.id = tooltipId;
targetProps.children = ( targetProps.children = (
<> <>
<div>
{targetProps.children} {targetProps.children}
<Tooltip {...tooltipProps}/> </div>
<Tooltip {...tooltipProps} />
</> </>
) )
} }
return <Target {...targetProps as any}/>; return <Target {...targetProps as any} />;
} }
} }

View File

@ -2,37 +2,47 @@
Here you can find description of changes we've built into each release. While we try our best to make each upgrade automatic and as smooth as possible, there may be some cases where you might need to do something to ensure the application works smoothly. So please read through the release highlights! Here you can find description of changes we've built into each release. While we try our best to make each upgrade automatic and as smooth as possible, there may be some cases where you might need to do something to ensure the application works smoothly. So please read through the release highlights!
## 3.6.0-rc.2 (current version) ## 3.6.4 (current version)
- Refresh input values on cluster change - Fix: deleted namespace does not get auto unselected
- Update logo - Get focus to dock tab (terminal & resource editor) content after resize
- Fix margins in cluster menu - Downloading kubectl binary does not block dashboard opening anymore
- Fix background image of What's New page on white theme
## 3.6.0-rc.1 ## 3.6.3
- Fix app crash on certain situations when opening ingress details
- Reduce app minimum size to support >= 800 x 600 resolution displays
- Fix app crash when service account has imagePullSecrets defined but the actual secret is missing
- Fix words in labels to be selectable either by hovering or double-clicking
**Known issues**
- Kubectl exec command does not work in terminal against clusters that are behind a load balancer and require Host header in request, for example Rancher clusters.
## 3.6.2
- Fix terminal connection opening
**Known issues**
- Kubectl exec command does not work in terminal against clusters that are behind a load balancer and require Host header in request, for example Rancher clusters.
## 3.6.1
- Inject Host header to k8s client requests
- Remove extra refreshEvents polling
- Fix windows installer when app directory removed manually
**Known issues**
- Kubectl exec command does not work in terminal against clusters that are behind a load balancer and require Host header in request, for example Rancher clusters.
## 3.6.0
- Allow user to configure directory where Kubectl binaries are downloaded - Allow user to configure directory where Kubectl binaries are downloaded
- Allow user to configure path to Kubectl binary, instead of using bundled Kubectl - Allow user to configure path to Kubectl binary, instead of using bundled Kubectl
- Log application logs also to log file - Allow user to select Kubeconfig from filesystem
- Restrict file permissions to only the user for pasted kubeconfigs - Show the path of the cluster's Kubeconfig in cluster settings
- Close Preferences and Cluster Setting on Esc keypress - Store reference to added Kubeconfig files
- Update logo
- Update Kubectl versions used with Lens - Update Kubectl versions used with Lens
- Update Helm binary version - Update Helm binary version
- Fix: Update CRD api to use preferred version and implement v1 differences
- Fix: Allow to drag and drop cluster icons
- Fix: Wider version select box for Helm chart installation
- Fix: Reload only active dashboard view, not the whole app window
- Fix cluster icon margins
- Fix: Reconnect non-accessible clusters on reconnect
- Fix: Bundle Kubectl and Helm binaries
- Fix: Remove double copyright
## 3.6.0-beta.2
- Fix: too narrow sidebar without clusters
- Fix app crash when iterating Events without 'kind' property defined
- Detect non-functional bundled kubectl
## 3.6.0-beta.1
- Allow user to select Kubeconfig from filesystem
- Store reference to added Kubeconfig files
- Show the path of the cluster's Kubeconfig in cluster settings
- Add support for PodDisruptionBudgets - Add support for PodDisruptionBudgets
- Add port-forwarding for containers in pod - Add port-forwarding for containers in pod
- Add shortcut keys to menu items - Add shortcut keys to menu items
@ -42,6 +52,30 @@ Here you can find description of changes we've built into each release. While we
- Allow to trigger cronjobs - Allow to trigger cronjobs
- Show devtools in menu - Show devtools in menu
- Open last active cluster as default - Open last active cluster as default
- Log application logs also to log file
- Fix Dialog Esc keypress behavior
- Set new workspace name restrictions
- Fix cluster's apiUrl
- Fix: Cluster dashboard not rendered
- Fix app reload in cluster settings
- Fix proxy kubeconfig file permissions
- Move verbose log lines to silly level
- Add path to auth proxy url if present in cluster url
- Fix path validation message
- Fix: Refresh input values on cluster change
- Fix margins in cluster menu
- Restrict file permissions to only the user for pasted kubeconfigs
- Close Preferences and Cluster Setting on Esc keypress
- Fix: Update CRD api to use preferred version and implement v1 differences
- Fix: Allow to drag and drop cluster icons
- Fix: Wider version select box for Helm chart installation
- Fix: Reload only active dashboard view, not the whole app window
- Fix cluster icon margins
- Fix: Reconnect non-accessible clusters on reconnect
- Fix: Remove double copyright
- Fix: too narrow sidebar without clusters
- Fix app crash when iterating Events without 'kind' property defined
- Detect non-functional bundled kubectl
- Fix format duration rounding days error - Fix format duration rounding days error
- Handle unsupported resources properly after they've been created from editor - Handle unsupported resources properly after they've been created from editor
- Fix CRD api parsing - Fix CRD api parsing
@ -62,7 +96,7 @@ Here you can find description of changes we've built into each release. While we
## 3.5.2 ## 3.5.2
- Fix application not opening properly in some cases by catching and logging error from shell sync. - Fix application not opening properly in some cases by catching and logging error from shell sync.
## 3.5.1 (current version) ## 3.5.1
- Fix kubernetes api requests to work with non-"namespaces" pathnames - Fix kubernetes api requests to work with non-"namespaces" pathnames
- Fix: Handle invalid metrics responses properly - Fix: Handle invalid metrics responses properly
- Fix: Display namespace defined in kubeconfig always in the namespace selector - Fix: Display namespace defined in kubeconfig always in the namespace selector

View File

@ -2161,6 +2161,13 @@
resolved "https://registry.yarnpkg.com/@types/source-list-map/-/source-list-map-0.1.2.tgz#0078836063ffaf17412349bba364087e0ac02ec9" resolved "https://registry.yarnpkg.com/@types/source-list-map/-/source-list-map-0.1.2.tgz#0078836063ffaf17412349bba364087e0ac02ec9"
integrity sha512-K5K+yml8LTo9bWJI/rECfIPrGgxdpeNbj+d53lwN4QjW1MCwlkhUms+gtdzigTeUyBr09+u8BwOIY3MXvHdcsA== integrity sha512-K5K+yml8LTo9bWJI/rECfIPrGgxdpeNbj+d53lwN4QjW1MCwlkhUms+gtdzigTeUyBr09+u8BwOIY3MXvHdcsA==
"@types/spdy@^3.4.4":
version "3.4.4"
resolved "https://registry.yarnpkg.com/@types/spdy/-/spdy-3.4.4.tgz#3282fd4ad8c4603aa49f7017dd520a08a345b2bc"
integrity sha512-N9LBlbVRRYq6HgYpPkqQc3a9HJ/iEtVZToW6xlTtJiMhmRJ7jJdV7TaZQJw/Ve/1ePUsQiCTDc4JMuzzag94GA==
dependencies:
"@types/node" "*"
"@types/stack-utils@^1.0.1": "@types/stack-utils@^1.0.1":
version "1.0.1" version "1.0.1"
resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-1.0.1.tgz#0a851d3bd96498fa25c33ab7278ed3bd65f06c3e" resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-1.0.1.tgz#0a851d3bd96498fa25c33ab7278ed3bd65f06c3e"
@ -5927,6 +5934,11 @@ growly@^1.3.0:
resolved "https://registry.yarnpkg.com/growly/-/growly-1.3.0.tgz#f10748cbe76af964b7c96c93c6bcc28af120c081" resolved "https://registry.yarnpkg.com/growly/-/growly-1.3.0.tgz#f10748cbe76af964b7c96c93c6bcc28af120c081"
integrity sha1-8QdIy+dq+WS3yWyTxrzCivEgwIE= integrity sha1-8QdIy+dq+WS3yWyTxrzCivEgwIE=
handle-thing@^2.0.0:
version "2.0.1"
resolved "https://registry.yarnpkg.com/handle-thing/-/handle-thing-2.0.1.tgz#857f79ce359580c340d43081cc648970d0bb234e"
integrity sha512-9Qn4yBxelxoh2Ow62nP+Ka/kMnOXRi8BXnRaUwezLNhqelnN49xKz4F/dPP8OYLxLxq6JDtZb2i9XznUQbNPTg==
handlebars@^4.7.6: handlebars@^4.7.6:
version "4.7.6" version "4.7.6"
resolved "https://registry.yarnpkg.com/handlebars/-/handlebars-4.7.6.tgz#d4c05c1baf90e9945f77aa68a7a219aa4a7df74e" resolved "https://registry.yarnpkg.com/handlebars/-/handlebars-4.7.6.tgz#d4c05c1baf90e9945f77aa68a7a219aa4a7df74e"
@ -6113,6 +6125,16 @@ hosted-git-info@^3.0.4:
dependencies: dependencies:
lru-cache "^5.1.1" lru-cache "^5.1.1"
hpack.js@^2.1.6:
version "2.1.6"
resolved "https://registry.yarnpkg.com/hpack.js/-/hpack.js-2.1.6.tgz#87774c0949e513f42e84575b3c45681fade2a0b2"
integrity sha1-h3dMCUnlE/QuhFdbPEVoH63ioLI=
dependencies:
inherits "^2.0.1"
obuf "^1.0.0"
readable-stream "^2.0.1"
wbuf "^1.1.0"
html-encoding-sniffer@^2.0.1: html-encoding-sniffer@^2.0.1:
version "2.0.1" version "2.0.1"
resolved "https://registry.yarnpkg.com/html-encoding-sniffer/-/html-encoding-sniffer-2.0.1.tgz#42a6dc4fd33f00281176e8b23759ca4e4fa185f3" resolved "https://registry.yarnpkg.com/html-encoding-sniffer/-/html-encoding-sniffer-2.0.1.tgz#42a6dc4fd33f00281176e8b23759ca4e4fa185f3"
@ -6175,6 +6197,11 @@ http-cache-semantics@^4.0.0:
resolved "https://registry.yarnpkg.com/http-cache-semantics/-/http-cache-semantics-4.1.0.tgz#49e91c5cbf36c9b94bcfcd71c23d5249ec74e390" resolved "https://registry.yarnpkg.com/http-cache-semantics/-/http-cache-semantics-4.1.0.tgz#49e91c5cbf36c9b94bcfcd71c23d5249ec74e390"
integrity sha512-carPklcUh7ROWRK7Cv27RPtdhYhUsela/ue5/jKzjegVvXDqM2ILE9Q2BGn9JZJh1g87cp56su/FgQSzcWS8cQ== integrity sha512-carPklcUh7ROWRK7Cv27RPtdhYhUsela/ue5/jKzjegVvXDqM2ILE9Q2BGn9JZJh1g87cp56su/FgQSzcWS8cQ==
http-deceiver@^1.2.7:
version "1.2.7"
resolved "https://registry.yarnpkg.com/http-deceiver/-/http-deceiver-1.2.7.tgz#fa7168944ab9a519d337cb0bec7284dc3e723d87"
integrity sha1-+nFolEq5pRnTN8sL7HKE3D5yPYc=
http-proxy@^1.18.1: http-proxy@^1.18.1:
version "1.18.1" version "1.18.1"
resolved "https://registry.yarnpkg.com/http-proxy/-/http-proxy-1.18.1.tgz#401541f0534884bbf95260334e72f88ee3976549" resolved "https://registry.yarnpkg.com/http-proxy/-/http-proxy-1.18.1.tgz#401541f0534884bbf95260334e72f88ee3976549"
@ -8646,6 +8673,11 @@ object.pick@^1.3.0:
dependencies: dependencies:
isobject "^3.0.1" isobject "^3.0.1"
obuf@^1.0.0, obuf@^1.1.2:
version "1.1.2"
resolved "https://registry.yarnpkg.com/obuf/-/obuf-1.1.2.tgz#09bea3343d41859ebd446292d11c9d4db619084e"
integrity sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==
oidc-token-hash@^3.0.1: oidc-token-hash@^3.0.1:
version "3.0.2" version "3.0.2"
resolved "https://registry.yarnpkg.com/oidc-token-hash/-/oidc-token-hash-3.0.2.tgz#5bd4716cc48ad433f4e4e99276811019b165697e" resolved "https://registry.yarnpkg.com/oidc-token-hash/-/oidc-token-hash-3.0.2.tgz#5bd4716cc48ad433f4e4e99276811019b165697e"
@ -9763,7 +9795,7 @@ read-pkg@^5.2.0:
string_decoder "~1.1.1" string_decoder "~1.1.1"
util-deprecate "~1.0.1" util-deprecate "~1.0.1"
readable-stream@^3.1.1, readable-stream@^3.6.0: readable-stream@^3.0.6, readable-stream@^3.1.1, readable-stream@^3.6.0:
version "3.6.0" version "3.6.0"
resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.0.tgz#337bbda3adc0706bd3e024426a286d4b4b2c9198" resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.0.tgz#337bbda3adc0706bd3e024426a286d4b4b2c9198"
integrity sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA== integrity sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==
@ -10294,6 +10326,11 @@ scss-tokenizer@^0.2.3:
js-base64 "^2.1.8" js-base64 "^2.1.8"
source-map "^0.4.2" source-map "^0.4.2"
select-hose@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/select-hose/-/select-hose-2.0.0.tgz#625d8658f865af43ec962bfc376a37359a4994ca"
integrity sha1-Yl2GWPhlr0Psliv8N2o3NZpJlMo=
semver-compare@^1.0.0: semver-compare@^1.0.0:
version "1.0.0" version "1.0.0"
resolved "https://registry.yarnpkg.com/semver-compare/-/semver-compare-1.0.0.tgz#0dee216a1c941ab37e9efb1788f6afc5ff5537fc" resolved "https://registry.yarnpkg.com/semver-compare/-/semver-compare-1.0.0.tgz#0dee216a1c941ab37e9efb1788f6afc5ff5537fc"
@ -10603,6 +10640,29 @@ spdx-license-ids@^3.0.0:
resolved "https://registry.yarnpkg.com/spdx-license-ids/-/spdx-license-ids-3.0.5.tgz#3694b5804567a458d3c8045842a6358632f62654" resolved "https://registry.yarnpkg.com/spdx-license-ids/-/spdx-license-ids-3.0.5.tgz#3694b5804567a458d3c8045842a6358632f62654"
integrity sha512-J+FWzZoynJEXGphVIS+XEh3kFSjZX/1i9gFBaWQcB+/tmpe2qUsSBABpcxqxnAxFdiUFEgAX1bjYGQvIZmoz9Q== integrity sha512-J+FWzZoynJEXGphVIS+XEh3kFSjZX/1i9gFBaWQcB+/tmpe2qUsSBABpcxqxnAxFdiUFEgAX1bjYGQvIZmoz9Q==
spdy-transport@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/spdy-transport/-/spdy-transport-3.0.0.tgz#00d4863a6400ad75df93361a1608605e5dcdcf31"
integrity sha512-hsLVFE5SjA6TCisWeJXFKniGGOpBgMLmerfO2aCyCU5s7nJ/rpAepqmFifv/GCbSbueEeAJJnmSQ2rKC/g8Fcw==
dependencies:
debug "^4.1.0"
detect-node "^2.0.4"
hpack.js "^2.1.6"
obuf "^1.1.2"
readable-stream "^3.0.6"
wbuf "^1.7.3"
spdy@^4.0.2:
version "4.0.2"
resolved "https://registry.yarnpkg.com/spdy/-/spdy-4.0.2.tgz#b74f466203a3eda452c02492b91fb9e84a27677b"
integrity sha512-r46gZQZQV+Kl9oItvl1JZZqJKGr+oEkB08A6BzkiR7593/7IbtuncXHd2YoYeTsG4157ZssMu9KYvUHLcjcDoA==
dependencies:
debug "^4.1.0"
handle-thing "^2.0.0"
http-deceiver "^1.2.7"
select-hose "^2.0.0"
spdy-transport "^3.0.0"
spectron@11.0.0: spectron@11.0.0:
version "11.0.0" version "11.0.0"
resolved "https://registry.yarnpkg.com/spectron/-/spectron-11.0.0.tgz#79d785e6b8898638e77b5186711e3910ed4ca09b" resolved "https://registry.yarnpkg.com/spectron/-/spectron-11.0.0.tgz#79d785e6b8898638e77b5186711e3910ed4ca09b"
@ -11793,6 +11853,13 @@ watchpack@^1.6.1:
chokidar "^3.4.0" chokidar "^3.4.0"
watchpack-chokidar2 "^2.0.0" watchpack-chokidar2 "^2.0.0"
wbuf@^1.1.0, wbuf@^1.7.3:
version "1.7.3"
resolved "https://registry.yarnpkg.com/wbuf/-/wbuf-1.7.3.tgz#c1d8d149316d3ea852848895cb6a0bfe887b87df"
integrity sha512-O84QOnr0icsbFGLS0O3bI5FswxzRr8/gHwWkDlQFskhSPryQXvrTMxjxGP4+iWYoauLoBvfDpkrOauZ+0iZpDA==
dependencies:
minimalistic-assert "^1.0.0"
wcwidth@^1.0.1: wcwidth@^1.0.1:
version "1.0.1" version "1.0.1"
resolved "https://registry.yarnpkg.com/wcwidth/-/wcwidth-1.0.1.tgz#f0b0dcf915bc5ff1528afadb2c0e17b532da2fe8" resolved "https://registry.yarnpkg.com/wcwidth/-/wcwidth-1.0.1.tgz#f0b0dcf915bc5ff1528afadb2c0e17b532da2fe8"