diff --git a/src/common/cluster-ipc.ts b/src/common/cluster-ipc.ts index f0f5ff8b54..577819c995 100644 --- a/src/common/cluster-ipc.ts +++ b/src/common/cluster-ipc.ts @@ -17,12 +17,4 @@ export const clusterIpc = { return clusterStore.getById(clusterId)?.disconnect(); }, }), - - reconnect: createIpcChannel({ - channel: "cluster:reconnect", - handle: (clusterId: ClusterId = clusterStore.activeClusterId) => { - tracker.event("cluster", "reconnect"); - return clusterStore.getById(clusterId)?.reconnect(); - }, - }), } \ No newline at end of file diff --git a/src/common/cluster-store.ts b/src/common/cluster-store.ts index 7961bc7e63..723cc5bfb9 100644 --- a/src/common/cluster-store.ts +++ b/src/common/cluster-store.ts @@ -197,3 +197,8 @@ export class ClusterStore extends BaseStore { } export const clusterStore = ClusterStore.getInstance(); + +export function getHostedCluster(): Cluster { + const clusterId = location.hostname.split(".")[0]; + return clusterStore.getById(clusterId); +} diff --git a/src/main/cluster-manager.ts b/src/main/cluster-manager.ts index 1b20b10394..0bbd280c1e 100644 --- a/src/main/cluster-manager.ts +++ b/src/main/cluster-manager.ts @@ -12,9 +12,8 @@ export class ClusterManager { autorun(() => { clusterStore.clusters.forEach(cluster => { if (!cluster.initialized) { - logger.info(`[CLUSTER-MANAGER]: initializing cluster`, cluster.getMeta()); - cluster.init(port); // connect to kube-auth-proxy, context handling - cluster.bindEvents(); // send push-updates to renderer + logger.info(`[CLUSTER-MANAGER]: init cluster`, cluster.getMeta()); + cluster.init(port); } }); }); @@ -25,10 +24,7 @@ export class ClusterManager { if (removedClusters.length > 0) { const meta = removedClusters.map(cluster => cluster.getMeta()); logger.info(`[CLUSTER-MANAGER]: removing clusters`, meta); - removedClusters.forEach(cluster => { - cluster.disconnect(); - cluster.unbindEvents(); - }); + removedClusters.forEach(cluster => cluster.disconnect()); clusterStore.removedClusters.clear(); } }, { @@ -38,7 +34,6 @@ export class ClusterManager { // listen for ipc-events that must/can be handled *only* in main-process (nodeIntegration=true) clusterIpc.activate.handleInMain(); clusterIpc.disconnect.handleInMain(); - clusterIpc.reconnect.handleInMain(); } stop() { diff --git a/src/main/cluster.ts b/src/main/cluster.ts index 9dd47d031e..38ddb79577 100644 --- a/src/main/cluster.ts +++ b/src/main/cluster.ts @@ -1,7 +1,7 @@ import type { ClusterId, ClusterModel, ClusterPreferences } from "../common/cluster-store" import type { FeatureStatusMap } from "./feature" import type { WorkspaceId } from "../common/workspace-store"; -import { action, computed, observable, reaction, toJS } from "mobx"; +import { action, observable, reaction, toJS, when } from "mobx"; import { apiKubePrefix } from "../common/vars"; import { broadcastIpc } from "../common/ipc"; import { ContextHandler } from "./context-handler" @@ -23,6 +23,7 @@ export interface ClusterState extends ClusterModel { initialized: boolean; apiUrl: string; online: boolean; + disconnected: boolean; accessible: boolean; failureReason: string; nodes: number; @@ -40,7 +41,9 @@ export class Cluster implements ClusterModel { public kubeCtl: Kubectl public contextHandler: ContextHandler; protected kubeconfigManager: KubeconfigManager; - protected disposers: Function[] = []; + protected eventDisposers: Function[] = []; + + whenInitialized = when(() => this.initialized); @observable initialized = false; @observable contextName: string; @@ -67,10 +70,6 @@ export class Cluster implements ClusterModel { this.updateModel(model); } - @computed get isReady() { - return this.initialized && this.accessible === true; - } - @action updateModel(model: ClusterModel) { Object.assign(this, model); @@ -103,47 +102,53 @@ export class Cluster implements ClusterModel { } } - bindEvents() { + protected bindEvents() { logger.info(`[CLUSTER]: bind events`, this.getMeta()); const refreshTimer = setInterval(() => this.online && this.refresh(), 30000); // every 30s const refreshEventsTimer = setInterval(() => this.online && this.refreshEvents(), 3000); // every 3s - this.disposers.push( + this.eventDisposers.push( + reaction(this.getState, this.pushState), () => clearInterval(refreshTimer), () => clearInterval(refreshEventsTimer), - reaction(this.getState, this.pushState, { - fireImmediately: true - }) ); } - unbindEvents() { + protected unbindEvents() { logger.info(`[CLUSTER]: unbind events`, this.getMeta()); - this.disposers.forEach(dispose => dispose()); - this.disposers.length = 0; + this.eventDisposers.forEach(dispose => dispose()); + this.eventDisposers.length = 0; } async activate() { - if (this.disconnected) await this.reconnect(); + logger.info(`[CLUSTER]: activate`, this.getMeta()); + await this.whenInitialized; + if (!this.eventDisposers.length) { + this.bindEvents(); + } + if (this.disconnected) { + await this.reconnect(); + } await this.refresh(); return this.pushState(); } - // todo: check, possibly doesn't work as expected async reconnect() { logger.info(`[CLUSTER]: reconnect`, this.getMeta()); - this.disconnected = false; await this.contextHandler.stopServer(); await this.contextHandler.ensureServer(); + this.disconnected = false; } @action disconnect() { logger.info(`[CLUSTER]: disconnect`, this.getMeta()); + this.unbindEvents(); + this.contextHandler.stopServer(); this.disconnected = true; this.online = false; this.accessible = false; - this.contextHandler.stopServer(); + this.pushState(); } @action @@ -344,13 +349,14 @@ export class Cluster implements ClusterModel { }) } - // serializable cluster-state (mostly used for push-notifications) + // serializable cluster-state used for sync btw main <-> renderer getState = (): ClusterState => { const state: ClusterState = { ...this.toJSON(), initialized: this.initialized, apiUrl: this.apiUrl, online: this.online, + disconnected: this.disconnected, accessible: this.accessible, failureReason: this.failureReason, nodes: this.nodes, @@ -383,8 +389,9 @@ export class Cluster implements ClusterModel { id: this.id, name: this.contextName, initialized: this.initialized, - accessible: this.accessible, online: this.online, + accessible: this.accessible, + disconnected: this.disconnected, } } diff --git a/src/main/index.ts b/src/main/index.ts index a897b1ac9e..6ca9a78be0 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -74,7 +74,6 @@ async function main() { // create window manager and open app windowManager = new WindowManager(proxyPort); - windowManager.showSplash(); initMenu(windowManager); } diff --git a/src/main/kube-auth-proxy.ts b/src/main/kube-auth-proxy.ts index 21e9bd8a7d..e86bda5ffe 100644 --- a/src/main/kube-auth-proxy.ts +++ b/src/main/kube-auth-proxy.ts @@ -6,8 +6,8 @@ import { bundledKubectl, Kubectl } from "./kubectl" import logger from "./logger" export interface KubeAuthProxyResponse { - data: string; - stream: "stderr" | "stdout"; + data: string; // stream=stdout + error?: boolean; // stream=stderr } export class KubeAuthProxy { @@ -47,10 +47,7 @@ export class KubeAuthProxy { env: this.env }) this.proxyProcess.on("exit", (code) => { - if (code) { - logger.error(`[KUBE-AUTH]: proxying ${this.cluster.contextName} exited with code ${code}`, this.cluster.getMeta()); - } - this.sendIpcLogMessage({ data: `proxy exited with code ${code}`, stream: "stderr" }) + this.sendIpcLogMessage({ data: `proxy exited with code: ${code}`, error: code > 0 }) this.proxyProcess = null }) this.proxyProcess.stdout.on('data', (data) => { @@ -58,11 +55,11 @@ export class KubeAuthProxy { if (logItem.startsWith("Starting to serve on")) { logItem = "Authentication proxy started\n" } - this.sendIpcLogMessage({ data: logItem, stream: "stdout" }) + this.sendIpcLogMessage({ data: logItem }) }) this.proxyProcess.stderr.on('data', (data) => { this.lastError = this.parseError(data.toString()) - this.sendIpcLogMessage({ data: data.toString(), stream: "stderr" }) + this.sendIpcLogMessage({ data: data.toString(), error: true }) }) return waitUntilUsed(this.port, 500, 10000) @@ -97,6 +94,7 @@ export class KubeAuthProxy { if (this.proxyProcess) { logger.debug("[KUBE-AUTH]: stopping local proxy", this.cluster.getMeta()) this.proxyProcess.kill() + this.proxyProcess = null; } } } diff --git a/src/main/menu.ts b/src/main/menu.ts index 0bab5ed617..2e3f393fd9 100644 --- a/src/main/menu.ts +++ b/src/main/menu.ts @@ -13,7 +13,7 @@ export function initMenu(windowManager: WindowManager) { function navigate(url: string) { const activeClusterId = clusterStore.activeClusterId; - const view = windowManager.getView(activeClusterId); + const view = windowManager.getClusterView(activeClusterId); if (view) { broadcastIpc({ channel: "menu:navigate", diff --git a/src/main/routes/kubeconfig-route.ts b/src/main/routes/kubeconfig-route.ts index afd1128781..09f1f061cf 100644 --- a/src/main/routes/kubeconfig-route.ts +++ b/src/main/routes/kubeconfig-route.ts @@ -4,7 +4,7 @@ import { Cluster } from "../cluster" import { CoreV1Api, V1Secret } from "@kubernetes/client-node" function generateKubeConfig(username: string, secret: V1Secret, cluster: Cluster) { - const tokenData = new Buffer(secret.data["token"], "base64") + const tokenData = Buffer.from(secret.data["token"], "base64") return { 'apiVersion': 'v1', 'kind': 'Config', diff --git a/src/main/window-manager.ts b/src/main/window-manager.ts index de775747dd..c8492a55cc 100644 --- a/src/main/window-manager.ts +++ b/src/main/window-manager.ts @@ -1,4 +1,4 @@ -import { autorun, reaction, when } from "mobx"; +import { autorun, reaction } from "mobx"; import { BrowserWindow, shell } from "electron" import windowStateKeeper from "electron-window-state" import type { ClusterId } from "../common/cluster-store"; @@ -15,25 +15,23 @@ export class WindowManager { protected disposers: CallableFunction[] = []; protected windowState: windowStateKeeper.State; - constructor(protected proxyPort: number) { - this.splashWindow = new BrowserWindow({ - width: 500, - height: 300, - backgroundColor: "#1e2124", - center: true, - frame: false, - resizable: false, - show: false, - }); - + constructor(protected proxyPort: number, showSplash = true) { // Manage main window size and position with state persistence this.windowState = windowStateKeeper({ defaultHeight: 900, defaultWidth: 1440, }); + // Show while app not ready + if (showSplash) { + this.showSplash(); + } + // Manage reactive state this.disposers.push( + // show and hide "no-clusters" window when necessary + autorun(this.handleNoClustersView), + // auto-show active cluster window and subscribe for push-events reaction(() => clusterStore.activeClusterId, this.activateView, { fireImmediately: true, @@ -42,33 +40,37 @@ export class WindowManager { // auto-destroy views for removed clusters reaction(() => clusterStore.removedClusters.toJS(), removedClusters => { removedClusters.forEach(cluster => { - this.destroyView(cluster.id); + this.destroyClusterView(cluster.id); }); }, { delay: 25, // fix: destroy later and allow to use view's state in next activateView() }), - - // handle no-clusters view - autorun(() => { - if (!clusterStore.hasClusters()) { - this.handleNoClustersView(); - } - }) ); } - protected async handleNoClustersView() { - if (!this.noClustersWindow) { - this.noClustersWindow = this.initView(undefined); - await this.noClustersWindow.loadURL(`http://no-clusters.localhost:${this.proxyPort}`); + protected handleNoClustersView = async () => { + this.noClustersWindow = this.initClusterView(null); + await this.noClustersWindow.loadURL(`http://no-clusters.localhost:${this.proxyPort}`).catch(Function); + if (!clusterStore.hasClusters()) { + this.activeView = this.noClustersWindow; + this.noClustersWindow.show(); + this.hideSplash(); } - this.activeView = this.noClustersWindow; - this.noClustersWindow.show(); - this.hideSplash(); } async showSplash() { - await this.splashWindow.loadURL("static://splash.html").catch(() => null) + if (!this.splashWindow) { + this.splashWindow = new BrowserWindow({ + width: 500, + height: 300, + backgroundColor: "#1e2124", + center: true, + frame: false, + resizable: false, + show: false, + }); + } + await this.splashWindow.loadURL("static://splash.html").catch(Function) this.splashWindow.show(); } @@ -76,19 +78,17 @@ export class WindowManager { this.splashWindow.hide(); } - getView(clusterId: ClusterId) { + getClusterView(clusterId: ClusterId): BrowserWindow { return this.views.get(clusterId); } activateView = async (clusterId: ClusterId): Promise => { const cluster = clusterStore.getById(clusterId); - if (!cluster) { - return; - } + if (!cluster) return; try { const prevActiveView = this.activeView; - const isLoadedBefore = !!this.getView(clusterId); - const view = this.initView(clusterId); + const isLoadedBefore = !!this.getClusterView(clusterId); + const view = this.initClusterView(clusterId); logger.info(`[WINDOW-MANAGER]: activating cluster view`, { id: view.id, clusterId: cluster.id, @@ -97,9 +97,8 @@ export class WindowManager { }); if (prevActiveView !== view) { this.activeView = view; - cluster.activate(); // refresh + reconnect when required if (!isLoadedBefore) { - await when(() => cluster.initialized); + await cluster.whenInitialized; // wait for url await view.loadURL(cluster.webContentUrl); this.hideSplash(); } @@ -119,8 +118,8 @@ export class WindowManager { } } - protected initView(clusterId: ClusterId): BrowserWindow { - let view = this.getView(clusterId); + protected initClusterView(clusterId: ClusterId): BrowserWindow { + let view = this.getClusterView(clusterId); if (!view) { const { width, height, x, y } = this.windowState; view = new BrowserWindow({ @@ -146,7 +145,7 @@ export class WindowManager { return view; } - protected destroyView(clusterId: ClusterId) { + protected destroyClusterView(clusterId: ClusterId) { const view = this.views.get(clusterId); if (view) { view.destroy(); diff --git a/src/renderer/components/app.tsx b/src/renderer/components/app.tsx index bd9ef6c65d..5bd49fbe4e 100755 --- a/src/renderer/components/app.tsx +++ b/src/renderer/components/app.tsx @@ -1,7 +1,7 @@ import "./app.scss"; import React from "react"; import { disposeOnUnmount, observer } from "mobx-react"; -import { computed, observable, reaction } from "mobx"; +import { autorun, computed, observable } from "mobx"; import { Redirect, Route, Switch } from "react-router"; import { Notifications } from "./notifications"; import { NotFound } from "./+404"; @@ -28,45 +28,45 @@ import { CustomResources } from "./+custom-resources/custom-resources"; import { crdRoute } from "./+custom-resources"; import { isAllowedResource } from "../api/rbac"; import { AddCluster, addClusterRoute } from "./+add-cluster"; -import { LandingPage, landingRoute, landingURL } from "./+landing-page"; +import { LandingPage, landingRoute } from "./+landing-page"; import { ClusterSettings, clusterSettingsRoute } from "./+cluster-settings"; import { Workspaces, workspacesRoute } from "./+workspaces"; import { ErrorBoundary } from "./error-boundary"; -import { navigation } from "../navigation"; import { clusterIpc } from "../../common/cluster-ipc"; -import { clusterStore } from "../../common/cluster-store"; +import { getHostedCluster } from "../../common/cluster-store"; import { clusterStatusRoute, clusterStatusURL } from "./cluster-manager/cluster-status.route"; import { Preferences, preferencesRoute } from "./+preferences"; import { ClusterStatus } from "./cluster-manager/cluster-status"; import { CubeSpinner } from "./spinner"; +import { navigate, navigation } from "../navigation"; @observer export class App extends React.Component { @observable isReady = false; - @computed get clusterReady() { - const clusterId = location.hostname.split(".")[0]; - return !!clusterStore.getById(clusterId)?.isReady; + @computed get clusterReady(): boolean { + const cluster = getHostedCluster(); + if (cluster) { + return cluster.initialized && cluster.accessible; + } } async componentDidMount() { - await clusterIpc.activate.invokeFromRenderer(); + await clusterIpc.activate.invokeFromRenderer(); // refresh state, reconnect, etc. this.isReady = true; disposeOnUnmount(this, [ - reaction(() => this.startURL, url => { - const redirect = !this.clusterReady || !clusterStore.hasClusters(); - if (redirect) navigation.replace(url); - }, { - fireImmediately: true + autorun(() => { + if (!this.clusterReady) { + navigate(clusterStatusURL()); + } else if (clusterStatusURL() == navigation.getPath()) { + navigate("/"); // redirect when cluster accessible + } }) ]) } get startURL() { - if (!clusterStore.hasClusters()) { - return landingURL(); - } if (!this.clusterReady) { return clusterStatusURL(); } diff --git a/src/renderer/components/cluster-manager/cluster-status.tsx b/src/renderer/components/cluster-manager/cluster-status.tsx index c950677279..a89cc8b871 100644 --- a/src/renderer/components/cluster-manager/cluster-status.tsx +++ b/src/renderer/components/cluster-manager/cluster-status.tsx @@ -1,65 +1,76 @@ +import type { KubeAuthProxyResponse } from "../../../main/kube-auth-proxy"; + import "./cluster-status.scss" import React from "react"; -import type { KubeAuthProxyResponse } from "../../../main/kube-auth-proxy"; -import { clusterStore } from "../../../common/cluster-store"; -import { ipcRenderer } from "electron"; -import { observable } from "mobx"; import { observer } from "mobx-react"; +import { ipcRenderer } from "electron"; +import { computed, observable } from "mobx"; +import { clusterIpc } from "../../../common/cluster-ipc"; +import { getHostedCluster } from "../../../common/cluster-store"; import { Icon } from "../icon"; import { Button } from "../button"; import { cssNames } from "../../utils"; -import { clusterIpc } from "../../../common/cluster-ipc"; @observer export class ClusterStatus extends React.Component { - @observable authOutput: string[] = []; - @observable hasErrors = false; + @observable authOutput: KubeAuthProxyResponse[] = []; + @observable isReconnecting = false; - get cluster() { - return clusterStore.activeCluster; + @computed get hasErrors() { + return this.authOutput.some(({ error }) => error) } - get clusterId() { - return clusterStore.activeClusterId; + @computed get cluster() { + return getHostedCluster() } - componentDidMount() { - this.authOutput = ["Connecting ...\n"]; - ipcRenderer.on(`kube-auth:${this.clusterId}`, (evt, { data, stream }: KubeAuthProxyResponse) => { - this.authOutput.push(`[${stream}]: ${data}`); - if (stream === "stderr") { - this.hasErrors = true; - } + async componentDidMount() { + this.authOutput = [{ data: "Connecting ...\n" }]; + ipcRenderer.on(`kube-auth:${this.cluster.id}`, (evt, res) => { + this.authOutput.push(res); }) } componentWillUnmount() { - ipcRenderer.removeAllListeners(`kube-auth:${this.clusterId}`); + ipcRenderer.removeAllListeners(`kube-auth:${this.cluster.id}`); } - reconnect = () => { - this.authOutput = ["Reconnecting ...\n"]; - clusterIpc.reconnect.invokeFromRenderer(this.clusterId); + reconnect = async () => { + this.authOutput = [{ data: "Reconnecting ...\n" }]; + this.isReconnecting = true; + await clusterIpc.activate.invokeFromRenderer(); + this.isReconnecting = false; } render() { const { authOutput, cluster, hasErrors } = this; + const isDisconnected = !!cluster.disconnected; + const isInactive = hasErrors || isDisconnected; return (
- {!hasErrors && } - {hasErrors && } - {cluster &&

{cluster.contextName}

} -
-          {authOutput.map((data, index) => {
-            const error = data.startsWith("[stderr]");
-            return 

{data}

- })} -
- {hasErrors && ( + {isInactive && ( + + )} +

+ {cluster.contextName} +

+ {!isDisconnected && ( +
+            {authOutput.map(({ data, error }, index) => {
+              return 

{data}

+ })} +
+ )} + {isInactive && (
diff --git a/src/renderer/components/cluster-manager/clusters-menu.tsx b/src/renderer/components/cluster-manager/clusters-menu.tsx index 63c86cf704..99cbd8d59a 100644 --- a/src/renderer/components/cluster-manager/clusters-menu.tsx +++ b/src/renderer/components/cluster-manager/clusters-menu.tsx @@ -20,6 +20,7 @@ import { landingURL } from "../+landing-page"; import { Tooltip, TooltipContent } from "../tooltip"; import { ConfirmDialog } from "../confirm-dialog"; import { clusterIpc } from "../../../common/cluster-ipc"; +import { clusterStatusURL } from "./cluster-status.route"; // fixme: allow to rearrange clusters with drag&drop @@ -54,9 +55,9 @@ export class ClustersMenu extends React.Component { if (cluster.online) { menu.append(new MenuItem({ label: _i18n._(t`Disconnect`), - click: () => { - navigate(landingURL()); - clusterIpc.disconnect.invokeFromRenderer(); + click: async () => { + await clusterIpc.disconnect.invokeFromRenderer(); + navigate(clusterStatusURL()); } })) }