diff --git a/src/common/cluster-store.ts b/src/common/cluster-store.ts index a866d44d87..3d3366fe1e 100644 --- a/src/common/cluster-store.ts +++ b/src/common/cluster-store.ts @@ -1,11 +1,21 @@ -import { ipcRenderer } from "electron"; import type { WorkspaceId } from "./workspace-store"; +import path from "path"; +import filenamify from "filenamify"; +import { app, ipcRenderer } from "electron"; +import { copyFile, ensureDir, unlink } from "fs-extra"; import { action, computed, observable, toJS } from "mobx"; -import { v4 as uuid } from "uuid" +import { appProto } from "./vars"; import { BaseStore } from "./base-store"; import { Cluster, ClusterState } from "../main/cluster"; import migrations from "../migrations/cluster-store" import logger from "../main/logger"; +import { tracker } from "./tracker"; + +export interface ClusterIconUpload { + clusterId: string; + name: string; + path: string; +} export interface ClusterStoreModel { activeCluster?: ClusterId; // last opened cluster @@ -42,6 +52,10 @@ export interface ClusterPreferences { } export class ClusterStore extends BaseStore { + static get iconsDir() { + return path.join(app.getPath("userData"), "icons"); + } + private constructor() { super({ configName: "lens-cluster-store", @@ -81,9 +95,8 @@ export class ClusterStore extends BaseStore { @action addCluster(model: ClusterModel): Cluster { - const id = model.id || uuid(); - const cluster = new Cluster({ ...model, id }) - this.clusters.set(id, cluster); + const cluster = new Cluster(model); + this.clusters.set(model.id, cluster); return cluster; } @@ -102,6 +115,30 @@ export class ClusterStore extends BaseStore { }) } + @action + protected async uploadClusterIcon({ clusterId, ...upload }: ClusterIconUpload): Promise { + const cluster = this.getById(clusterId); + if (cluster) { + tracker.event("cluster", "upload-icon"); + const fileDest = path.join(ClusterStore.iconsDir, filenamify(cluster.contextName + "-" + upload.name)) + await ensureDir(path.dirname(fileDest)); + await copyFile(upload.path, fileDest) + cluster.preferences.icon = `${appProto}:///icons/${fileDest}` + return cluster.preferences.icon; + } + } + + @action + protected resetClusterIcon(clusterId: ClusterId) { + const cluster = this.getById(clusterId); + if (cluster) { + tracker.event("cluster", "reset-icon") + const iconPath = path.join(ClusterStore.iconsDir, path.basename(cluster.preferences.icon)); + unlink(iconPath).catch(() => null); // remove file + delete cluster.preferences.icon; + } + } + @action protected fromStore({ activeCluster, clusters = [] }: ClusterStoreModel = {}) { const currentClusters = this.clusters.toJS(); diff --git a/src/common/ipc-messages.ts b/src/common/ipc-messages.ts deleted file mode 100644 index 2bda82b7b1..0000000000 --- a/src/common/ipc-messages.ts +++ /dev/null @@ -1,14 +0,0 @@ -// IPC messages (all channels) -// All values must be unique - -export enum ClusterIpcMessage { - ADD = "cluster-add", - STOP = "cluster-stop", - REMOVE = "cluster-remove", - REMOVE_WORKSPACE = "cluster-remove-all-from-workspace", - FEATURE_INSTALL = "cluster-feature-install", - FEATURE_UPGRADE = "cluster-feature-upgrade", - FEATURE_REMOVE = "cluster-feature-remove", - ICON_SAVE = "cluster-icon-save", - ICON_RESET = "cluster-icon-reset", -} diff --git a/src/common/kube-helpers.ts b/src/common/kube-helpers.ts index c276ddcfde..d99ac1cb6a 100644 --- a/src/common/kube-helpers.ts +++ b/src/common/kube-helpers.ts @@ -141,11 +141,12 @@ export function getNodeWarningConditions(node: V1Node) { } // Write kubeconfigs to "embedded" store, i.e. "/Users/ixrock/Library/Application Support/Lens/kubeconfigs" -export function saveConfigToAppFiles(clusterId: string, kubeConfig: string): string { +export function saveConfigToAppFiles(clusterId: string, kubeConfig: KubeConfig | string): string { const userData = (app || remote.app).getPath("userData"); const kubeConfigFile = path.join(userData, `kubeconfigs/${clusterId}`) + const kubeConfigContents = typeof kubeConfig == "string" ? kubeConfig : dumpConfigYaml(kubeConfig); ensureDirSync(path.dirname(kubeConfigFile)); - writeFileSync(kubeConfigFile, kubeConfig); + writeFileSync(kubeConfigFile, kubeConfigContents); return kubeConfigFile; } diff --git a/src/main/cluster-manager.ts b/src/main/cluster-manager.ts index d30b794da4..d2c99d2b18 100644 --- a/src/main/cluster-manager.ts +++ b/src/main/cluster-manager.ts @@ -1,30 +1,12 @@ -import { app } from "electron" +import type http from "http" import { autorun } from "mobx"; -import path from "path" -import http from "http" -import { copyFile, ensureDir } from "fs-extra" -import filenamify from "filenamify" -import { apiKubePrefix, appProto } from "../common/vars"; -import { ClusterId, ClusterModel, clusterStore } from "../common/cluster-store" -import { handleMessages } from "../common/ipc"; -import { ClusterIpcMessage } from "../common/ipc-messages"; +import { apiKubePrefix } from "../common/vars"; +import { ClusterId, clusterStore } from "../common/cluster-store" +import { handleMessage } from "../common/ipc"; import { tracker } from "../common/tracker"; -import { validateConfig } from "../common/kube-helpers"; -import { Cluster } from "./cluster" -import { FeatureInstallRequest } from "./feature"; -import logger from "./logger" - -export interface ClusterIconUpload { - clusterId: string; - name: string; - path: string; -} +import { Cluster, ClusterIpcEvent } from "./cluster" export class ClusterManager { - static get clusterIconDir() { - return path.join(app.getPath("userData"), "icons"); - } - constructor(public readonly port: number) { // auto-init clusters autorun(() => { @@ -32,13 +14,15 @@ export class ClusterManager { .filter(cluster => !cluster.initialized) .forEach(cluster => cluster.init(port)); }); + // auto-stop removed clusters autorun(() => { clusterStore.removedClusters.forEach(cluster => cluster.stop()); clusterStore.removedClusters.clear(); }); - // listen ipc-events - ClusterManager.ipcListen(this); + + // listen ipc-events which could be handled *only* in main-process (nodeIntegration=true) + handleMessage(ClusterIpcEvent.STOP, this.stopCluster.bind(this)); } stop() { @@ -51,40 +35,11 @@ export class ClusterManager { return clusterStore.getById(id); } - protected async addCluster(clusterModel: ClusterModel): Promise { - tracker.event("cluster", "add"); - try { - await validateConfig(clusterModel.kubeConfigPath); - return clusterStore.addCluster(clusterModel); - } catch (error) { - logger.error(`[CLUSTER-MANAGER]: add cluster error ${JSON.stringify(error)}`) - throw error; - } - } - protected stopCluster(clusterId: ClusterId) { tracker.event("cluster", "stop"); this.getCluster(clusterId)?.stop(); } - protected removeAllByWorkspace(workspaceId: string) { - tracker.event("cluster", "remove-workspace"); - const clusters = clusterStore.getByWorkspaceId(workspaceId); - clusters.forEach(cluster => { - this.removeCluster(cluster.id); - }); - } - - protected removeCluster(clusterId: string): Cluster { - tracker.event("cluster", "remove"); - const cluster = this.getCluster(clusterId); - if (cluster) { - cluster.stop() - clusterStore.removeById(cluster.id); - return cluster; - } - } - getClusterForRequest(req: http.IncomingMessage): Cluster { let cluster: Cluster = null @@ -105,61 +60,4 @@ export class ClusterManager { return cluster; } - - protected async uploadClusterIcon({ clusterId, name: fileName, path: src }: ClusterIconUpload): Promise { - const cluster = this.getCluster(clusterId); - if (cluster) { - tracker.event("cluster", "upload-icon"); - await ensureDir(ClusterManager.clusterIconDir) - fileName = filenamify(cluster.contextName + "-" + fileName) - const dest = path.join(ClusterManager.clusterIconDir, fileName) - await copyFile(src, dest) - cluster.preferences.icon = `${appProto}:///icons/${fileName}` - return cluster.preferences.icon; - } - } - - // todo: remove current icon file ? - protected resetClusterIcon(clusterId: ClusterId) { - const cluster = this.getCluster(clusterId); - if (cluster) { - tracker.event("cluster", "reset-icon") - cluster.preferences.icon = null; - } - } - - protected async installFeature({ clusterId, name, config }: FeatureInstallRequest) { - tracker.event("cluster", "install-feature") - return this.getCluster(clusterId)?.installFeature(name, config) - } - - protected async upgradeFeature({ clusterId, name, config }: FeatureInstallRequest) { - tracker.event("cluster", "upgrade-feature") - return this.getCluster(clusterId)?.upgradeFeature(name, config) - } - - protected async uninstallFeature({ clusterId, name }: FeatureInstallRequest) { - tracker.event("cluster", "uninstall-feature") - return this.getCluster(clusterId)?.uninstallFeature(name); - } - - static ipcListen(clusterManager: ClusterManager) { - const handlers = { - [ClusterIpcMessage.ADD]: clusterManager.addCluster, - [ClusterIpcMessage.STOP]: clusterManager.stopCluster, - [ClusterIpcMessage.REMOVE]: clusterManager.removeCluster, - [ClusterIpcMessage.REMOVE_WORKSPACE]: clusterManager.removeAllByWorkspace, - [ClusterIpcMessage.FEATURE_INSTALL]: clusterManager.installFeature, - [ClusterIpcMessage.FEATURE_UPGRADE]: clusterManager.upgradeFeature, - [ClusterIpcMessage.FEATURE_REMOVE]: clusterManager.uninstallFeature, - [ClusterIpcMessage.ICON_SAVE]: clusterManager.uploadClusterIcon, - [ClusterIpcMessage.ICON_RESET]: clusterManager.removeCluster, - }; - Object.entries(handlers).forEach(([key, handler]) => { - handlers[key as keyof typeof handlers] = handler.bind(clusterManager); - }) - handleMessages(handlers, { - timeout: 2000 - }) - } } diff --git a/src/main/cluster.ts b/src/main/cluster.ts index 33e0edcea0..d11060fcfd 100644 --- a/src/main/cluster.ts +++ b/src/main/cluster.ts @@ -13,7 +13,11 @@ import { getFeatures, installFeature, uninstallFeature, upgradeFeature } from ". import request, { RequestPromiseOptions } from "request-promise-native" import logger from "./logger" -enum ClusterStatus { +export enum ClusterIpcEvent { + STOP = "cluster:stop", +} + +export enum ClusterStatus { AccessGranted = 2, AccessDenied = 1, Offline = 0 @@ -310,7 +314,7 @@ export class Cluster implements ClusterModel { }) } - // serializable full-featured state of the cluster + // serializable cluster-info for push-notifications getState(): ClusterState { const state: ClusterState = { ...this.toJSON(), diff --git a/src/renderer/components/+add-cluster/add-cluster.scss b/src/renderer/components/+add-cluster/add-cluster.scss index 0458997e8a..7b913cd7f2 100644 --- a/src/renderer/components/+add-cluster/add-cluster.scss +++ b/src/renderer/components/+add-cluster/add-cluster.scss @@ -20,6 +20,12 @@ border-left: 1px solid #353a3e; } + .error { + border-radius: $radius; + padding: $padding; + background-color: pink; + } + a { color: $colorInfo; } diff --git a/src/renderer/components/+add-cluster/add-cluster.tsx b/src/renderer/components/+add-cluster/add-cluster.tsx index 1c9cb557f6..13419b71b0 100644 --- a/src/renderer/components/+add-cluster/add-cluster.tsx +++ b/src/renderer/components/+add-cluster/add-cluster.tsx @@ -1,39 +1,101 @@ import "./add-cluster.scss" +import path from "path"; +import fs from "fs-extra"; import React from "react"; import { observer } from "mobx-react"; import { computed, observable } from "mobx"; -import path from "path"; import { Select, SelectOption } from "../select"; import { t, Trans } from "@lingui/macro"; import { Input } from "../input"; import { _i18n } from "../../i18n"; import { AceEditor } from "../ace-editor"; import { Button } from "../button"; +import { KubeConfig } from "@kubernetes/client-node"; +import { loadConfig, saveConfigToAppFiles, splitConfig, validateConfig } from "../../../common/kube-helpers"; +import { tracker } from "../../../common/tracker"; +import { clusterStore } from "../../../common/cluster-store"; +import { workspaceStore } from "../../../common/workspace-store"; +import { v4 as uuid } from "uuid" @observer export class AddCluster extends React.Component { - readonly customContext = "custom" - readonly kubeConfigFile = path.join(process.env.HOME, '.kube', 'config'); + readonly custom: any = "custom" + @observable.ref clusterConfig: KubeConfig; + @observable.ref kubeConfig: KubeConfig; // local ~/.kube/config (if available) + @observable isWaiting = false @observable showSettings = false - @observable clusterContext = "" @observable error = "" - @observable proxyServerUrl = "" + @observable proxyServer = "" @observable customConfig = "" - // todo: mark new contexts with badge - @computed get clusterOptions(): SelectOption[] { - return [ - { - label: Custom.., - value: this.customContext, - } - ] + async componentDidMount() { + const kubeConfig = await this.readLocalKubeConfig(); + if (kubeConfig) { + this.kubeConfig = loadConfig(kubeConfig) + this.customConfig = kubeConfig + } } - // todo - addCluster = () => { - console.log('add new cluster') + async readLocalKubeConfig(): Promise { + const localPath = path.join(process.env.HOME, '.kube', 'config'); + return fs.readFile(localPath, "utf8").catch(() => null) + } + + @computed get isCustom() { + return this.clusterConfig === this.custom; + } + + @computed get clusterOptions() { + const options: SelectOption[] = []; + if (this.kubeConfig) { + splitConfig(this.kubeConfig).forEach(kubeConfig => { + const contextName = kubeConfig.getCurrentContext(); + const isNew = false; // fixme: detect new context since last visit + options.push({ + value: kubeConfig, + label: <> + {contextName} + {isNew && (new)} + , + }) + }) + } + options.push({ + label: Custom.., + value: this.custom, + }); + return options; + } + + addCluster = async () => { + tracker.event("cluster", "add"); + const { clusterConfig, customConfig, proxyServer } = this; + const clusterId = uuid(); + try { + const config = this.isCustom ? loadConfig(customConfig) : clusterConfig; + if (!config) { + this.error = "Please select kubeconfig" + return; + } + this.error = "" + this.isWaiting = true + validateConfig(config); + clusterStore.addCluster({ + id: clusterId, + kubeConfigPath: saveConfigToAppFiles(clusterId, config), + workspace: workspaceStore.currentWorkspaceId, + contextName: config.currentContext, + preferences: { + clusterName: config.currentContext, + httpsProxy: proxyServer || undefined, + }, + }) + } catch (err) { + this.error = String(err); + } finally { + this.isWaiting = false; + } } render() { @@ -43,9 +105,9 @@ export class AddCluster extends React.Component {

Add Cluster

:)`)} - value={this.proxyServerUrl} - onChange={value => this.proxyServerUrl = value} + value={this.proxyServer} + onChange={value => this.proxyServer = value} /> HTTP Proxy server. Used for communicating with Kubernetes API. )} - {this.clusterContext === this.customContext && ( + {this.isCustom && (

Kubeconfig:

)} + {this.error && ( +
{this.error}
+ )}
diff --git a/src/renderer/components/+cluster-settings/cluster-icon.scss b/src/renderer/components/+cluster-settings/cluster-icon.scss index 3d1cc0e6b5..09ad4b9e3f 100644 --- a/src/renderer/components/+cluster-settings/cluster-icon.scss +++ b/src/renderer/components/+cluster-settings/cluster-icon.scss @@ -1,5 +1,5 @@ .ClusterIcon { - --size: 40px; + --size: 37px; position: relative; opacity: .75; diff --git a/src/renderer/components/cluster-manager/clusters-menu.scss b/src/renderer/components/cluster-manager/clusters-menu.scss index de38ba258c..01973ec058 100644 --- a/src/renderer/components/cluster-manager/clusters-menu.scss +++ b/src/renderer/components/cluster-manager/clusters-menu.scss @@ -1,8 +1,7 @@ .ClustersMenu { - --flex-gap: #{$padding * 2}; - position: relative; - padding: var(--flex-gap); + text-align: center; + padding: $padding * 2 $padding; background: var(--clusters-menu-bgc); > .startup-tooltip { @@ -35,14 +34,17 @@ > .clusters { @include hidden-scrollbar; + --flex-gap: #{$padding * 2}; + padding: $padding; .is-mac & { - margin-top: $padding; + margin-top: $padding * 2; } } > .add-cluster { position: relative; + margin-top: $padding; .Icon { border-radius: $radius; diff --git a/src/renderer/components/cluster-manager/clusters-menu.tsx b/src/renderer/components/cluster-manager/clusters-menu.tsx index f64500210f..d34079b7d3 100644 --- a/src/renderer/components/cluster-manager/clusters-menu.tsx +++ b/src/renderer/components/cluster-manager/clusters-menu.tsx @@ -74,7 +74,7 @@ export class ClustersMenu extends React.Component { const showStartupHint = this.showHint && isLanding && noClusters; return (
this.showHint = false} > {showStartupHint && ( @@ -87,7 +87,7 @@ export class ClustersMenu extends React.Component {

)} -
+
{clusters.map(cluster => { return (