diff --git a/.eslintrc.js b/.eslintrc.js index 913d959a97..7845b93739 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -21,9 +21,7 @@ module.exports = { sourceType: 'module', }, rules: { - "indent": ["error", 2, { - "SwitchCase": 1, - }], + "indent": ["error", 2], "no-unused-vars": "off", "semi": ["error", "always"], "object-shorthand": "error", diff --git a/extensions/telemetry/src/tracker.ts b/extensions/telemetry/src/tracker.ts index 982ae146c4..4c41c70b19 100644 --- a/extensions/telemetry/src/tracker.ts +++ b/extensions/telemetry/src/tracker.ts @@ -9,7 +9,7 @@ import { comparer } from "mobx"; export class Tracker extends Util.Singleton { static readonly GA_ID = "UA-159377374-1"; static readonly SEGMENT_KEY = "YENwswyhlOgz8P7EFKUtIZ2MfON7Yxqb"; - protected eventHandlers: Array<(ev: EventBus.AppEvent ) => void> = []; + protected eventHandlers: Array<(ev: EventBus.AppEvent) => void> = []; protected started = false; protected visitor: ua.Visitor; protected analytics: Analytics; @@ -124,30 +124,28 @@ export class Tracker extends Util.Singleton { } protected resolveOS() { - let os = ""; if (App.isMac) { - os = "MacOS"; - } else if(App.isWindows) { - os = "Windows"; - } else if (App.isLinux) { - os = "Linux"; - if (App.isSnap) { - os += "; Snap"; - } else { - os += "; AppImage"; - } - } else { - os = "Unknown"; + return "MacOS"; } - return os; + + if (App.isWindows) { + return "Windows"; + } + + if (App.isLinux) { + return `Linux; ${App.isSnap ? "Snap" : "AppImage"}`; + } + + return "Unknown"; } protected async event(eventCategory: string, eventAction: string, otherParams = {}) { + const allowed = await this.isTelemetryAllowed(); + if (!allowed) { + return; + } + try { - const allowed = await this.isTelemetryAllowed(); - if (!allowed) { - return; - } this.visitor.event({ ec: eventCategory, ea: eventAction, diff --git a/src/common/cluster-store.ts b/src/common/cluster-store.ts index 35ec663a55..8767c6cee6 100644 --- a/src/common/cluster-store.ts +++ b/src/common/cluster-store.ts @@ -8,13 +8,14 @@ import { Cluster, ClusterState } from "../main/cluster"; import migrations from "../migrations/cluster-store"; import logger from "../main/logger"; import { appEventBus } from "./event-bus"; -import { dumpConfigYaml } from "./kube-helpers"; +import { dumpConfigYaml, LoadKubeError } from "./kube-helpers"; import { saveToAppFiles } from "./utils/saveToAppFiles"; import { KubeConfig } from "@kubernetes/client-node"; import { subscribeToBroadcast, unsubscribeAllFromBroadcast } from "./ipc"; import _ from "lodash"; import move from "array-move"; import type { WorkspaceId } from "./workspace-store"; +import { notificationsStore, NotificationStatus } from "./notifications.store"; export interface ClusterIconUpload { clusterId: string; @@ -73,6 +74,14 @@ export interface ClusterPrometheusPreferences { }; } +export interface ClusterRenderInfo extends ClusterModel { + DeadError?: LoadKubeError; // this != undefined => dead + isAdmin: boolean; + name: string; + eventCount: number; + online: boolean; +} + export class ClusterStore extends BaseStore { static getCustomKubeConfigPath(clusterId: ClusterId): string { return path.resolve((app || remote.app).getPath("userData"), "kubeconfigs", clusterId); @@ -88,6 +97,7 @@ export class ClusterStore extends BaseStore { @observable activeCluster: ClusterId; @observable removedClusters = observable.map(); @observable clusters = observable.map(); + @observable deadClusters = observable.map(); private constructor() { super({ @@ -137,6 +147,10 @@ export class ClusterStore extends BaseStore { return Array.from(this.clusters.values()); } + @computed get deadClustersList(): [ClusterModel, LoadKubeError][] { + return Array.from(this.deadClusters.values()); + } + @computed get enabledClustersList(): Cluster[] { return this.clustersList.filter((c) => c.enabled); } @@ -182,10 +196,32 @@ export class ClusterStore extends BaseStore { return this.clusters.get(id); } - getByWorkspaceId(workspaceId: string): Cluster[] { - const clusters = Array.from(this.clusters.values()) - .filter(cluster => cluster.workspace === workspaceId); - return _.sortBy(clusters, cluster => cluster.preferences.iconOrder); + getDeadById(id: ClusterId): [ClusterModel, LoadKubeError] { + return this.deadClusters.get(id); + } + + getByWorkspaceId(workspaceId: string): ClusterRenderInfo[] { + const aliveClusters: ClusterRenderInfo[] = this.clustersList.filter(c => c.workspace === workspaceId); + const deadClusters: ClusterRenderInfo[] = this.deadClustersList + .filter(([c]) => c.workspace === workspaceId) + .map(([cluster, error]) => ({ + DeadError: error, + name: cluster.contextName, + isAdmin: false, + eventCount: 0, + online: false, + ...cluster, + })); + + + return _.sortBy([...aliveClusters, ...deadClusters], c => c.preferences?.iconOrder); + } + + getCountInWorkspace(workspaceId: string): number { + const aliveCount = this.clustersList.filter(c => c.workspace === workspaceId).length; + const deadCount = this.deadClustersList.filter(([c]) => c.workspace === workspaceId).length; + + return aliveCount + deadCount; } @action @@ -241,6 +277,7 @@ export class ClusterStore extends BaseStore { const currentClusters = this.clusters.toJS(); const newClusters = new Map(); const removedClusters = new Map(); + const deadClusters = new Map(); // update new clusters for (const clusterModel of clusters) { @@ -248,7 +285,29 @@ export class ClusterStore extends BaseStore { if (cluster) { cluster.updateModel(clusterModel); } else { - cluster = new Cluster(clusterModel); + try { + cluster = new Cluster(clusterModel); + } catch (err) { + if (err instanceof LoadKubeError) { + deadClusters.set(clusterModel.id, [clusterModel, err]); + logger.error(`[CLUSTER-STORE]: marking cluster as dead`, { + err, + name: clusterModel.contextName, + id: clusterModel.id, + }); + + notificationsStore.add({ + message: `Cluster ${clusterModel.contextName} is reporting an error: ${err.toString()}`, + id: `${clusterModel.id}_IS_DEAD_NOTIFICATION`, // this **should** prevent double errors because we are loading the stores twice + status: NotificationStatus.ERROR, + timeout: 10000, + }); + continue; + } + + throw err; + } + if (!cluster.isManaged) { cluster.enabled = true; } @@ -264,14 +323,22 @@ export class ClusterStore extends BaseStore { }); this.activeCluster = newClusters.has(activeCluster) ? activeCluster : null; + this.deadClusters.replace(deadClusters); this.clusters.replace(newClusters); this.removedClusters.replace(removedClusters); } + @computed private get clustersAsJson(): ClusterModel[] { + const clusters = this.clustersList.map(c => c.toJSON()); + const dead = Array.from(this.deadClusters.values()).map(([c]) => c); + + return [...clusters, ...dead]; + } + toJSON(): ClusterStoreModel { return toJS({ activeCluster: this.activeCluster, - clusters: this.clustersList.map(cluster => cluster.toJSON()), + clusters: this.clustersAsJson, }, { recurseEverything: true }); diff --git a/src/common/kube-helpers.ts b/src/common/kube-helpers.ts index ecb85f78b9..1df9286d60 100644 --- a/src/common/kube-helpers.ts +++ b/src/common/kube-helpers.ts @@ -16,13 +16,55 @@ function resolveTilde(filePath: string) { return filePath; } -export function loadConfig(pathOrContent?: string): KubeConfig { - const kc = new KubeConfig(); +export class LoadKubeError extends Error { + public type: string; +} - if (fse.pathExistsSync(pathOrContent)) { - kc.loadFromFile(path.resolve(resolveTilde(pathOrContent))); - } else { - kc.loadFromString(pathOrContent); +export class AccessError extends LoadKubeError { + type = "AccessError"; + + constructor(public pathname: string) { + super(); + } + + toString() { + return `Failed to loading the kube config. Permission denied.\n\n${this.pathname}`; + } +} + +export class ExistError extends LoadKubeError { + type = "ExistError"; + + constructor(public pathname: string) { + super(); + } + + toString() { + return `Failed to loading the kube config. No such file.\n\n${this.pathname}`; + } +} + +export function loadConfig(pathOrContent: string): KubeConfig { + const kc = new KubeConfig(); + try { + kc.loadFromFile(pathOrContent); + } catch (err) { + switch (err.code) { + case "ENOENT": { // No such file or directory + const dir = path.dirname(pathOrContent); + const dirStat = fse.statSync(dir); + if (dirStat.isDirectory()) { + throw new ExistError(pathOrContent); + } + + kc.loadFromString(pathOrContent); + break; + } + case "EACCES": + throw new AccessError(pathOrContent); + default: + kc.loadFromString(pathOrContent); + } } return kc; @@ -145,15 +187,15 @@ export function getNodeWarningConditions(node: V1Node) { /** * Validates kubeconfig supplied in the add clusters screen. At present this will just validate - * the User struct, specifically the command passed to the exec substructure. - */ -export function validateKubeConfig (config: KubeConfig) { + * the User struct, specifically the command passed to the exec substructure. + */ +export function validateKubeConfig(config: KubeConfig) { // we only receive a single context, cluster & user object here so lets validate them as this // will be called when we add a new cluster to Lens logger.debug(`validateKubeConfig: validating kubeconfig - ${JSON.stringify(config)}`); // Validate the User Object - const user = config.getCurrentUser(); + const user = config.getCurrentUser(); if (user.exec) { const execCommand = user.exec["command"]; // check if the command is absolute or not @@ -166,4 +208,4 @@ export function validateKubeConfig (config: KubeConfig) { throw new ExecValidationNotFoundError(execCommand, isAbsolute); } } -} \ No newline at end of file +} diff --git a/src/renderer/components/notifications/notifications.store.ts b/src/common/notifications.store.ts similarity index 77% rename from src/renderer/components/notifications/notifications.store.ts rename to src/common/notifications.store.ts index 15ec2eac2c..ee6c1d3429 100644 --- a/src/renderer/components/notifications/notifications.store.ts +++ b/src/common/notifications.store.ts @@ -1,8 +1,8 @@ import React from "react"; import { action, observable } from "mobx"; -import { autobind } from "../../utils"; -import uniqueId from "lodash/uniqueId"; -import { JsonApiErrorParsed } from "../../api/json-api"; +import { autobind, Singleton } from "./utils"; +import _ from "lodash"; +import { JsonApiErrorParsed } from "../renderer/api/json-api"; export type NotificationId = string | number; export type NotificationMessage = React.ReactNode | React.ReactNode[] | JsonApiErrorParsed; @@ -21,10 +21,10 @@ export interface Notification { } @autobind() -export class NotificationsStore { +export class NotificationsStore extends Singleton { public notifications = observable.array([], { deep: false }); - protected autoHideTimers = new Map(); + protected autoHideTimers = new Map(); getById(id: NotificationId): Notification | null { return this.notifications.find(item => item.id === id) ?? null; @@ -35,7 +35,7 @@ export class NotificationsStore { if (!notification) return; this.removeAutoHideTimer(id); if (notification?.timeout) { - const timer = window.setTimeout(() => this.remove(id), notification.timeout); + const timer = setTimeout(() => this.remove(id), notification.timeout); this.autoHideTimers.set(id, timer); } } @@ -50,7 +50,7 @@ export class NotificationsStore { @action add(notification: Notification): () => void { const id = notification.id ?? ( - notification.id = uniqueId("notification_") + notification.id = _.uniqueId("notification_") ); const index = this.notifications.findIndex(item => item.id === id); if (index > -1) { @@ -69,4 +69,4 @@ export class NotificationsStore { } } -export const notificationsStore = new NotificationsStore(); +export const notificationsStore = NotificationsStore.getInstance(); diff --git a/src/common/utils/singleton.ts b/src/common/utils/singleton.ts index ed3f0cc962..2c9de0ac76 100644 --- a/src/common/utils/singleton.ts +++ b/src/common/utils/singleton.ts @@ -11,6 +11,10 @@ type Constructor = new (...args: any[]) => T; class Singleton { private static instances = new WeakMap(); + protected constructor() { + // to prevent constucting + } + // todo: improve types inferring static getInstance(...args: ConstructorParameters>): T { if (!Singleton.instances.has(this)) { @@ -25,4 +29,4 @@ class Singleton { } export { Singleton }; -export default Singleton; \ No newline at end of file +export default Singleton; diff --git a/src/main/__test__/kube-auth-proxy.test.ts b/src/main/__test__/kube-auth-proxy.test.ts index dbb3e308e3..dfaf370b46 100644 --- a/src/main/__test__/kube-auth-proxy.test.ts +++ b/src/main/__test__/kube-auth-proxy.test.ts @@ -27,6 +27,7 @@ jest.mock("../../common/ipc"); jest.mock("child_process"); jest.mock("tcp-port-used"); +import mockFs from "mock-fs"; import { Cluster } from "../cluster"; import { KubeAuthProxy } from "../kube-auth-proxy"; import { getFreePort } from "../port"; @@ -36,6 +37,10 @@ import { bundledKubectlPath, Kubectl } from "../kubectl"; import { mock, MockProxy } from 'jest-mock-extended'; import { waitUntilUsed } from 'tcp-port-used'; import { Readable } from "stream"; +import { app, remote } from "electron"; +import { Console } from "console"; + +console = new Console(process.stdout, process.stderr); // fix mockFS const mockBroadcastIpc = broadcastMessage as jest.MockedFunction; const mockSpawn = spawn as jest.MockedFunction; @@ -44,6 +49,14 @@ const mockWaitUntilUsed = waitUntilUsed as jest.MockedFunction { beforeEach(() => { jest.clearAllMocks(); + const mockOpts = { + 'fake-path.yml': JSON.stringify({}) + }; + mockFs(mockOpts); + }); + + afterEach(() => { + mockFs.restore(); }); it("calling exit multiple times shouldn't throw", async () => { diff --git a/src/main/cluster.ts b/src/main/cluster.ts index 80b0289c08..734c5555fe 100644 --- a/src/main/cluster.ts +++ b/src/main/cluster.ts @@ -51,9 +51,9 @@ export interface ClusterState { export class Cluster implements ClusterModel, ClusterState { public id: ClusterId; public kubeCtl: Kubectl; - public contextHandler: ContextHandler; + public contextHandler?: ContextHandler; public ownerRef: string; - protected kubeconfigManager: KubeconfigManager; + protected kubeconfigManager?: KubeconfigManager; protected eventDisposers: Function[] = []; protected activated = false; @@ -85,7 +85,7 @@ export class Cluster implements ClusterModel, ClusterState { } @computed get name() { - return this.preferences.clusterName || this.contextName; + return this.preferences.clusterName || this.contextName; } @computed get prometheusPreferences(): ClusterPrometheusPreferences { @@ -101,9 +101,12 @@ export class Cluster implements ClusterModel, ClusterState { constructor(model: ClusterModel) { this.updateModel(model); + const kubeconfig = this.getKubeconfig(); - if (kubeconfig.getContextObject(this.contextName)) { - this.apiUrl = kubeconfig.getCluster(kubeconfig.getContextObject(this.contextName).cluster).server; + const contextObj = kubeconfig.getContextObject(this.contextName); + if (contextObj) { + const clusterObj = kubeconfig.getCluster(contextObj.cluster); + this.apiUrl = clusterObj.server; } } @@ -386,6 +389,7 @@ export class Cluster implements ClusterModel, ClusterState { online: this.online, accessible: this.accessible, disconnected: this.disconnected, + activated: this.activated, }; } diff --git a/src/main/lens-proxy.ts b/src/main/lens-proxy.ts index 03b1b15d29..39fff50e6a 100644 --- a/src/main/lens-proxy.ts +++ b/src/main/lens-proxy.ts @@ -10,6 +10,8 @@ import { Router } from "./router"; import { ClusterManager } from "./cluster-manager"; import { ContextHandler } from "./context-handler"; import logger from "./logger"; +import _ from "lodash"; +import { clusterStore } from "../common/cluster-store"; export class LensProxy { protected origin: string; @@ -64,50 +66,55 @@ export class LensProxy { protected async handleProxyUpgrade(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 pUrl = url.parse(proxyUrl); - const connectOpts = { port: parseInt(pUrl.port), host: pUrl.hostname }; - const proxySocket = new net.Socket(); - proxySocket.connect(connectOpts, () => { - proxySocket.write(`${req.method} ${pUrl.path} HTTP/1.1\r\n`); - proxySocket.write(`Host: ${apiUrl.host}\r\n`); - for (let i = 0; i < req.rawHeaders.length; i += 2) { - const key = req.rawHeaders[i]; - if (key !== "Host" && key !== "Authorization") { - proxySocket.write(`${req.rawHeaders[i]}: ${req.rawHeaders[i+1]}\r\n`); - } - } - proxySocket.write("\r\n"); - proxySocket.write(head); - }); - - proxySocket.setKeepAlive(true); - socket.setKeepAlive(true); - proxySocket.setTimeout(0); - socket.setTimeout(0); - - proxySocket.on('data', function (chunk) { - socket.write(chunk); - }); - proxySocket.on('end', function () { - socket.end(); - }); - proxySocket.on('error', function (err) { - socket.write("HTTP/" + req.httpVersion + " 500 Connection error\r\n\r\n"); - socket.end(); - }); - socket.on('data', function (chunk) { - proxySocket.write(chunk); - }); - socket.on('end', function () { - proxySocket.end(); - }); - socket.on('error', function () { - proxySocket.end(); - }); + if (!cluster) { + return; } + + const proxyUrl = await cluster.contextHandler.resolveAuthProxyUrl() + req.url.replace(apiKubePrefix, ""); + const apiUrl = url.parse(cluster.apiUrl); + const pUrl = url.parse(proxyUrl); + const connectOpts = { port: parseInt(pUrl.port), host: pUrl.hostname }; + const proxySocket = new net.Socket(); + proxySocket.connect(connectOpts, () => { + proxySocket.write(`${req.method} ${pUrl.path} HTTP/1.1\r\n`); + proxySocket.write(`Host: ${apiUrl.host}\r\n`); + + for (const [key, value] of _.chunk(req.rawHeaders, 2)) { + if (["Host", "Authorization"].includes(key)) { + continue; + } + + proxySocket.write(`${key}: ${value}\r\n`); + } + + proxySocket.write("\r\n"); + proxySocket.write(head); + }); + + proxySocket.setKeepAlive(true); + socket.setKeepAlive(true); + proxySocket.setTimeout(0); + socket.setTimeout(0); + + proxySocket.on('data', function (chunk) { + socket.write(chunk); + }); + proxySocket.on('end', function () { + socket.end(); + }); + proxySocket.on('error', function (err) { + socket.write(`HTTP/${req.httpVersion} 500 Connection error\r\n\r\n`); + socket.end(); + }); + socket.on('data', function (chunk) { + proxySocket.write(chunk); + }); + socket.on('end', function () { + proxySocket.end(); + }); + socket.on('error', function () { + proxySocket.end(); + }); } protected createProxy(): httpProxy { @@ -166,6 +173,10 @@ export class LensProxy { protected async handleRequest(proxy: httpProxy, req: http.IncomingMessage, res: http.ServerResponse) { const cluster = this.clusterManager.getClusterForRequest(req); if (cluster) { + if (!cluster.initialized) { + return res.writeHead(404).end(`cluster: ${cluster.name} is not initialized`); + } + const proxyTarget = await this.getProxyTarget(req, cluster.contextHandler); if (proxyTarget) { // allow to fetch apis in "clusterId.localhost:port" from "localhost:port" diff --git a/src/main/tray.ts b/src/main/tray.ts index f98c064bdd..27e66a8542 100644 --- a/src/main/tray.ts +++ b/src/main/tray.ts @@ -1,12 +1,12 @@ import path from "path"; import packageInfo from "../../package.json"; -import { dialog, Menu, NativeImage, nativeTheme, Tray } from "electron"; +import { dialog, Menu, MenuItemConstructorOptions, NativeImage, nativeTheme, Tray } from "electron"; import { autorun } from "mobx"; import { showAbout } from "./menu"; import { AppUpdater } from "./app-updater"; import { WindowManager } from "./window-manager"; -import { clusterStore } from "../common/cluster-store"; -import { workspaceStore } from "../common/workspace-store"; +import { ClusterRenderInfo, clusterStore } from "../common/cluster-store"; +import { Workspace, workspaceStore } from "../common/workspace-store"; import { preferencesURL } from "../renderer/components/+preferences/preferences.route"; import { clusterViewURL } from "../renderer/components/cluster-manager/cluster-view.route"; import logger from "./logger"; @@ -33,7 +33,7 @@ export function initTray(windowManager: WindowManager) { const menu = createTrayMenu(windowManager); buildTray(getTrayIcon(), menu); } catch (err) { - logger.error(`[TRAY]: building failed: ${err}`); + logger.error(`[TRAY]: building failed: `, err); } }); return () => { @@ -82,25 +82,23 @@ export function createTrayMenu(windowManager: WindowManager): Menu { { label: "Clusters", submenu: workspaceStore.enabledWorkspacesList - .filter(workspace => clusterStore.getByWorkspaceId(workspace.id).length > 0) // hide empty workspaces - .map(workspace => { - const clusters = clusterStore.getByWorkspaceId(workspace.id); - return { - label: workspace.name, - toolTip: workspace.description, - submenu: clusters.map(cluster => { - const { id: clusterId, name: label, online, workspace } = cluster; - return { - label: `${online ? '✓' : '\x20'.repeat(3)/*offset*/}${label}`, - toolTip: clusterId, - async click() { - workspaceStore.setActive(workspace); - windowManager.navigate(clusterViewURL({ params: { clusterId } })); - } - }; - }) - }; - }), + .map((workspace): [Workspace, ClusterRenderInfo[]] => [workspace, clusterStore.getByWorkspaceId(workspace.id)]) + .filter(([, clusters]) => clusters.length > 0) + .map(([workspace, clusters]): MenuItemConstructorOptions => ({ + label: workspace.name, + toolTip: workspace.description, + submenu: clusters.map(({ id: clusterId, name, online, workspace, DeadError }) => ({ + label: name, + enabled: !!!DeadError, + checked: online, + toolTip: clusterId, + type: "radio", + async click() { + workspaceStore.setActive(workspace); + windowManager.navigate(clusterViewURL({ params: { clusterId } })); + } + })) + })), }, { label: "Check for updates", diff --git a/src/renderer/api/json-api.ts b/src/renderer/api/json-api.ts index 8d30f08a57..3477a13036 100644 --- a/src/renderer/api/json-api.ts +++ b/src/renderer/api/json-api.ts @@ -2,7 +2,7 @@ import { stringify } from "querystring"; import { EventEmitter } from "../../common/event-emitter"; -import { cancelableFetch } from "../utils/cancelableFetch"; +import { cancelableFetch, CancelablePromise } from "../utils/cancelableFetch"; export interface JsonApiData { } @@ -71,7 +71,7 @@ export class JsonApi { return this.request(path, params, { ...reqInit, method: "delete" }); } - protected request(path: string, params?: P, init: RequestInit = {}) { + protected request(path: string, params?: P, init: RequestInit = {}): CancelablePromise { let reqUrl = this.config.apiBase + path; const reqInit: RequestInit = { ...this.reqInit, ...init }; const { data, query } = params || {} as P; @@ -92,28 +92,27 @@ export class JsonApi { }); } - protected parseResponse(res: Response, log: JsonApiLog): Promise { + protected async parseResponse(res: Response, log: JsonApiLog): Promise { const { status } = res; - return res.text().then(text => { - let data; - try { - data = text ? JSON.parse(text) : ""; // DELETE-requests might not have response-body - } catch (e) { - data = text; - } - if (status >= 200 && status < 300) { - this.onData.emit(data, res); - this.writeLog({ ...log, data }); - return data; - } else if (log.method === "GET" && res.status === 403) { - this.writeLog({ ...log, data }); - } else { - const error = new JsonApiErrorParsed(data, this.parseError(data, res)); - this.onError.emit(error, res); - this.writeLog({ ...log, error }); - throw error; - } - }); + const text = await res.text(); + let data; + try { + data = text ? JSON.parse(text) : ""; // DELETE-requests might not have response-body + } catch (e) { + data = text; + } + if (status >= 200 && status < 300) { + this.onData.emit(data, res); + this.writeLog({ ...log, data }); + return data; + } else if (log.method === "GET" && res.status === 403) { + this.writeLog({ ...log, data }); + } else { + const error = new JsonApiErrorParsed(data, this.parseError(data, res)); + this.onError.emit(error, res); + this.writeLog({ ...log, error }); + throw error; + } } protected parseError(error: JsonApiError | string, res: Response): string[] { diff --git a/src/renderer/components/+landing-page/landing-page.tsx b/src/renderer/components/+landing-page/landing-page.tsx index 5c1e34297c..38dd6599e9 100644 --- a/src/renderer/components/+landing-page/landing-page.tsx +++ b/src/renderer/components/+landing-page/landing-page.tsx @@ -11,8 +11,7 @@ export class LandingPage extends React.Component { @observable showHint = true; render() { - const clusters = clusterStore.getByWorkspaceId(workspaceStore.currentWorkspaceId); - const noClustersInScope = !clusters.length; + const noClustersInScope = clusterStore.getCountInWorkspace(workspaceStore.currentWorkspaceId) === 0; const showStartupHint = this.showHint && noClustersInScope; return (
diff --git a/src/renderer/components/cluster-icon/cluster-icon.scss b/src/renderer/components/cluster-icon/cluster-icon.scss index 540cecf9eb..f9584f38f2 100644 --- a/src/renderer/components/cluster-icon/cluster-icon.scss +++ b/src/renderer/components/cluster-icon/cluster-icon.scss @@ -26,6 +26,15 @@ height: var(--size); } + .Icon.dead-error { + position: absolute; + border-radius: 50%; + right: 0px; + top: 0px; + color: $colorError; + background-color: black; + } + .Badge { position: absolute; right: 0; @@ -35,4 +44,4 @@ background: $colorError; color: white; } -} \ No newline at end of file +} diff --git a/src/renderer/components/cluster-icon/cluster-icon.tsx b/src/renderer/components/cluster-icon/cluster-icon.tsx index 8ee7c79aaf..1ce84626aa 100644 --- a/src/renderer/components/cluster-icon/cluster-icon.tsx +++ b/src/renderer/components/cluster-icon/cluster-icon.tsx @@ -4,17 +4,16 @@ import React, { DOMAttributes } from "react"; import { disposeOnUnmount, observer } from "mobx-react"; import { Params as HashiconParams } from "@emeraldpay/hashicon"; import { Hashicon } from "@emeraldpay/hashicon-react"; -import { Cluster } from "../../../main/cluster"; import { cssNames, IClassName } from "../../utils"; import { Badge } from "../badge"; import { Tooltip } from "../tooltip"; -import { eventStore } from "../+events/event.store"; -import { forCluster } from "../../api/kube-api"; -import { subscribeToBroadcast, unsubscribeAllFromBroadcast } from "../../../common/ipc"; -import { observable, when } from "mobx"; +import { subscribeToBroadcast } from "../../../common/ipc"; +import { observable } from "mobx"; +import { ClusterRenderInfo } from "../../../common/cluster-store"; +import { Icon } from "../icon"; interface Props extends DOMAttributes { - cluster: Cluster; + cluster: ClusterRenderInfo; className?: IClassName; errorClass?: IClassName; showErrors?: boolean; @@ -51,24 +50,32 @@ export class ClusterIcon extends React.Component { render() { const { - cluster, showErrors, showTooltip, errorClass, options, interactive, isActive, + cluster, showErrors, showTooltip, errorClass, options, interactive, isActive, className, children, ...elemProps } = this.props; - const { name, preferences, id: clusterId } = cluster; const eventCount = this.eventCount; - const { icon } = preferences; + const { name, preferences: { icon }, id: clusterId, DeadError } = cluster; const clusterIconId = `cluster-icon-${clusterId}`; - const className = cssNames("ClusterIcon flex inline", this.props.className, { - interactive: interactive !== undefined ? interactive : !!this.props.onClick, + const isDead = !!DeadError; + const classNames = cssNames("ClusterIcon flex inline", className, { + interactive: interactive ?? !!this.props.onClick, active: isActive, }); return ( -
+
{showTooltip && ( {name} )} - {icon && {name}/} - {!icon && } + { + icon + ? {name} + : + } + { + isDead && ( + + ) + } {showErrors && eventCount > 0 && !isActive && ( { + renderError(error: LoadKubeError) { + if (error instanceof AccessError) { + return <> +

The kube config file related to this cluster is no longer accessible by Lens.

+

Lens cannot connect to this cluster.

+ {isMac && ( +

This may have resulted in a recent macOS update locking down files.

+ )} +

If you allow Lens to access your home directory, it may rectify the problem.

+

{error.pathname}

+ ; + } + + if (error instanceof ExistError) { + return <> +

The kube config file related to this cluster no longer exists.

+

Either it has been moved by another process or it was deleted.

+

To fix this error, either recreate the config file in the same location or delete and re-add this cluster.

+

{error.pathname}

+ ; + } + + return <> +

An unknown error of type "{error.type}" occured while loading this cluster's kube config.

+ ; + } + + render() { + const classNames = cssNames("ClusterStatus flex column gaps box center align-center justify-center"); + const { cluster, error } = this.props; + + return
+ +

+ {cluster.preferences.clusterName || cluster.contextName} +

+ {this.renderError(error)} +
; + } +} diff --git a/src/renderer/components/cluster-manager/cluster-manager.tsx b/src/renderer/components/cluster-manager/cluster-manager.tsx index b27e088f84..37df141752 100644 --- a/src/renderer/components/cluster-manager/cluster-manager.tsx +++ b/src/renderer/components/cluster-manager/cluster-manager.tsx @@ -4,7 +4,7 @@ import React from "react"; import { Redirect, Route, Switch } from "react-router"; import { comparer, reaction } from "mobx"; import { disposeOnUnmount, observer } from "mobx-react"; -import { ClustersMenu } from "./clusters-menu"; +import { ClusterMenu } from "./clusters-menu"; import { BottomBar } from "./bottom-bar"; import { LandingPage, landingRoute, landingURL } from "../+landing-page"; import { Preferences, preferencesRoute } from "../+preferences"; @@ -60,7 +60,7 @@ export class ClusterManager extends React.Component { return (
-
+
@@ -70,13 +70,13 @@ export class ClusterManager extends React.Component { {globalPageRegistry.getItems().map(({ routePath, exact, components: { Page } }) => { - return ; + return ; })} - +
- - + +
); } diff --git a/src/renderer/components/cluster-manager/cluster-status.scss b/src/renderer/components/cluster-manager/cluster-status.scss index 0c4f161155..657829d411 100644 --- a/src/renderer/components/cluster-manager/cluster-status.scss +++ b/src/renderer/components/cluster-manager/cluster-status.scss @@ -18,4 +18,8 @@ --size: 70px; margin: auto; } -} \ No newline at end of file + + p.monospace { + font-family: monospace; + } +} diff --git a/src/renderer/components/cluster-manager/cluster-view.tsx b/src/renderer/components/cluster-manager/cluster-view.tsx index bbf1ba6533..a1c541c59e 100644 --- a/src/renderer/components/cluster-manager/cluster-view.tsx +++ b/src/renderer/components/cluster-manager/cluster-view.tsx @@ -7,7 +7,9 @@ import { IClusterViewRouteParams } from "./cluster-view.route"; import { ClusterStatus } from "./cluster-status"; import { hasLoadedView } from "./lens-views"; import { Cluster } from "../../../main/cluster"; -import { clusterStore } from "../../../common/cluster-store"; +import { ClusterModel, clusterStore } from "../../../common/cluster-store"; +import { LoadKubeError } from "../../../common/kube-helpers"; +import { ClusterDeadStatus } from "./cluster-dead-status"; interface Props extends RouteComponentProps { } @@ -22,6 +24,10 @@ export class ClusterView extends React.Component { return clusterStore.getById(this.clusterId); } + get deadCluster(): [ClusterModel, LoadKubeError] { + return clusterStore.getDeadById(this.clusterId); + } + async componentDidMount() { disposeOnUnmount(this, [ reaction(() => this.clusterId, clusterId => clusterStore.setActive(clusterId), { @@ -31,13 +37,16 @@ export class ClusterView extends React.Component { } render() { - const { cluster } = this; + const { cluster, deadCluster } = this; const showStatus = cluster && (!cluster.available || !hasLoadedView(cluster.id) || !cluster.ready); return (
- {showStatus && ( - - )} + {showStatus + ? + : deadCluster && ( + + ) + }
); } diff --git a/src/renderer/components/cluster-manager/clusters-menu.tsx b/src/renderer/components/cluster-manager/clusters-menu.tsx index ab608815aa..506176d55c 100644 --- a/src/renderer/components/cluster-manager/clusters-menu.tsx +++ b/src/renderer/components/cluster-manager/clusters-menu.tsx @@ -3,13 +3,12 @@ import "./clusters-menu.scss"; import React from "react"; import { remote } from "electron"; import { requestMain } from "../../../common/ipc"; -import type { Cluster } from "../../../main/cluster"; import { DragDropContext, Draggable, DraggableProvided, Droppable, DroppableProvided, DropResult } from "react-beautiful-dnd"; import { observer } from "mobx-react"; import { _i18n } from "../../i18n"; import { t, Trans } from "@lingui/macro"; import { userStore } from "../../../common/user-store"; -import { ClusterId, clusterStore } from "../../../common/cluster-store"; +import { ClusterId, ClusterRenderInfo, clusterStore } from "../../../common/cluster-store"; import { workspaceStore } from "../../../common/workspace-store"; import { ClusterIcon } from "../cluster-icon"; import { Icon } from "../icon"; @@ -30,7 +29,7 @@ interface Props { } @observer -export class ClustersMenu extends React.Component { +export class ClusterMenu extends React.Component { showCluster = (clusterId: ClusterId) => { navigate(clusterViewURL({ params: { clusterId } })); }; @@ -39,7 +38,7 @@ export class ClustersMenu extends React.Component { navigate(addClusterURL()); }; - showContextMenu = (cluster: Cluster) => { + showContextMenu = (cluster: ClusterRenderInfo) => { const { Menu, MenuItem } = remote; const menu = new Menu(); @@ -115,25 +114,22 @@ export class ClustersMenu extends React.Component { {({ innerRef, droppableProps, placeholder }: DroppableProvided) => (
- {clusters.map((cluster, index) => { - const isActive = cluster.id === activeClusterId; - return ( - - {({ draggableProps, dragHandleProps, innerRef }: DraggableProvided) => ( -
- this.showCluster(cluster.id)} - onContextMenu={() => this.showContextMenu(cluster)} - /> -
- )} -
- ); - })} + {clusters.map((cluster, index) => ( + + {({ draggableProps, dragHandleProps, innerRef }: DraggableProvided) => ( +
+ this.showCluster(cluster.id)} + onContextMenu={() => this.showContextMenu(cluster)} + /> +
+ )} +
+ ))} {placeholder}
)} @@ -144,9 +140,9 @@ export class ClustersMenu extends React.Component { Add Cluster - + {newContexts.size > 0 && ( - new}/> + new} /> )}
diff --git a/src/renderer/components/icon/icon.tsx b/src/renderer/components/icon/icon.tsx index bc7534d167..44b9590958 100644 --- a/src/renderer/components/icon/icon.tsx +++ b/src/renderer/components/icon/icon.tsx @@ -40,24 +40,23 @@ export class Icon extends React.PureComponent { if (this.props.disabled) { return; } - if (this.props.onClick) { - this.props.onClick(evt); - } + + this.props.onClick?.(evt); } @autobind() onKeyDown(evt: React.KeyboardEvent) { switch (evt.nativeEvent.code) { case "Space": - case "Enter": + case "Enter": { const icon = findDOMNode(this) as HTMLElement; setTimeout(() => icon.click()); evt.preventDefault(); break; + } } - if (this.props.onKeyDown) { - this.props.onKeyDown(evt); - } + + this.props.onKeyDown?.(evt); } render() { @@ -88,7 +87,7 @@ export class Icon extends React.PureComponent { // render as inline svg-icon if (svg) { const svgIconText = require("!!raw-loader!./" + svg + ".svg").default; - iconContent = ; + iconContent = ; } // render as material-icon @@ -106,10 +105,10 @@ export class Icon extends React.PureComponent { // render icon type if (link) { - return ; + return ; } if (href) { - return ; + return ; } return ; } diff --git a/src/renderer/components/notifications/index.ts b/src/renderer/components/notifications/index.ts index 1141b374e0..9ea5ce778c 100644 --- a/src/renderer/components/notifications/index.ts +++ b/src/renderer/components/notifications/index.ts @@ -1,2 +1 @@ export * from './notifications'; -export * from './notifications.store'; diff --git a/src/renderer/components/notifications/notifications.tsx b/src/renderer/components/notifications/notifications.tsx index 4d9f7ef6f3..9f9588facd 100644 --- a/src/renderer/components/notifications/notifications.tsx +++ b/src/renderer/components/notifications/notifications.tsx @@ -5,7 +5,7 @@ import { reaction } from "mobx"; import { disposeOnUnmount, observer } from "mobx-react"; import { JsonApiErrorParsed } from "../../api/json-api"; import { cssNames, prevDefault } from "../../utils"; -import { Notification, NotificationMessage, notificationsStore, NotificationStatus } from "./notifications.store"; +import { NotificationMessage, Notification, notificationsStore, NotificationStatus } from "../../../common/notifications.store"; import { Animate } from "../animate"; import { Icon } from "../icon"; diff --git a/src/renderer/kube-object.store.ts b/src/renderer/kube-object.store.ts index 80995559c6..3ebc1d6cb7 100644 --- a/src/renderer/kube-object.store.ts +++ b/src/renderer/kube-object.store.ts @@ -175,7 +175,7 @@ export abstract class KubeObjectStore extends ItemSt switch (type) { case "ADDED": - case "MODIFIED": + case "MODIFIED": { const newItem = new api.objectConstructor(object); if (!item) { items.push(newItem); @@ -183,6 +183,7 @@ export abstract class KubeObjectStore extends ItemSt items.splice(index, 1, newItem); } break; + } case "DELETED": if (item) { items.splice(index, 1);