From d4ff99f3bfa7ab3ffd3a1e5130aed4a9245948c3 Mon Sep 17 00:00:00 2001 From: Sebastian Malton Date: Sun, 2 Aug 2020 08:56:39 -0400 Subject: [PATCH] convert the cluster settings view from Vue to React (#613) - Add a basic file picker component - Now stores the icons as base64 img src formatted data blobs - Changed over cluster settings to mobx - Removed old Vue files Signed-off-by: Sebastian Malton Co-authored-by: Sebastian Malton --- src/common/cluster-ipc.ts | 30 +++ src/common/cluster-store.ts | 29 +-- src/common/ipc.ts | 2 + src/common/kube-helpers.ts | 14 +- src/common/user-store.ts | 10 +- src/features/metrics.ts | 74 +++---- src/features/user-mode.ts | 60 +++--- src/main/cluster-manager.ts | 3 + src/main/cluster.ts | 8 +- src/main/feature-manager.ts | 59 +++-- src/main/feature.ts | 10 +- src/main/kube-auth-proxy.ts | 24 ++- src/main/resource-applier.ts | 4 +- .../+cluster-settings/cluster-settings.scss | 85 +++++++- .../+cluster-settings/cluster-settings.tsx | 19 +- .../components/cluster-home-dir-setting.tsx | 86 ++++++++ .../components/cluster-icon-setting.tsx | 72 +++++++ .../components/cluster-name-setting.tsx | 85 ++++++++ .../components/cluster-prometheus-setting.tsx | 41 ++++ .../components/cluster-proxy-setting.tsx | 105 +++++++++ .../components/cluster-workspace-setting.tsx | 36 ++++ .../components/install-metrics.tsx | 109 ++++++++++ .../components/install-user-mode.tsx | 108 ++++++++++ .../components/remove-cluster-button.tsx | 63 ++++++ .../+cluster-settings/components/statuses.ts | 24 +++ .../components/+cluster-settings/features.tsx | 20 ++ .../components/+cluster-settings/general.tsx | 28 +++ .../components/+cluster-settings/removal.tsx | 18 ++ .../components/+cluster-settings/status.tsx | 47 ++++ src/renderer/components/app.scss | 13 +- .../cluster-icon.scss | 0 .../cluster-icon.tsx | 4 +- src/renderer/components/cluster-icon/index.ts | 1 + .../cluster-manager/cluster-manager.tsx | 5 +- .../cluster-manager/clusters-menu.tsx | 2 +- .../components/file-picker/file-picker.scss | 14 ++ .../components/file-picker/file-picker.tsx | 203 ++++++++++++++++++ src/renderer/components/file-picker/index.ts | 1 + src/renderer/components/input/input.tsx | 30 ++- src/renderer/components/spinner/spinner.scss | 4 +- src/renderer/components/tooltip/tooltip.tsx | 34 ++- 41 files changed, 1375 insertions(+), 209 deletions(-) create mode 100644 src/renderer/components/+cluster-settings/components/cluster-home-dir-setting.tsx create mode 100644 src/renderer/components/+cluster-settings/components/cluster-icon-setting.tsx create mode 100644 src/renderer/components/+cluster-settings/components/cluster-name-setting.tsx create mode 100644 src/renderer/components/+cluster-settings/components/cluster-prometheus-setting.tsx create mode 100644 src/renderer/components/+cluster-settings/components/cluster-proxy-setting.tsx create mode 100644 src/renderer/components/+cluster-settings/components/cluster-workspace-setting.tsx create mode 100644 src/renderer/components/+cluster-settings/components/install-metrics.tsx create mode 100644 src/renderer/components/+cluster-settings/components/install-user-mode.tsx create mode 100644 src/renderer/components/+cluster-settings/components/remove-cluster-button.tsx create mode 100644 src/renderer/components/+cluster-settings/components/statuses.ts create mode 100644 src/renderer/components/+cluster-settings/features.tsx create mode 100644 src/renderer/components/+cluster-settings/general.tsx create mode 100644 src/renderer/components/+cluster-settings/removal.tsx create mode 100644 src/renderer/components/+cluster-settings/status.tsx rename src/renderer/components/{+cluster-settings => cluster-icon}/cluster-icon.scss (100%) rename src/renderer/components/{+cluster-settings => cluster-icon}/cluster-icon.tsx (96%) create mode 100644 src/renderer/components/cluster-icon/index.ts create mode 100644 src/renderer/components/file-picker/file-picker.scss create mode 100644 src/renderer/components/file-picker/file-picker.tsx create mode 100644 src/renderer/components/file-picker/index.ts diff --git a/src/common/cluster-ipc.ts b/src/common/cluster-ipc.ts index 577819c995..5d5dfe206d 100644 --- a/src/common/cluster-ipc.ts +++ b/src/common/cluster-ipc.ts @@ -17,4 +17,34 @@ export const clusterIpc = { return clusterStore.getById(clusterId)?.disconnect(); }, }), + + installFeature: createIpcChannel({ + channel: "cluster:install-feature", + handle: async (clusterId: ClusterId, feature: string, config?: any) => { + tracker.event("cluster", "install", feature); + const cluster = clusterStore.getById(clusterId); + + if (cluster) { + await cluster.installFeature(feature, config) + } else { + throw `${clusterId} is not a valid cluster id`; + } + } + }), + + uninstallFeature: createIpcChannel({ + channel: "cluster:uninstall-feature", + handle: (clusterId: ClusterId, feature: string) => { + tracker.event("cluster", "uninstall", feature); + return clusterStore.getById(clusterId)?.uninstallFeature(feature) + } + }), + + upgradeFeature: createIpcChannel({ + channel: "cluster:upgrade-feature", + handle: (clusterId: ClusterId, feature: string, config?: any) => { + tracker.event("cluster", "upgrade", feature); + return clusterStore.getById(clusterId)?.upgradeFeature(feature, config) + } + }), } \ No newline at end of file diff --git a/src/common/cluster-store.ts b/src/common/cluster-store.ts index 9fc1da019a..e4febf532a 100644 --- a/src/common/cluster-store.ts +++ b/src/common/cluster-store.ts @@ -1,7 +1,7 @@ import type { WorkspaceId } from "./workspace-store"; import path from "path"; import filenamify from "filenamify"; -import { app, ipcRenderer } from "electron"; +import { app, ipcRenderer, remote } from "electron"; import { copyFile, ensureDir, unlink } from "fs-extra"; import { action, computed, observable, toJS } from "mobx"; import { appProto, noClustersHost } from "./vars"; @@ -53,7 +53,8 @@ export interface ClusterPreferences { export class ClusterStore extends BaseStore { static get iconsDir() { - return path.join(app.getPath("userData"), "icons"); + // TODO: remove remote cheat + return path.join((app || remote.app).getPath("userData"), "icons"); } private constructor() { @@ -130,30 +131,6 @@ export class ClusterStore extends BaseStore { }) } - @action - protected async uploadIcon({ 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 resetIcon(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.ts b/src/common/ipc.ts index db65b00e6a..95e982f56c 100644 --- a/src/common/ipc.ts +++ b/src/common/ipc.ts @@ -45,6 +45,8 @@ export async function invokeIpc(channel: IpcChannel, ...args: any[]): P // todo: make isomorphic api export function handleIpc(channel: IpcChannel, handler: IpcMessageHandler, options: IpcHandleOpts = {}) { const { timeout = 0 } = options; + logger.info(`[IPC]: setup to handle "${channel}"`); + ipcMain.handle(channel, async (event, ...args) => { logger.info(`[IPC]: handle "${channel}"`, { args }); return new Promise(async (resolve, reject) => { diff --git a/src/common/kube-helpers.ts b/src/common/kube-helpers.ts index 67bedf70c0..3df03a8211 100644 --- a/src/common/kube-helpers.ts +++ b/src/common/kube-helpers.ts @@ -5,6 +5,7 @@ import path from "path" import os from "os" import yaml from "js-yaml" import logger from "../main/logger"; +import fse from "fs-extra" function resolveTilde(filePath: string) { if (filePath[0] === "~" && (filePath[1] === "/" || filePath.length === 1)) { @@ -15,11 +16,13 @@ function resolveTilde(filePath: string) { export function loadConfig(pathOrContent?: string): KubeConfig { const kc = new KubeConfig(); - if (path.isAbsolute(pathOrContent)) { - kc.loadFromFile(resolveTilde(pathOrContent)); + + if (fse.pathExistsSync(pathOrContent)) { + kc.loadFromFile(path.resolve(resolveTilde(pathOrContent))); } else { kc.loadFromString(pathOrContent); } + return kc } @@ -154,7 +157,12 @@ export function saveConfigToAppFiles(clusterId: string, kubeConfig: KubeConfig | export async function getKubeConfigLocal(): Promise { try { const configFile = path.join(process.env.HOME, '.kube', 'config'); - return readFile(configFile, "utf8"); + const file = await readFile(configFile, "utf8"); + const obj = yaml.safeLoad(file); + if (obj.contexts) { + obj.contexts = obj.context.filter((ctx: any) => ctx?.context?.cluster && ctx?.name) + } + return yaml.safeDump(obj); } catch (err) { logger.debug(`Cannot read local kube-config: ${err}`) return ""; diff --git a/src/common/user-store.ts b/src/common/user-store.ts index d6f7a7e73c..8d343a62e9 100644 --- a/src/common/user-store.ts +++ b/src/common/user-store.ts @@ -71,11 +71,11 @@ export class UserStore extends BaseStore { if (kubeConfig) { this.newContexts.clear(); const localContexts = loadConfig(kubeConfig).getContexts(); - localContexts.forEach(({ name }) => { - if (!this.seenContexts.has(name)) { - this.newContexts.add(name); - } - }) + console.log(localContexts) + localContexts + .filter(ctx => ctx.cluster) + .filter(ctx => !this.seenContexts.has(ctx.name)) + .forEach(ctx => this.newContexts.add(ctx.name)); } } diff --git a/src/features/metrics.ts b/src/features/metrics.ts index a8b556b7a6..0d30037b3f 100644 --- a/src/features/metrics.ts +++ b/src/features/metrics.ts @@ -27,7 +27,8 @@ export interface MetricsConfiguration { } export class MetricsFeature extends Feature { - name = 'metrics'; + static id = 'metrics' + name = MetricsFeature.id; latestVersion = "v2.17.2-lens1" config: MetricsConfiguration = { @@ -51,58 +52,49 @@ export class MetricsFeature extends Feature { storageClass: null, }; - async install(cluster: Cluster): Promise { + async install(cluster: Cluster): Promise { // Check if there are storageclasses const storageClient = cluster.getProxyKubeconfig().makeApiClient(k8s.StorageV1Api) const scs = await storageClient.listStorageClass(); - scs.body.items.forEach(sc => { - if(sc.metadata.annotations && - (sc.metadata.annotations['storageclass.kubernetes.io/is-default-class'] === 'true' || sc.metadata.annotations['storageclass.beta.kubernetes.io/is-default-class'] === 'true')) { - this.config.persistence.enabled = true; - } - }); + + this.config.persistence.enabled = scs.body.items.some(sc => ( + sc.metadata?.annotations?.['storageclass.kubernetes.io/is-default-class'] === 'true' || + sc.metadata?.annotations?.['storageclass.beta.kubernetes.io/is-default-class'] === 'true' + )); return super.install(cluster) } - async upgrade(cluster: Cluster): Promise { + async upgrade(cluster: Cluster): Promise { return this.install(cluster) } async featureStatus(kc: KubeConfig): Promise { - return new Promise( async (resolve, reject) => { - const client = kc.makeApiClient(AppsV1Api) - const status: FeatureStatus = { - currentVersion: null, - installed: false, - latestVersion: this.latestVersion, - canUpgrade: false, // Dunno yet - }; - try { - - const prometheus = (await client.readNamespacedStatefulSet('prometheus', 'lens-metrics')).body; - status.installed = true; - status.currentVersion = prometheus.spec.template.spec.containers[0].image.split(":")[1]; - status.canUpgrade = semver.lt(status.currentVersion, this.latestVersion, true); - resolve(status) - } catch(error) { - resolve(status) - } - }); + const client = kc.makeApiClient(AppsV1Api) + const status: FeatureStatus = { + currentVersion: null, + installed: false, + latestVersion: this.latestVersion, + canUpgrade: false, // Dunno yet + }; + + try { + const prometheus = (await client.readNamespacedStatefulSet('prometheus', 'lens-metrics')).body; + status.installed = true; + status.currentVersion = prometheus.spec.template.spec.containers[0].image.split(":")[1]; + status.canUpgrade = semver.lt(status.currentVersion, this.latestVersion, true); + } catch { + // ignore error + } + + return status; } - async uninstall(cluster: Cluster): Promise { - return new Promise(async (resolve, reject) => { - const rbacClient = cluster.getProxyKubeconfig().makeApiClient(RbacAuthorizationV1Api) - try { - await this.deleteNamespace(cluster.getProxyKubeconfig(), "lens-metrics") - await rbacClient.deleteClusterRole("lens-prometheus"); - await rbacClient.deleteClusterRoleBinding("lens-prometheus"); - resolve(true); - } catch(error) { - reject(error); - } - }); + async uninstall(cluster: Cluster): Promise { + const rbacClient = cluster.getProxyKubeconfig().makeApiClient(RbacAuthorizationV1Api) + + await this.deleteNamespace(cluster.getProxyKubeconfig(), "lens-metrics") + await rbacClient.deleteClusterRole("lens-prometheus"); + await rbacClient.deleteClusterRoleBinding("lens-prometheus"); } - } diff --git a/src/features/user-mode.ts b/src/features/user-mode.ts index a4c731a5ae..71e0652385 100644 --- a/src/features/user-mode.ts +++ b/src/features/user-mode.ts @@ -3,48 +3,42 @@ import {KubeConfig, RbacAuthorizationV1Api} from "@kubernetes/client-node" import { Cluster } from "../main/cluster" export class UserModeFeature extends Feature { - name = 'user-mode'; + static id = 'user-mode' + name = UserModeFeature.id; latestVersion = "v2.0.0" - async install(cluster: Cluster): Promise { + async install(cluster: Cluster): Promise { return super.install(cluster) } - async upgrade(cluster: Cluster): Promise { - return true + async upgrade(cluster: Cluster): Promise { + return; } async featureStatus(kc: KubeConfig): Promise { - return new Promise( async (resolve, reject) => { - const client = kc.makeApiClient(RbacAuthorizationV1Api) - const status: FeatureStatus = { - currentVersion: null, - installed: false, - latestVersion: this.latestVersion, - canUpgrade: false, // Dunno yet - }; - try { - await client.readClusterRoleBinding("lens-user") - status.installed = true; - status.currentVersion = this.latestVersion - status.canUpgrade = false - resolve(status) - } catch(error) { - resolve(status) - } - }); + const client = kc.makeApiClient(RbacAuthorizationV1Api) + const status: FeatureStatus = { + currentVersion: null, + installed: false, + latestVersion: this.latestVersion, + canUpgrade: false, // Dunno yet + }; + + try { + await client.readClusterRoleBinding("lens-user") + status.installed = true; + status.currentVersion = this.latestVersion; + status.canUpgrade = false; + } catch { + // ignore error + } + + return status; } - async uninstall(cluster: Cluster): Promise { - return new Promise(async (resolve, reject) => { - const rbacClient = cluster.getProxyKubeconfig().makeApiClient(RbacAuthorizationV1Api) - try { - await rbacClient.deleteClusterRole("lens-user"); - await rbacClient.deleteClusterRoleBinding("lens-user"); - resolve(true); - } catch(error) { - reject(error); - } - }); + async uninstall(cluster: Cluster): Promise { + const rbacClient = cluster.getProxyKubeconfig().makeApiClient(RbacAuthorizationV1Api) + await rbacClient.deleteClusterRole("lens-user"); + await rbacClient.deleteClusterRoleBinding("lens-user"); } } diff --git a/src/main/cluster-manager.ts b/src/main/cluster-manager.ts index 0bbd280c1e..09e6404c88 100644 --- a/src/main/cluster-manager.ts +++ b/src/main/cluster-manager.ts @@ -34,6 +34,9 @@ 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.installFeature.handleInMain(); + clusterIpc.uninstallFeature.handleInMain(); + clusterIpc.upgradeFeature.handleInMain(); } stop() { diff --git a/src/main/cluster.ts b/src/main/cluster.ts index 7ee6c93eb1..a6692cc4c0 100644 --- a/src/main/cluster.ts +++ b/src/main/cluster.ts @@ -136,7 +136,7 @@ export class Cluster implements ClusterModel { async reconnect() { logger.info(`[CLUSTER]: reconnect`, this.getMeta()); - await this.contextHandler.stopServer(); + this.contextHandler.stopServer(); await this.contextHandler.ensureServer(); this.disconnected = false; } @@ -206,15 +206,15 @@ export class Cluster implements ClusterModel { } async installFeature(name: string, config: any) { - return await installFeature(name, this, config) + return installFeature(name, this, config) } async upgradeFeature(name: string, config: any) { - return await upgradeFeature(name, this, config) + return upgradeFeature(name, this, config) } async uninstallFeature(name: string) { - return await uninstallFeature(name, this) + return uninstallFeature(name, this) } getPrometheusApiPrefix() { diff --git a/src/main/feature-manager.ts b/src/main/feature-manager.ts index f1e20be07a..375872929e 100644 --- a/src/main/feature-manager.ts +++ b/src/main/feature-manager.ts @@ -1,55 +1,44 @@ import { KubeConfig } from "@kubernetes/client-node" import logger from "./logger"; import { Cluster } from "./cluster"; -import { Feature, FeatureStatusMap } from "./feature" +import { Feature, FeatureStatusMap, FeatureMap } from "./feature" import { MetricsFeature } from "../features/metrics" import { UserModeFeature } from "../features/user-mode" -const ALL_FEATURES: any = { - 'metrics': new MetricsFeature(null), - 'user-mode': new UserModeFeature(null), -} +const ALL_FEATURES: Map = new Map([ + [MetricsFeature.id, new MetricsFeature(null)], + [UserModeFeature.id, new UserModeFeature(null)], +]); export async function getFeatures(cluster: Cluster): Promise { - return new Promise(async (resolve, reject) => { - const result: FeatureStatusMap = {}; - logger.debug(`features for ${cluster.contextName}`); - for (const key in ALL_FEATURES) { - logger.debug(`feature ${key}`); - if (ALL_FEATURES.hasOwnProperty(key)) { - logger.debug("getting feature status..."); - const feature = ALL_FEATURES[key] as Feature; - const kc = new KubeConfig() - kc.loadFromFile(cluster.getProxyKubeconfigPath()) - - const status = await feature.featureStatus(kc); - result[feature.name] = status + const result: FeatureStatusMap = {}; + logger.debug(`features for ${cluster.contextName}`); - } else { - logger.error("ALL_FEATURES.hasOwnProperty(key) returned FALSE ?!?!?!?!") + for (const [key, feature] of ALL_FEATURES) { + logger.debug(`feature ${key}`); + logger.debug("getting feature status..."); - } - } - logger.debug(`getFeatures resolving with features: ${JSON.stringify(result)}`); - resolve(result); - }); + const kc = new KubeConfig(); + kc.loadFromFile(cluster.getProxyKubeconfigPath()); + + result[feature.name] = await feature.featureStatus(kc); + } + + logger.debug(`getFeatures resolving with features: ${JSON.stringify(result)}`); + return result; } -export async function installFeature(name: string, cluster: Cluster, config: any) { - const feature = ALL_FEATURES[name] as Feature +export async function installFeature(name: string, cluster: Cluster, config: any): Promise { // TODO Figure out how to handle config stuff - await feature.install(cluster) + return ALL_FEATURES.get(name).install(cluster) } -export async function upgradeFeature(name: string, cluster: Cluster, config: any) { - const feature = ALL_FEATURES[name] as Feature +export async function upgradeFeature(name: string, cluster: Cluster, config: any): Promise { // TODO Figure out how to handle config stuff - await feature.upgrade(cluster) + return ALL_FEATURES.get(name).upgrade(cluster) } -export async function uninstallFeature(name: string, cluster: Cluster) { - const feature = ALL_FEATURES[name] as Feature - - await feature.uninstall(cluster) +export async function uninstallFeature(name: string, cluster: Cluster): Promise { + return ALL_FEATURES.get(name).uninstall(cluster) } diff --git a/src/main/feature.ts b/src/main/feature.ts index f278328092..a62012af5e 100644 --- a/src/main/feature.ts +++ b/src/main/feature.ts @@ -7,6 +7,7 @@ import { Cluster } from "./cluster"; import logger from "./logger"; export type FeatureStatusMap = Record +export type FeatureMap = Record export interface FeatureInstallRequest { clusterId: string; @@ -25,23 +26,22 @@ export abstract class Feature { name: string; latestVersion: string; - abstract async upgrade(cluster: Cluster): Promise; + abstract async upgrade(cluster: Cluster): Promise; - abstract async uninstall(cluster: Cluster): Promise; + abstract async uninstall(cluster: Cluster): Promise; abstract async featureStatus(kc: KubeConfig): Promise; constructor(public config: any) { } - async install(cluster: Cluster): Promise { + async install(cluster: Cluster): Promise { const resources = this.renderTemplates(); try { await new ResourceApplier(cluster).kubectlApplyAll(resources); - return true; } catch (err) { logger.error("Installing feature error", { err, cluster }); - return false + throw err; } } diff --git a/src/main/kube-auth-proxy.ts b/src/main/kube-auth-proxy.ts index 6079c00347..ea87eadfe9 100644 --- a/src/main/kube-auth-proxy.ts +++ b/src/main/kube-auth-proxy.ts @@ -31,25 +31,30 @@ export class KubeAuthProxy { return; } const proxyBin = await this.kubectl.getPath() - let args = [ + const args = [ "proxy", - "-p", this.port.toString(), - "--kubeconfig", this.cluster.kubeConfigPath, - "--context", this.cluster.contextName, + "-p", `${this.port}`, + // "--kubeconfig", `"${this.cluster.kubeConfigPath}"`, + // "--context", `"${this.cluster.contextName}"`, + "--kubeconfig", `${this.cluster.kubeConfigPath}`, + "--context", `${this.cluster.contextName}`, "--accept-hosts", ".*", "--reject-paths", "^[^/]" ] if (process.env.DEBUG_PROXY === "true") { - args = args.concat(["-v", "9"]) + args.push("-v", "9") } logger.debug(`spawning kubectl proxy with args: ${args}`) - this.proxyProcess = spawn(proxyBin, args, { - env: this.env - }) + this.proxyProcess = spawn(proxyBin, args, { env: this.env, }) + this.proxyProcess.on("exit", (code) => { this.sendIpcLogMessage({ data: `proxy exited with code: ${code}`, error: code > 0 }) - this.proxyProcess = null + this.proxyProcess.removeAllListeners(); + this.proxyProcess.stderr.removeAllListeners(); + this.proxyProcess.stdout.removeAllListeners(); + this.proxyProcess = null; }) + this.proxyProcess.stdout.on('data', (data) => { let logItem = data.toString() if (logItem.startsWith("Starting to serve on")) { @@ -57,6 +62,7 @@ export class KubeAuthProxy { } this.sendIpcLogMessage({ data: logItem }) }) + this.proxyProcess.stderr.on('data', (data) => { this.lastError = this.parseError(data.toString()) this.sendIpcLogMessage({ data: data.toString(), error: true }) diff --git a/src/main/resource-applier.ts b/src/main/resource-applier.ts index bb3acc12e4..7628166a26 100644 --- a/src/main/resource-applier.ts +++ b/src/main/resource-applier.ts @@ -25,7 +25,7 @@ export class ResourceApplier { return new Promise((resolve, reject) => { const fileName = tempy.file({ name: "resource.yaml" }) fs.writeFileSync(fileName, content) - const cmd = `"${kubectlPath}" apply --kubeconfig "${kubeConfigPath}" -o json -f ${fileName}` + const cmd = `"${kubectlPath}" apply --kubeconfig "${kubeConfigPath}" -o json -f "${fileName}"` logger.debug("shooting manifests with: " + cmd); const execEnv: NodeJS.ProcessEnv = Object.assign({}, process.env) const httpsProxy = this.cluster.preferences?.httpsProxy @@ -54,7 +54,7 @@ export class ResourceApplier { resources.forEach((resource, index) => { fs.writeFileSync(path.join(tmpDir, `${index}.yaml`), resource); }) - const cmd = `"${kubectlPath}" apply --kubeconfig "${kubeConfigPath}" -o json -f ${tmpDir}` + const cmd = `"${kubectlPath}" apply --kubeconfig "${kubeConfigPath}" -o json -f "${tmpDir}"` console.log("shooting manifests with:", cmd); exec(cmd, (error, stdout, stderr) => { if (error) { diff --git a/src/renderer/components/+cluster-settings/cluster-settings.scss b/src/renderer/components/+cluster-settings/cluster-settings.scss index 743c0079ec..1ea6926eab 100644 --- a/src/renderer/components/+cluster-settings/cluster-settings.scss +++ b/src/renderer/components/+cluster-settings/cluster-settings.scss @@ -1,3 +1,86 @@ .ClusterSettings { - + overflow-y: scroll; + grid-template-columns: unset; + + .info-col { + display: none; + } + + .content-col { + margin-right: unset; + } + + * { + margin-top: 40px; + + &:first-child { + margin-top: 0px; + } + } + + h4 { + margin-top: 20px; + } + + .status-table { + margin-top: 20px; + display: grid; + grid-template-columns: 1fr 3fr; + grid-gap: 10px; + } + + .loading { + margin-top: 20px; + text-align: center; + + .Spinner { + display: inline-block; + } + } + + .Input,.Select { + margin-top: 10px; + } + + .Icon:not(.updated):not(.clean) { + color: #ad0000; + } + + .Icon.updated { + color: #00dd1d; + } + + .updated { + animation: updated-name 1s 1; + animation-fill-mode: forwards; + animation-delay: 3s; + } + + @keyframes updated-name { + from {opacity :1;} + to {opacity :0;} + } + + .center { + text-align: center; + } + + input[type="text"]::placeholder { + font-size: small; + color: #707070; + } + + input[type="text"] { + color: white; + } + + button { + margin-top: 5px; + + .Spinner { + width: 10px; + height: 10px; + border-color: transparent black; + } + } } \ No newline at end of file diff --git a/src/renderer/components/+cluster-settings/cluster-settings.tsx b/src/renderer/components/+cluster-settings/cluster-settings.tsx index 8cb0c779ac..777562060b 100644 --- a/src/renderer/components/+cluster-settings/cluster-settings.tsx +++ b/src/renderer/components/+cluster-settings/cluster-settings.tsx @@ -1,14 +1,25 @@ import "./cluster-settings.scss" import React from "react"; import { observer } from "mobx-react"; +import { Features } from "./features" +import { Removal } from "./removal" +import { Status } from "./status" +import { General } from "./general" +import { getHostedCluster } from "../../../common/cluster-store" +import { WizardLayout } from "../layout/wizard-layout"; @observer export class ClusterSettings extends React.Component { render() { + const cluster = getHostedCluster(); + return ( -
- ClusterSettings -
- ); + + + + + + + ) } } diff --git a/src/renderer/components/+cluster-settings/components/cluster-home-dir-setting.tsx b/src/renderer/components/+cluster-settings/components/cluster-home-dir-setting.tsx new file mode 100644 index 0000000000..c8779c692a --- /dev/null +++ b/src/renderer/components/+cluster-settings/components/cluster-home-dir-setting.tsx @@ -0,0 +1,86 @@ +import React from "react"; +import { Cluster } from "../../../../main/cluster"; +import { Input } from "../../input"; +import { Spinner } from "../../spinner"; +import { clusterStore } from "../../../../common/cluster-store" +import { Icon } from "../../icon"; +import { Tooltip, TooltipPosition } from "../../tooltip"; +import { autobind } from "../../../utils"; +import { TextInputStatus } from "./statuses" +import { observable } from "mobx"; +import { observer } from "mobx-react"; + +interface Props { + cluster: Cluster; +} + +@observer +export class ClusterHomeDirSetting extends React.Component { + @observable directory = this.props.cluster.preferences.terminalCWD || ""; + @observable status = TextInputStatus.CLEAN; + @observable errorText?: string; + + render() { + return <> +

Working Directory

+

Set initial working directory for terminals. When set it will the `pwd` when a new terminal instance is opened for this cluster.

+ + ; + } + + @autobind() + onWorkingDirectoryChange(directory: string, _e: React.ChangeEvent) { + if (this.status === TextInputStatus.UPDATING) { + console.log("prevent changing cluster directory while updating"); + return; + } + + this.status = this.dirDiffers(directory); + this.directory = directory; + } + + dirDiffers(directory: string): TextInputStatus { + const { terminalCWD = "" } = this.props.cluster.preferences; + + return directory === terminalCWD ? TextInputStatus.CLEAN : TextInputStatus.DIRTY; + } + + getIconRight(): React.ReactNode { + switch (this.status) { + case TextInputStatus.CLEAN: + return null; + case TextInputStatus.DIRTY: + return ; + case TextInputStatus.UPDATED: + return ; + case TextInputStatus.UPDATING: + return ; + case TextInputStatus.ERROR: + return + + {this.errorText} + + + } + } + + @autobind() + onWorkingDirectorySubmit(directory: string) { + if (this.dirDiffers(directory) !== TextInputStatus.DIRTY) { + return; + } + + this.status = TextInputStatus.UPDATING + this.props.cluster.preferences.terminalCWD = directory; + this.directory = directory; + this.status = TextInputStatus.UPDATED + } +} \ No newline at end of file diff --git a/src/renderer/components/+cluster-settings/components/cluster-icon-setting.tsx b/src/renderer/components/+cluster-settings/components/cluster-icon-setting.tsx new file mode 100644 index 0000000000..2a5f0d0b97 --- /dev/null +++ b/src/renderer/components/+cluster-settings/components/cluster-icon-setting.tsx @@ -0,0 +1,72 @@ +import React from "react"; +import { Cluster } from "../../../../main/cluster"; +import { clusterStore } from "../../../../common/cluster-store" +import { Icon } from "../../icon"; +import { FilePicker, OverSizeLimitStyle } from "../../file-picker"; +import { autobind } from "../../../utils"; +import { Button } from "../../button"; +import { GeneralInputStatus } from "./statuses" +import { observable } from "mobx"; +import { observer } from "mobx-react"; + +interface Props { + cluster: Cluster; +} + +@observer +export class ClusterIconSetting extends React.Component { + @observable status = GeneralInputStatus.CLEAN; + @observable errorText?: string; + + @autobind() + async onIconPick([file]: File[]) { + const { cluster } = this.props; + + try { + if (file) { + const buf = Buffer.from(await file.arrayBuffer()); + cluster.preferences.icon = `data:image/jpeg;base64, ${buf.toString('base64')}`; + } else { + // this has to be done as a seperate branch (and not always) because `cluster` + // is observable and triggers an update loop. + cluster.preferences.icon = undefined; + } + } catch (e) { + this.errorText = e.toString() + this.status = GeneralInputStatus.ERROR + } + } + + getClearButton() { + const { cluster } = this.props; + + if (cluster.preferences.icon) { + return + } + } + + render() { + return <> +

Cluster Icon

+

Set cluster icon. By default it is automatically generated. {this.getIconRight()}

+
+ + {this.getClearButton()} +
+ ; + } + + getIconRight(): React.ReactNode { + switch (this.status) { + case GeneralInputStatus.CLEAN: + return null; + case GeneralInputStatus.ERROR: + return + } + } +} \ No newline at end of file diff --git a/src/renderer/components/+cluster-settings/components/cluster-name-setting.tsx b/src/renderer/components/+cluster-settings/components/cluster-name-setting.tsx new file mode 100644 index 0000000000..e605864030 --- /dev/null +++ b/src/renderer/components/+cluster-settings/components/cluster-name-setting.tsx @@ -0,0 +1,85 @@ +import React from "react"; +import { Cluster } from "../../../../main/cluster"; +import { Input } from "../../input"; +import { Spinner } from "../../spinner"; +import { clusterStore } from "../../../../common/cluster-store" +import { Icon } from "../../icon"; +import { Tooltip, TooltipPosition } from "../../tooltip"; +import { autobind } from "../../../utils"; +import { TextInputStatus } from "./statuses" +import { observable } from "mobx"; +import { observer } from "mobx-react"; + +interface Props { + cluster: Cluster; +} + +@observer +export class ClusterNameSetting extends React.Component { + @observable name = this.props.cluster.preferences.clusterName || ""; + @observable status = TextInputStatus.CLEAN; + @observable errorText?: string; + + render() { + return <> +

Cluster Name

+

Change cluster name:

+ + ; + } + + @autobind() + onClusterNameChange(name: string, _e: React.ChangeEvent) { + if (this.status === TextInputStatus.UPDATING) { + console.log("prevent changing cluster name while updating"); + return; + } + + this.status = this.nameDiffers(name) + this.name = name; + } + + nameDiffers(name: string): TextInputStatus { + const { clusterName } = this.props.cluster.preferences; + + return name === clusterName ? TextInputStatus.CLEAN : TextInputStatus.DIRTY; + } + + getIconRight(): React.ReactNode { + switch (this.status) { + case TextInputStatus.CLEAN: + return null; + case TextInputStatus.DIRTY: + return ; + case TextInputStatus.UPDATED: + return ; + case TextInputStatus.UPDATING: + return ; + case TextInputStatus.ERROR: + return + + {this.errorText} + + + } + } + + @autobind() + onClusterNameSubmit(name: string) { + if (this.nameDiffers(name) !== TextInputStatus.DIRTY) { + return; + } + + this.status = TextInputStatus.UPDATING + this.props.cluster.preferences.clusterName = name; + this.name = name; + this.status = TextInputStatus.UPDATED + } +} \ No newline at end of file diff --git a/src/renderer/components/+cluster-settings/components/cluster-prometheus-setting.tsx b/src/renderer/components/+cluster-settings/components/cluster-prometheus-setting.tsx new file mode 100644 index 0000000000..7596cc245b --- /dev/null +++ b/src/renderer/components/+cluster-settings/components/cluster-prometheus-setting.tsx @@ -0,0 +1,41 @@ +import React from "react"; +import { Cluster } from "../../../../main/cluster"; +import { clusterStore } from "../../../../common/cluster-store" +import { Select, SelectOption, SelectProps } from "../../select"; +import { prometheusProviders } from "../../../../common/prometheus-providers"; +import { autobind } from "../../../utils"; +import { observable } from "mobx"; +import { observer } from "mobx-react"; + +const prometheusGuide = "https://github.com/lensapp/lens/blob/master/troubleshooting/custom-prometheus.md"; +const options: SelectOption[] = [ + { value: "", label: "Auto detect" }, + ...prometheusProviders.map(pp => ({value: pp.id, label: pp.name})) +]; + +interface Props { + cluster: Cluster; +} + +@observer +export class ClusterPrometheusSetting extends React.Component { + @observable prometheusProvider = this.props.cluster.preferences.prometheusProvider?.type || ""; + + render() { + return <> +

Cluster Prometheus

+

Use pre-installed Prometheus service for metrics. Please refer to this guide for possible configuration changes.

+ + ; + } + + @autobind() + changeProxyState(proxy: string, _e: React.ChangeEvent) { + if (this.status === TextInputStatus.UPDATING) { + console.log("prevent changing cluster proxy while updating"); + return; + } + + this.status = this.proxyDiffers(proxy); + this.proxy = proxy; + } + + proxyDiffers(proxy: string): TextInputStatus { + const { httpsProxy = "" } = this.props.cluster.preferences; + + return proxy === httpsProxy ? TextInputStatus.CLEAN : TextInputStatus.DIRTY; + } + + getIconRight(): React.ReactNode { + switch (this.status) { + case TextInputStatus.CLEAN: + return null; + case TextInputStatus.DIRTY: + return ; + case TextInputStatus.UPDATED: + return ; + case TextInputStatus.UPDATING: + return ; + case TextInputStatus.ERROR: + return + + {this.errorText} + + + } + } + + @autobind() + updateClusterProxy(proxy: string) { + if (this.proxyDiffers(proxy) !== TextInputStatus.DIRTY) { + return; + } + + try { + const url = new URL(proxy); + + if (url.protocol !== "https") { + this.status = TextInputStatus.ERROR + this.errorText= `Proxy's protocol should be "https"` + return + } + if (url.port === "") { + this.status = TextInputStatus.ERROR + this.errorText= "Proxy should include a port" + return + } + } catch (e) { + this.status = TextInputStatus.ERROR + this.errorText= "Invalid URL" + return + } + + this.status = TextInputStatus.UPDATING + this.props.cluster.preferences.httpsProxy = proxy; + this.proxy = proxy; + this.status = TextInputStatus.UPDATED + } +} \ No newline at end of file diff --git a/src/renderer/components/+cluster-settings/components/cluster-workspace-setting.tsx b/src/renderer/components/+cluster-settings/components/cluster-workspace-setting.tsx new file mode 100644 index 0000000000..6337f56aa0 --- /dev/null +++ b/src/renderer/components/+cluster-settings/components/cluster-workspace-setting.tsx @@ -0,0 +1,36 @@ +import React from "react"; +import { Cluster } from "../../../../main/cluster"; +import { clusterStore } from "../../../../common/cluster-store" +import { workspaceStore } from "../../../../common/workspace-store" +import { Select, SelectOption } from "../../../components/select"; +import { GeneralInputStatus } from "./statuses" +import { observable } from "mobx"; +import { autobind } from "../../../utils"; +import { observer } from "mobx-react"; + +interface Props { + cluster: Cluster; +} + +@observer +export class ClusterWorkspaceSetting extends React.Component { + @observable workspace = this.props.cluster.workspace; + + render() { + return <> +

Cluster Workspace

+

Change cluster workspace:

+ this.handlePickFiles(event.target.files)} + /> + ; + } + + getIconRight(): React.ReactNode { + switch (this.status) { + case FileInputStatus.CLEAR: + return + case FileInputStatus.PROCESSING: + return ; + case FileInputStatus.ERROR: + return + } + } +} \ No newline at end of file diff --git a/src/renderer/components/file-picker/index.ts b/src/renderer/components/file-picker/index.ts new file mode 100644 index 0000000000..9e4bd291c2 --- /dev/null +++ b/src/renderer/components/file-picker/index.ts @@ -0,0 +1 @@ +export * from "./file-picker" \ No newline at end of file diff --git a/src/renderer/components/input/input.tsx b/src/renderer/components/input/input.tsx index 86e4bc09a7..7502850c3a 100644 --- a/src/renderer/components/input/input.tsx +++ b/src/renderer/components/input/input.tsx @@ -13,7 +13,7 @@ type Omit = Pick> type InputElement = HTMLInputElement | HTMLTextAreaElement; type InputElementProps = InputHTMLAttributes & TextareaHTMLAttributes & DOMAttributes; -export type InputProps = Omit & { +export type InputProps = Omit & { theme?: "round-black"; className?: string; value?: T; @@ -25,6 +25,7 @@ export type InputProps = Omit & { iconRight?: string | React.ReactNode; validators?: Validator | Validator[]; onChange?(value: T, evt: React.ChangeEvent): void; + onSubmit?(value: T): void; } interface State { @@ -100,7 +101,7 @@ export class Input extends React.Component { async validate(value = this.getValue()) { let validationId = (this.validationId = ""); // reset every time for async validators const asyncValidators: Promise[] = []; - let errors: React.ReactNode[] = []; + const errors: React.ReactNode[] = []; // run validators for (const validator of this.validators) { @@ -130,12 +131,10 @@ export class Input extends React.Component { // handle async validators result if (asyncValidators.length > 0) { - this.setState({ validating: true, valid: false, }); + this.setState({ validating: true, valid: false }); const asyncErrors = await Promise.all(asyncValidators); - const isLastValidationCheck = this.validationId === validationId; - if (isLastValidationCheck) { - errors = this.state.errors.concat(asyncErrors.filter(err => err)); - this.setValidation(errors); + if (this.validationId === validationId) { + this.setValidation(errors.concat(...asyncErrors.filter(err => err))); } } @@ -157,7 +156,7 @@ export class Input extends React.Component { private setupValidators() { this.validators = conditionalValidators - // add conditional validators if matches input props + // add conditional validators if matches input props .filter(validator => validator.condition(this.props)) // add custom validators .concat(this.props.validators) @@ -209,6 +208,19 @@ export class Input extends React.Component { } } + @autobind() + onKeyDown(evt: React.KeyboardEvent) { + const modified = evt.shiftKey || evt.metaKey || evt.altKey || evt.ctrlKey; + + switch (evt.key) { + case "Enter": + if (this.props.onSubmit && !modified && !evt.repeat) { + this.props.onSubmit(this.getValue()); + } + break; + } + } + get showMaxLenIndicator() { const { maxLength, multiLine } = this.props; return maxLength && multiLine; @@ -269,8 +281,10 @@ export class Input extends React.Component { onFocus: this.onFocus, onBlur: this.onBlur, onChange: this.onChange, + onKeyDown: this.onKeyDown, rows: multiLine ? (rows || 1) : null, ref: this.bindRef, + type: "text", }); return ( diff --git a/src/renderer/components/spinner/spinner.scss b/src/renderer/components/spinner/spinner.scss index 39a3a63a6d..802ef27bfc 100644 --- a/src/renderer/components/spinner/spinner.scss +++ b/src/renderer/components/spinner/spinner.scss @@ -8,9 +8,7 @@ $colorAnimation: colors $duration*4 ease-in-out infinite; @mixin spinner-color($color) { - border-color: $color; - border-left-color: transparent; - border-right-color: transparent; + border-color: transparent $color; } width: var(--spinner-size); diff --git a/src/renderer/components/tooltip/tooltip.tsx b/src/renderer/components/tooltip/tooltip.tsx index 2645595186..b985b04f02 100644 --- a/src/renderer/components/tooltip/tooltip.tsx +++ b/src/renderer/components/tooltip/tooltip.tsx @@ -76,18 +76,14 @@ export class Tooltip extends React.Component { const { position } = this.props; const { elem, targetElem } = this; - let allPositions: TooltipPosition[] = [ - TooltipPosition.RIGHT, - TooltipPosition.BOTTOM, - TooltipPosition.LEFT, - TooltipPosition.RIGHT, - ]; - if (allPositions.includes(position)) { - allPositions = [ - position, // put first as priority side for positioning - ...allPositions.filter(pos => pos !== position), - ]; + const positionPreference = new Set(); + if (typeof position !== "undefined") { + positionPreference.add(position); } + positionPreference.add(TooltipPosition.RIGHT) + .add(TooltipPosition.BOTTOM) + .add(TooltipPosition.TOP) + .add(TooltipPosition.LEFT) // reset position first and get all possible client-rect area for tooltip element this.setPosition({ left: 0, top: 0 }); @@ -97,21 +93,21 @@ export class Tooltip extends React.Component { const { innerWidth: viewportWidth, innerHeight: viewportHeight } = window; // find proper position - this.activePosition = null; - for (const pos of allPositions) { + for (const pos of positionPreference) { const { left, top, right, bottom } = this.getPosition(pos, selfBounds, targetBounds) const fitsToWindow = left >= 0 && top >= 0 && right <= viewportWidth && bottom <= viewportHeight; if (fitsToWindow) { this.activePosition = pos; this.setPosition({ top, left }); - break; + + return; } } - if (!this.activePosition) { - const { left, top } = this.getPosition(allPositions[0], selfBounds, targetBounds) - this.activePosition = allPositions[0]; - this.setPosition({ left, top }); - } + + const preferedPosition = Array.from(positionPreference)[0]; + const { left, top } = this.getPosition(preferedPosition, selfBounds, targetBounds) + this.activePosition = preferedPosition; + this.setPosition({ left, top }); } protected setPosition(pos: { left: number, top: number }) {