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

fix reconnect, better handling no-clusters state

Signed-off-by: Roman <ixrock@gmail.com>
This commit is contained in:
Roman 2020-07-24 14:17:14 +03:00
parent 0490dc1d12
commit 408f64c184
12 changed files with 145 additions and 138 deletions

View File

@ -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();
},
}),
}

View File

@ -197,3 +197,8 @@ export class ClusterStore extends BaseStore<ClusterStoreModel> {
}
export const clusterStore = ClusterStore.getInstance<ClusterStore>();
export function getHostedCluster(): Cluster {
const clusterId = location.hostname.split(".")[0];
return clusterStore.getById(clusterId);
}

View File

@ -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() {

View File

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

View File

@ -74,7 +74,6 @@ async function main() {
// create window manager and open app
windowManager = new WindowManager(proxyPort);
windowManager.showSplash();
initMenu(windowManager);
}

View File

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

View File

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

View File

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

View File

@ -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<number> => {
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();

View File

@ -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();
}

View File

@ -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 (
<div className="ClusterStatus flex column gaps">
{!hasErrors && <Icon material="cloud_queue"/>}
{hasErrors && <Icon material="cloud_off" className="error"/>}
{cluster && <h2>{cluster.contextName}</h2>}
<pre className="kube-auth-out">
{authOutput.map((data, index) => {
const error = data.startsWith("[stderr]");
return <p key={index} className={cssNames({ error })}>{data}</p>
})}
</pre>
{hasErrors && (
{isInactive && (
<Icon
material="cloud_off"
className={cssNames({ error: hasErrors })}
/>
)}
<h2>
{cluster.contextName}
</h2>
{!isDisconnected && (
<pre className="kube-auth-out">
{authOutput.map(({ data, error }, index) => {
return <p key={index} className={cssNames({ error })}>{data}</p>
})}
</pre>
)}
{isInactive && (
<Button
primary className="box center"
primary
label="Reconnect"
className="box center"
onClick={this.reconnect}
waiting={this.isReconnecting}
/>
)}
</div>

View File

@ -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<Props> {
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());
}
}))
}