Merge remote-tracking branch 'origin/master' into feature/extensions_api

# Conflicts:
#	package.json
#	src/renderer/components/cluster-manager/clusters-menu.tsx
This commit is contained in:
Roman committed 2020-09-07 13:40:49 +03:00
commit b1783d6150
44 files changed
+877 -291

No files matched your search

+1
View File
@@ -13,6 +13,7 @@ export const clusterIpc = {
}
},
}),
activate: createIpcChannel({
channel: "cluster:activate",
handle: (clusterId: ClusterId) => {
+25 -5
View File
@@ -1,4 +1,4 @@
import type { WorkspaceId } from "./workspace-store";
import { WorkspaceId, workspaceStore } from "./workspace-store";
import path from "path";
import { app, ipcRenderer, remote } from "electron";
import { unlink } from "fs-extra";
@@ -11,6 +11,9 @@ import { tracker } from "./tracker";
import { dumpConfigYaml } from "./kube-helpers";
import { saveToAppFiles } from "./utils/saveToAppFiles";
import { KubeConfig } from "@kubernetes/client-node";
import _ from "lodash";
import move from "array-move";
import { is } from "immer/dist/internal";
export interface ClusterIconUpload {
clusterId: string;
@@ -48,6 +51,7 @@ export interface ClusterPreferences {
prometheusProvider?: {
type: string;
};
iconOrder?: number;
icon?: string;
httpsProxy?: string;
}
@@ -60,7 +64,7 @@ export class ClusterStore extends BaseStore<ClusterStoreModel> {
static embedCustomKubeConfig(clusterId: ClusterId, kubeConfig: KubeConfig | string): string {
const filePath = ClusterStore.getCustomKubeConfigPath(clusterId);
const fileContents = typeof kubeConfig == "string" ? kubeConfig : dumpConfigYaml(kubeConfig);
saveToAppFiles(filePath, fileContents);
saveToAppFiles(filePath, fileContents, { mode: 0o600});
return filePath;
}
@@ -101,6 +105,20 @@ export class ClusterStore extends BaseStore<ClusterStoreModel> {
this.activeClusterId = id;
}
@action
swapIconOrders(workspace: WorkspaceId, from: number, to: number) {
const clusters = this.getByWorkspaceId(workspace);
if (from < 0 || to < 0 || from >= clusters.length || to >= clusters.length || isNaN(from) || isNaN(to)) {
throw new Error(`invalid from<->to arguments`)
}
move.mutate(clusters, from, to);
for (const i in clusters) {
// This resets the iconOrder to the current display order
clusters[i].preferences.iconOrder = +i;
}
}
hasClusters() {
return this.clusters.size > 0;
}
@@ -114,7 +132,9 @@ export class ClusterStore extends BaseStore<ClusterStoreModel> {
}
getByWorkspaceId(workspaceId: string): Cluster[] {
return this.clustersList.filter(cluster => cluster.workspace === workspaceId)
const clusters = Array.from(this.clusters.values())
.filter(cluster => cluster.workspace === workspaceId);
return _.sortBy(clusters, cluster => cluster.preferences.iconOrder)
}
@action
@@ -156,7 +176,7 @@ export class ClusterStore extends BaseStore<ClusterStoreModel> {
const removedClusters = new Map<ClusterId, Cluster>();
// update new clusters
clusters.forEach(clusterModel => {
for (const clusterModel of clusters) {
let cluster = currentClusters.get(clusterModel.id);
if (cluster) {
cluster.updateModel(clusterModel);
@@ -164,7 +184,7 @@ export class ClusterStore extends BaseStore<ClusterStoreModel> {
cluster = new Cluster(clusterModel);
}
newClusters.set(clusterModel.id, cluster);
});
}
// update removed clusters
currentClusters.forEach(cluster => {
+28
View File
@@ -93,6 +93,34 @@ describe("empty config", () => {
expect(fs.readFileSync(file, "utf8")).toBe("kubeconfig");
})
it("check if reorderring works for same from and to", () => {
clusterStore.swapIconOrders("workstation", 1, 1)
const clusters = clusterStore.getByWorkspaceId("workstation");
expect(clusters[0].id).toBe("prod")
expect(clusters[0].preferences.iconOrder).toBe(0)
expect(clusters[1].id).toBe("dev")
expect(clusters[1].preferences.iconOrder).toBe(1)
});
it("check if reorderring works for different from and to", () => {
clusterStore.swapIconOrders("workstation", 0, 1)
const clusters = clusterStore.getByWorkspaceId("workstation");
expect(clusters[0].id).toBe("dev")
expect(clusters[0].preferences.iconOrder).toBe(0)
expect(clusters[1].id).toBe("prod")
expect(clusters[1].preferences.iconOrder).toBe(1)
});
it("check if after icon reordering, changing workspaces still works", () => {
clusterStore.swapIconOrders("workstation", 1, 1)
clusterStore.getById("prod").workspace = "default"
expect(clusterStore.getByWorkspaceId("workstation").length).toBe(1);
expect(clusterStore.getByWorkspaceId("default").length).toBe(2);
});
it("removes cluster from store", async () => {
await clusterStore.removeById("foo");
expect(clusterStore.getById("foo")).toBeUndefined();
+16
View File
@@ -1,4 +1,5 @@
import type { ThemeId } from "../renderer/theme.store";
import { app, remote } from 'electron';
import semver from "semver"
import { readFile } from "fs-extra"
import { action, observable, reaction, toJS } from "mobx";
@@ -8,6 +9,7 @@ import { getAppVersion } from "./utils/app-version";
import { kubeConfigDefaultPath, loadConfig } from "./kube-helpers";
import { tracker } from "./tracker";
import logger from "../main/logger";
import path from 'path';
export interface UserStoreModel {
kubeConfigPath: string;
@@ -22,6 +24,9 @@ export interface UserPreferences {
allowUntrustedCAs?: boolean;
allowTelemetry?: boolean;
downloadMirror?: string | "default";
downloadKubectlBinaries?: boolean;
downloadBinariesPath?: string;
kubectlBinariesPath?: string;
}
export class UserStore extends BaseStore<UserStoreModel> {
@@ -53,6 +58,9 @@ export class UserStore extends BaseStore<UserStoreModel> {
allowUntrustedCAs: false,
colorTheme: UserStore.defaultTheme,
downloadMirror: "default",
downloadKubectlBinaries: true, // Download kubectl binaries matching cluster version
downloadBinariesPath: this.getDefaultKubectlPath(),
kubectlBinariesPath: ""
};
get isNewVersion() {
@@ -98,6 +106,14 @@ export class UserStore extends BaseStore<UserStoreModel> {
this.newContexts.clear();
}
/**
* Getting default directory to download kubectl binaries
* @returns string
*/
getDefaultKubectlPath(): string {
return path.join((app || remote.app).getPath("userData"), "binaries")
}
@action
protected async fromStore(data: Partial<UserStoreModel> = {}) {
const { lastSeenAppVersion, seenContexts = [], preferences, kubeConfigPath } = data
+3 -3
View File
@@ -1,11 +1,11 @@
// Save file to electron app directory (e.g. "/Users/$USER/Library/Application Support/Lens" for MacOS)
import path from "path";
import { app, remote } from "electron";
import { ensureDirSync, writeFileSync } from "fs-extra";
import { ensureDirSync, writeFileSync, WriteFileOptions } from "fs-extra";
export function saveToAppFiles(filePath: string, contents: any): string {
export function saveToAppFiles(filePath: string, contents: any, options?: WriteFileOptions): string {
const absPath = path.resolve((app || remote.app).getPath("userData"), filePath);
ensureDirSync(path.dirname(absPath));
writeFileSync(absPath, contents);
writeFileSync(absPath, contents, options);
return absPath;
}
+1 -1
View File
@@ -8,8 +8,8 @@ export const isMac = process.platform === "darwin"
export const isWindows = process.platform === "win32"
export const isDebugging = process.env.DEBUG === "true";
export const isProduction = process.env.NODE_ENV === "production"
export const isDevelopment = isDebugging || !isProduction;
export const isTestEnv = !!process.env.JEST_WORKER_ID;
export const isDevelopment = !isTestEnv && !isProduction;
export const appName = `${packageInfo.productName}${isDevelopment ? "Dev" : ""}`
export const publicPath = "/build/"
+2 -2
View File
@@ -2,7 +2,7 @@ import type { ClusterId, ClusterModel, ClusterPreferences } from "../common/clus
import type { IMetricsReqParams } from "../renderer/api/endpoints/metrics.api";
import type { WorkspaceId } from "../common/workspace-store";
import type { FeatureStatusMap } from "./feature"
import { action, computed, observable, reaction, toJS, when } from "mobx";
import { action, computed, intercept, observable, reaction, toJS, when } from "mobx";
import { apiKubePrefix } from "../common/vars";
import { broadcastIpc } from "../common/ipc";
import { ContextHandler } from "./context-handler"
@@ -130,7 +130,7 @@ export class Cluster implements ClusterModel {
if (!this.eventDisposers.length) {
this.bindEvents();
}
if (this.disconnected) {
if (this.disconnected || !this.accessible) {
await this.reconnect();
}
await this.refresh();
+1
View File
@@ -46,6 +46,7 @@ export class HelmRepoManager extends Singleton {
}
async init() {
helmCli.setLogger(logger)
await helmCli.ensureBinary();
if (!this.initialized) {
this.helmEnv = await this.parseHelmEnv()
+4 -3
View File
@@ -21,7 +21,7 @@ import logger from "./logger"
const workingDir = path.join(app.getPath("appData"), appName);
app.setName(appName);
if(!process.env.CICD) {
if (!process.env.CICD) {
app.setPath("userData", workingDir);
}
@@ -49,7 +49,8 @@ async function main() {
try {
proxyPort = await getFreePort()
} catch (error) {
await dialog.showErrorBox("Lens Error", "Could not find a free port for the cluster proxy")
logger.error(error)
dialog.showErrorBox("Lens Error", "Could not find a free port for the cluster proxy")
app.quit();
}
@@ -68,7 +69,7 @@ async function main() {
proxyServer = LensProxy.create(proxyPort, clusterManager);
} catch (error) {
logger.error(`Could not start proxy (127.0.0:${proxyPort}): ${error.message}`)
await dialog.showErrorBox("Lens Error", `Could not start proxy (127.0.0:${proxyPort}): ${error.message || "unknown error"}`)
dialog.showErrorBox("Lens Error", `Could not start proxy (127.0.0:${proxyPort}): ${error.message || "unknown error"}`)
app.quit();
}
+4
View File
@@ -44,6 +44,10 @@ export class KubeAuthProxy {
}
logger.debug(`spawning kubectl proxy with args: ${args}`)
this.proxyProcess = spawn(proxyBin, args, { env: this.env, })
this.proxyProcess.on("error", (error) => {
this.sendIpcLogMessage({ data: error.message, error: true })
this.exit()
})
this.proxyProcess.on("exit", (code) => {
this.sendIpcLogMessage({ data: `proxy exited with code: ${code}`, error: code > 0 })
+99 -78
View File
@@ -9,7 +9,7 @@ import { helmCli } from "./helm/helm-cli"
import { userStore } from "../common/user-store"
import { customRequest } from "../common/request";
import { getBundledKubectlVersion } from "../common/utils/app-version"
import { isDevelopment, isWindows } from "../common/vars";
import { isDevelopment, isWindows, isTestEnv } from "../common/vars";
const bundledVersion = getBundledKubectlVersion()
const kubectlMap: Map<string, string> = new Map([
@@ -22,9 +22,10 @@ const kubectlMap: Map<string, string> = new Map([
["1.13", "1.13.12"],
["1.14", "1.14.10"],
["1.15", "1.15.11"],
["1.16", "1.16.8"],
["1.16", "1.16.14"],
["1.17", bundledVersion],
["1.18", "1.18.0"]
["1.18", "1.18.8"],
["1.19", "1.19.0"]
])
const packageMirrors: Map<string, string> = new Map([
@@ -35,8 +36,9 @@ const packageMirrors: Map<string, string> = new Map([
let bundledPath: string
const initScriptVersionString = "# lens-initscript v3\n"
if (isDevelopment) {
bundledPath = path.join(process.cwd(), "binaries", "client", process.platform, process.arch, "kubectl")
if (isDevelopment || isTestEnv) {
const platformName = isWindows ? "windows" : process.platform
bundledPath = path.join(process.cwd(), "binaries", "client", platformName, process.arch, "kubectl")
} else {
bundledPath = path.join(process.resourcesPath, process.arch, "kubectl")
}
@@ -58,6 +60,7 @@ export class Kubectl {
public static readonly bundledKubectlPath = bundledPath
public static readonly bundledKubectlVersion: string = bundledVersion
public static invalidBundle = false
private static bundledInstance: Kubectl;
// Returns the single bundled Kubectl instance
@@ -94,13 +97,38 @@ export class Kubectl {
this.url = `${this.getDownloadMirror()}/v${this.kubectlVersion}/bin/${platformName}/${arch}/${binaryName}`
this.dirname = path.normalize(path.join(Kubectl.kubectlDir, this.kubectlVersion))
this.dirname = path.normalize(path.join(this.getDownloadDir(), this.kubectlVersion))
this.path = path.join(this.dirname, binaryName)
}
public async getPath(): Promise<string> {
public getBundledPath() {
return Kubectl.bundledKubectlPath
}
public getPathFromPreferences() {
return userStore.preferences?.kubectlBinariesPath || this.getBundledPath()
}
protected getDownloadDir() {
return userStore.preferences?.downloadBinariesPath || Kubectl.kubectlDir
}
public async getPath(bundled = false): Promise<string> {
if (userStore.preferences?.downloadKubectlBinaries === false) {
return this.getPathFromPreferences()
}
// return binary name if bundled path is not functional
if (!await this.checkBinary(this.getBundledPath(), false)) {
Kubectl.invalidBundle = true
return path.basename(bundledPath)
}
try {
await this.ensureKubectl()
if (!await this.ensureKubectl()) {
logger.error("Failed to ensure kubectl, fallback to the bundled version")
return Kubectl.bundledKubectlPath
}
return this.path
} catch (err) {
logger.error("Failed to ensure kubectl, fallback to the bundled version")
@@ -112,6 +140,7 @@ export class Kubectl {
public async binDir() {
try {
await this.ensureKubectl()
await this.writeInitScripts()
return this.dirname
} catch (err) {
logger.error(err)
@@ -119,16 +148,15 @@ export class Kubectl {
}
}
public async checkBinary(checkVersion = true) {
const exists = await pathExists(this.path)
public async checkBinary(path: string, checkVersion = true) {
const exists = await pathExists(path)
if (exists) {
if (!checkVersion) {
return true
}
try {
const { stdout } = await promiseExec(`"${this.path}" version --client=true -o json`)
const { stdout } = await promiseExec(`"${path}" version --client=true -o json`)
const output = JSON.parse(stdout)
if (!checkVersion) {
return true
}
let version: string = output.clientVersion.gitVersion
if (version[0] === 'v') {
version = version.slice(1)
@@ -165,20 +193,32 @@ export class Kubectl {
}
public async ensureKubectl(): Promise<boolean> {
if (userStore.preferences?.downloadKubectlBinaries === false) {
return true
}
if (Kubectl.invalidBundle) {
logger.error(`Detected invalid bundle binary, returning ...`)
return false
}
await ensureDir(this.dirname, 0o755)
return lockFile.lock(this.dirname).then(async (release) => {
logger.debug(`Acquired a lock for ${this.kubectlVersion}`)
const bundled = await this.checkBundled()
const isValid = await this.checkBinary(!bundled)
if (!isValid) {
let isValid = await this.checkBinary(this.path, !bundled)
if (!isValid && !bundled) {
await this.downloadKubectl().catch((error) => {
logger.error(error)
logger.debug(`Releasing lock for ${this.kubectlVersion}`)
release()
return false
});
isValid = !await this.checkBinary(this.path, false)
}
if(!isValid) {
logger.debug(`Releasing lock for ${this.kubectlVersion}`)
release()
return false
}
await this.writeInitScripts().catch((error) => {
logger.error("Failed to write init scripts");
logger.error(error)
})
logger.debug(`Releasing lock for ${this.kubectlVersion}`)
release()
return true
@@ -221,71 +261,52 @@ export class Kubectl {
})
}
protected async scriptIsLatest(scriptPath: string) {
const scriptExists = await pathExists(scriptPath)
if (!scriptExists) return false
try {
const filehandle = await fs.promises.open(scriptPath, 'r')
const buffer = Buffer.alloc(40)
await filehandle.read(buffer, 0, 40, 0)
await filehandle.close()
return buffer.toString().startsWith(initScriptVersionString)
} catch (err) {
logger.error(err)
return false
}
}
protected async writeInitScripts() {
const kubectlPath = userStore.preferences?.downloadKubectlBinaries ? this.dirname : path.dirname(this.getPathFromPreferences())
const helmPath = helmCli.getBinaryDir()
const fsPromises = fs.promises;
const bashScriptPath = path.join(this.dirname, '.bash_set_path')
const bashScriptIsLatest = await this.scriptIsLatest(bashScriptPath)
if (!bashScriptIsLatest) {
let bashScript = "" + initScriptVersionString
bashScript += "tempkubeconfig=\"$KUBECONFIG\"\n"
bashScript += "test -f \"/etc/profile\" && . \"/etc/profile\"\n"
bashScript += "if test -f \"$HOME/.bash_profile\"; then\n"
bashScript += " . \"$HOME/.bash_profile\"\n"
bashScript += "elif test -f \"$HOME/.bash_login\"; then\n"
bashScript += " . \"$HOME/.bash_login\"\n"
bashScript += "elif test -f \"$HOME/.profile\"; then\n"
bashScript += " . \"$HOME/.profile\"\n"
bashScript += "fi\n"
bashScript += `export PATH="${this.dirname}:${helmPath}:$PATH"\n`
bashScript += "export KUBECONFIG=\"$tempkubeconfig\"\n"
bashScript += "unset tempkubeconfig\n"
await fsPromises.writeFile(bashScriptPath, bashScript.toString(), { mode: 0o644 })
}
let bashScript = "" + initScriptVersionString
bashScript += "tempkubeconfig=\"$KUBECONFIG\"\n"
bashScript += "test -f \"/etc/profile\" && . \"/etc/profile\"\n"
bashScript += "if test -f \"$HOME/.bash_profile\"; then\n"
bashScript += " . \"$HOME/.bash_profile\"\n"
bashScript += "elif test -f \"$HOME/.bash_login\"; then\n"
bashScript += " . \"$HOME/.bash_login\"\n"
bashScript += "elif test -f \"$HOME/.profile\"; then\n"
bashScript += " . \"$HOME/.profile\"\n"
bashScript += "fi\n"
bashScript += `export PATH="${helmPath}:${kubectlPath}:$PATH"\n`
bashScript += "export KUBECONFIG=\"$tempkubeconfig\"\n"
bashScript += "unset tempkubeconfig\n"
await fsPromises.writeFile(bashScriptPath, bashScript.toString(), { mode: 0o644 })
const zshScriptPath = path.join(this.dirname, '.zlogin')
const zshScriptIsLatest = await this.scriptIsLatest(zshScriptPath)
if (!zshScriptIsLatest) {
let zshScript = "" + initScriptVersionString
zshScript += "tempkubeconfig=\"$KUBECONFIG\"\n"
// restore previous ZDOTDIR
zshScript += "export ZDOTDIR=\"$OLD_ZDOTDIR\"\n"
// source all the files
zshScript += "test -f \"$OLD_ZDOTDIR/.zshenv\" && . \"$OLD_ZDOTDIR/.zshenv\"\n"
zshScript += "test -f \"$OLD_ZDOTDIR/.zprofile\" && . \"$OLD_ZDOTDIR/.zprofile\"\n"
zshScript += "test -f \"$OLD_ZDOTDIR/.zlogin\" && . \"$OLD_ZDOTDIR/.zlogin\"\n"
zshScript += "test -f \"$OLD_ZDOTDIR/.zshrc\" && . \"$OLD_ZDOTDIR/.zshrc\"\n"
let zshScript = "" + initScriptVersionString
// voodoo to replace any previous occurrences of kubectl path in the PATH
zshScript += `kubectlpath=\"${this.dirname}"\n`
zshScript += `helmpath=\"${helmPath}"\n`
zshScript += "p=\":$kubectlpath:\"\n"
zshScript += "d=\":$PATH:\"\n"
zshScript += "d=${d//$p/:}\n"
zshScript += "d=${d/#:/}\n"
zshScript += "export PATH=\"$kubectlpath:$helmpath:${d/%:/}\"\n"
zshScript += "export KUBECONFIG=\"$tempkubeconfig\"\n"
zshScript += "unset tempkubeconfig\n"
zshScript += "unset OLD_ZDOTDIR\n"
await fsPromises.writeFile(zshScriptPath, zshScript.toString(), { mode: 0o644 })
}
zshScript += "tempkubeconfig=\"$KUBECONFIG\"\n"
// restore previous ZDOTDIR
zshScript += "export ZDOTDIR=\"$OLD_ZDOTDIR\"\n"
// source all the files
zshScript += "test -f \"$OLD_ZDOTDIR/.zshenv\" && . \"$OLD_ZDOTDIR/.zshenv\"\n"
zshScript += "test -f \"$OLD_ZDOTDIR/.zprofile\" && . \"$OLD_ZDOTDIR/.zprofile\"\n"
zshScript += "test -f \"$OLD_ZDOTDIR/.zlogin\" && . \"$OLD_ZDOTDIR/.zlogin\"\n"
zshScript += "test -f \"$OLD_ZDOTDIR/.zshrc\" && . \"$OLD_ZDOTDIR/.zshrc\"\n"
// voodoo to replace any previous occurrences of kubectl path in the PATH
zshScript += `kubectlpath=\"${kubectlPath}"\n`
zshScript += `helmpath=\"${helmPath}"\n`
zshScript += "p=\":$kubectlpath:\"\n"
zshScript += "d=\":$PATH:\"\n"
zshScript += "d=${d//$p/:}\n"
zshScript += "d=${d/#:/}\n"
zshScript += "export PATH=\"$helmpath:$kubectlpath:${d/%:/}\"\n"
zshScript += "export KUBECONFIG=\"$tempkubeconfig\"\n"
zshScript += "unset tempkubeconfig\n"
zshScript += "unset OLD_ZDOTDIR\n"
await fsPromises.writeFile(zshScriptPath, zshScript.toString(), { mode: 0o644 })
}
protected getDownloadMirror() {
+28
View File
@@ -1,5 +1,7 @@
import packageInfo from "../../package.json"
import path from "path"
import { bundledKubectl, Kubectl } from "../../src/main/kubectl";
import { isWindows } from "../common/vars";
jest.mock("../common/user-store");
@@ -15,3 +17,29 @@ describe("kubectlVersion", () => {
expect(kubectl.kubectlVersion).toBe(bundledKubectl.kubectlVersion)
})
})
describe("getPath()", () => {
it("returns path to downloaded kubectl binary", async () => {
const { bundledKubectlVersion } = packageInfo.config;
const kubectl = new Kubectl(bundledKubectlVersion);
const kubectlPath = await kubectl.getPath()
let binaryName = "kubectl"
if (isWindows) {
binaryName += ".exe"
}
const expectedPath = path.join(Kubectl.kubectlDir, Kubectl.bundledKubectlVersion, binaryName)
expect(kubectlPath).toBe(expectedPath)
})
it("returns plain binary name if bundled kubectl is non-functional", async () => {
const { bundledKubectlVersion } = packageInfo.config;
const kubectl = new Kubectl(bundledKubectlVersion);
jest.spyOn(kubectl, "getBundledPath").mockReturnValue("/invalid/path/kubectl")
const kubectlPath = await kubectl.getPath()
let binaryName = "kubectl"
if (isWindows) {
binaryName += ".exe"
}
expect(kubectlPath).toBe(binaryName)
})
})
+16 -11
View File
@@ -1,10 +1,10 @@
import path from "path"
import fs from "fs"
import request from "request"
import logger from "./logger"
import { ensureDir, pathExists } from "fs-extra"
import * as tar from "tar"
import { isWindows } from "../common/vars";
import winston from "winston"
export type LensBinaryOpts = {
version: string;
@@ -27,6 +27,7 @@ export class LensBinary {
protected arch: string
protected originalBinaryName: string
protected requestOpts: request.Options
protected logger: Console | winston.Logger
constructor(opts: LensBinaryOpts) {
const baseDir = opts.baseDir
@@ -34,7 +35,7 @@ export class LensBinary {
this.binaryName = opts.newBinaryName || opts.originalBinaryName
this.binaryVersion = opts.version
this.requestOpts = opts.requestOpts
this.logger = console
let arch = null
if (process.arch == "x64") {
@@ -59,6 +60,10 @@ export class LensBinary {
}
}
public setLogger(logger: Console | winston.Logger) {
this.logger = logger
}
protected binaryDir() {
throw new Error("binaryDir not implemented")
}
@@ -93,7 +98,7 @@ export class LensBinary {
await this.ensureBinary()
return this.dirname
} catch (err) {
logger.error(err)
this.logger.error(err)
return ""
}
}
@@ -107,17 +112,17 @@ export class LensBinary {
const isValid = await this.checkBinary()
if (!isValid) {
await this.downloadBinary().catch((error) => {
logger.error(error)
this.logger.error(error)
});
if (this.tarPath) await this.untarBinary()
if (this.originalBinaryName != this.binaryName) await this.renameBinary()
logger.info(`${this.originalBinaryName} has been downloaded to ${this.getBinaryPath()}`)
this.logger.info(`${this.originalBinaryName} has been downloaded to ${this.getBinaryPath()}`)
}
}
protected async untarBinary() {
return new Promise<void>((resolve, reject) => {
logger.debug(`Extracting ${this.originalBinaryName} binary`)
this.logger.debug(`Extracting ${this.originalBinaryName} binary`)
tar.x({
file: this.tarPath,
cwd: this.dirname
@@ -129,7 +134,7 @@ export class LensBinary {
protected async renameBinary() {
return new Promise<void>((resolve, reject) => {
logger.debug(`Renaming ${this.originalBinaryName} binary to ${this.binaryName}`)
this.logger.debug(`Renaming ${this.originalBinaryName} binary to ${this.binaryName}`)
fs.rename(this.getOriginalBinaryPath(), this.getBinaryPath(), (err) => {
if (err) {
reject(err)
@@ -148,7 +153,7 @@ export class LensBinary {
const file = fs.createWriteStream(binaryPath)
const url = this.getUrl()
logger.info(`Downloading ${this.originalBinaryName} ${this.binaryVersion} from ${url} to ${binaryPath}`)
this.logger.info(`Downloading ${this.originalBinaryName} ${this.binaryVersion} from ${url} to ${binaryPath}`)
const requestOpts: request.UriOptions & request.CoreOptions = {
uri: url,
gzip: true,
@@ -158,12 +163,12 @@ export class LensBinary {
const stream = request(requestOpts)
stream.on("complete", () => {
logger.info(`Download of ${this.originalBinaryName} finished`)
this.logger.info(`Download of ${this.originalBinaryName} finished`)
file.end()
})
stream.on("error", (error) => {
logger.error(error)
this.logger.error(error)
fs.unlink(binaryPath, () => {
// do nothing
})
@@ -171,7 +176,7 @@ export class LensBinary {
})
return new Promise((resolve, reject) => {
file.on("close", () => {
logger.debug(`${this.originalBinaryName} binary download closed`)
this.logger.debug(`${this.originalBinaryName} binary download closed`)
if (!this.tarPath) fs.chmod(binaryPath, 0o755, (err) => {
if (err) reject(err);
})
+14 -4
View File
@@ -1,20 +1,30 @@
import { app, remote } from "electron";
import winston from "winston"
import { isDebugging } from "../common/vars";
const options = {
colorize: true,
const consoleOptions: winston.transports.ConsoleTransportOptions = {
handleExceptions: false,
json: false,
level: isDebugging ? "debug" : "info",
}
const fileOptions: winston.transports.FileTransportOptions = {
handleExceptions: false,
level: isDebugging ? "debug" : "info",
filename: "lens.log",
dirname: (app ?? remote?.app)?.getPath("logs"),
maxsize: 16 * 1024,
maxFiles: 16,
tailable: true,
}
const logger = winston.createLogger({
format: winston.format.combine(
winston.format.colorize(),
winston.format.simple(),
),
transports: [
new winston.transports.Console(options),
new winston.transports.Console(consoleOptions),
new winston.transports.File(fileOptions),
],
});
+3 -3
View File
@@ -43,7 +43,7 @@ export function buildMenu(windowManager: WindowManager) {
`${appName}: ${app.getVersion()}`,
`Electron: ${process.versions.electron}`,
`Chrome: ${process.versions.chrome}`,
`Copyright 2020 Copyright 2020 Mirantis, Inc.`,
`Copyright 2020 Mirantis, Inc.`,
]
dialog.showMessageBoxSync(browserWindow, {
title: `${isWindows ? " ".repeat(2) : ""}${appName}`,
@@ -163,14 +163,14 @@ export function buildMenu(windowManager: WindowManager) {
label: 'Forward',
accelerator: 'CmdOrCtrl+]',
click() {
webContents.getFocusedWebContents()?.goForward();
webContents.getFocusedWebContents()?.goForward()
}
},
{
label: 'Reload',
accelerator: 'CmdOrCtrl+R',
click() {
webContents.getFocusedWebContents()?.reload();
windowManager.reload({ channel: "menu:reload" });
}
},
{ role: 'toggleDevTools' },
+6 -2
View File
@@ -10,6 +10,7 @@ import { ClusterPreferences } from "../common/cluster-store";
import { helmCli } from "./helm/helm-cli"
import { isWindows } from "../common/vars";
import { tracker } from "../common/tracker";
import { userStore } from "../common/user-store";
export class ShellSession extends EventEmitter {
static shellEnvs: Map<string, any> = new Map()
@@ -20,6 +21,7 @@ export class ShellSession extends EventEmitter {
protected nodeShellPod: string;
protected kubectl: Kubectl;
protected kubectlBinDir: string;
protected kubectlPathDir: string;
protected helmBinDir: string;
protected preferences: ClusterPreferences;
protected running = false;
@@ -36,6 +38,8 @@ export class ShellSession extends EventEmitter {
public async open() {
this.kubectlBinDir = await this.kubectl.binDir()
const pathFromPreferences = userStore.preferences.kubectlBinariesPath || Kubectl.bundledKubectlPath
this.kubectlPathDir = userStore.preferences.downloadKubectlBinaries ? await this.kubectl.binDir() : path.dirname(pathFromPreferences)
this.helmBinDir = helmCli.getBinaryDir()
const env = await this.getCachedShellEnv()
const shell = env.PTYSHELL
@@ -67,11 +71,11 @@ export class ShellSession extends EventEmitter {
protected async getShellArgs(shell: string): Promise<Array<string>> {
switch(path.basename(shell)) {
case "powershell.exe":
return ["-NoExit", "-command", `& {Set-Location $Env:USERPROFILE; $Env:PATH="${this.kubectlBinDir};${this.helmBinDir};$Env:PATH"}`]
return ["-NoExit", "-command", `& {Set-Location $Env:USERPROFILE; $Env:PATH="${this.helmBinDir};${this.kubectlPathDir};$Env:PATH"}`]
case "bash":
return ["--init-file", path.join(this.kubectlBinDir, '.bash_set_path')]
case "fish":
return ["--login", "--init-command", `export PATH="${this.kubectlBinDir}:${this.helmBinDir}:$PATH"; export KUBECONFIG="${this.kubeconfigPath}"`]
return ["--login", "--init-command", `export PATH="${this.helmBinDir}:${this.kubectlPathDir}:$PATH"; export KUBECONFIG="${this.kubeconfigPath}"`]
case "zsh":
return ["--login"]
default:
+12 -2
View File
@@ -1,8 +1,9 @@
import type { ClusterId } from "../common/cluster-store";
import { BrowserWindow, dialog, ipcMain, shell, WebContents, webContents } from "electron"
import { clusterStore } from "../common/cluster-store";
import { BrowserWindow, dialog, ipcMain, shell, webContents } from "electron"
import windowStateKeeper from "electron-window-state"
import { observable } from "mobx";
import { initMenu } from "./menu";
import type { ClusterId } from "../common/cluster-store";
export class WindowManager {
protected mainView: BrowserWindow;
@@ -58,6 +59,15 @@ export class WindowManager {
}
}
reload({ channel }: { channel: string }) {
const frameId = clusterStore.getById(this.activeClusterId)?.frameId;
if (frameId) {
this.mainView.webContents.sendToFrame(frameId, channel);
} else {
webContents.getFocusedWebContents()?.reload();
}
}
async showMain() {
try {
await this.showSplash();
+31 -23
View File
@@ -1,13 +1,28 @@
import { KubeObject } from "../kube-object";
import { KubeApi } from "../kube-api";
import { VersionedKubeApi } from "../kube-api-versioned";
import { crdResourcesURL } from "../../components/+custom-resources/crd.route";
type AdditionalPrinterColumnsCommon = {
name: string;
type: "integer" | "number" | "string" | "boolean" | "date";
priority: number;
description: string;
}
type AdditionalPrinterColumnsV1 = AdditionalPrinterColumnsCommon & {
jsonPath: string;
}
type AdditionalPrinterColumnsV1Beta = AdditionalPrinterColumnsCommon & {
JSONPath: string;
}
export class CustomResourceDefinition extends KubeObject {
static kind = "CustomResourceDefinition";
spec: {
group: string;
version: string;
version?: string; // deprecated in v1 api
names: {
plural: string;
singular: string;
@@ -20,18 +35,14 @@ export class CustomResourceDefinition extends KubeObject {
name: string;
served: boolean;
storage: boolean;
schema?: unknown; // required in v1 but not present in v1beta
additionalPrinterColumns?: AdditionalPrinterColumnsV1[]
}[];
conversion: {
strategy?: string;
webhook?: any;
};
additionalPrinterColumns?: {
name: string;
type: "integer" | "number" | "string" | "boolean" | "date";
priority: number;
description: string;
JSONPath: string;
}[];
additionalPrinterColumns?: AdditionalPrinterColumnsV1Beta[]; // removed in v1
}
status: {
conditions: {
@@ -61,8 +72,8 @@ export class CustomResourceDefinition extends KubeObject {
}
getResourceApiBase() {
const { version, group } = this.spec;
return `/apis/${group}/${version}/${this.getPluralName()}`
const { group } = this.spec;
return `/apis/${group}/${this.getVersion()}/${this.getPluralName()}`
}
getPluralName() {
@@ -87,7 +98,8 @@ export class CustomResourceDefinition extends KubeObject {
}
getVersion() {
return this.spec.version;
// v1 has removed the spec.version property, if it is present it must match the first version
return this.spec.versions[0]?.name ?? this.spec.version;
}
isNamespaced() {
@@ -107,14 +119,16 @@ export class CustomResourceDefinition extends KubeObject {
}
getPrinterColumns(ignorePriority = true) {
const columns = this.spec.additionalPrinterColumns || [];
const columns = this.spec.versions.find(a => this.getVersion() == a.name)?.additionalPrinterColumns
?? this.spec.additionalPrinterColumns?.map(({JSONPath, ...rest}) => ({ ...rest, jsonPath: JSONPath })) // map to V1 shape
?? [];
return columns
.filter(column => column.name != "Age")
.filter(column => ignorePriority ? true : !column.priority);
}
getValidation() {
return JSON.stringify(this.spec.validation, null, 2);
return JSON.stringify(this.spec.validation ?? this.spec.versions?.[0]?.schema, null, 2);
}
getConditions() {
@@ -130,16 +144,10 @@ export class CustomResourceDefinition extends KubeObject {
}
}
export const crdBetaApi = new KubeApi<CustomResourceDefinition>({
kind: CustomResourceDefinition.kind,
apiBase: "/apis/apiextensions.k8s.io/v1beta1/customresourcedefinitions",
isNamespaced: false,
objectConstructor: CustomResourceDefinition,
});
export const crdApi = new KubeApi<CustomResourceDefinition>({
export const crdApi = new VersionedKubeApi<CustomResourceDefinition>({
kind: CustomResourceDefinition.kind,
apiBase: "/apis/apiextensions.k8s.io/v1/customresourcedefinitions",
isNamespaced: false,
objectConstructor: CustomResourceDefinition,
objectConstructor: CustomResourceDefinition
});
+3 -1
View File
@@ -121,6 +121,8 @@ export function lookupApiLink(ref: IKubeObjectRef, parentObject: KubeObject): st
namespace = parentObject.getNs()
} = ref;
if (!kind) return "";
// search in registered apis by 'kind' & 'apiVersion'
const api = apiManager.getApi(api => api.kind === kind && api.apiVersionWithGroup == apiVersion)
if (api) {
@@ -129,7 +131,7 @@ export function lookupApiLink(ref: IKubeObjectRef, parentObject: KubeObject): st
// lookup api by generated resource link
const apiPrefixes = ["/apis", "/api"];
const resource = kind.toLowerCase() + kind.endsWith("s") ? "es" : "s";
const resource = kind.endsWith("s") ? `${kind.toLowerCase()}es` : `${kind.toLowerCase()}s`;
for (const apiPrefix of apiPrefixes) {
const apiLink = createKubeApiURL({ apiPrefix, apiVersion, name, namespace, resource });
if (apiManager.getApi(apiLink)) {
+56
View File
@@ -0,0 +1,56 @@
import { stringify } from "querystring";
import { KubeObject } from "./kube-object";
import { createKubeApiURL } from "./kube-api-parse";
import { KubeApi, IKubeApiQueryParams, IKubeApiOptions } from "./kube-api";
import { apiManager } from "./api-manager";
export class VersionedKubeApi<T extends KubeObject = any> extends KubeApi<T> {
private preferredVersion?: string;
constructor(opts: IKubeApiOptions<T>) {
super(opts);
this.getPreferredVersion().then(() => {
if (this.apiBase != opts.apiBase)
apiManager.registerApi(this.apiBase, this);
});
}
// override this property to make read-write
apiBase: string
async getPreferredVersion() {
if (this.preferredVersion) return;
const apiGroupVersion = await this.request.get<{ preferredVersion?: { version: string; }; }>(`${this.apiPrefix}/${this.apiGroup}`);
if (!apiGroupVersion?.preferredVersion) return;
this.preferredVersion = apiGroupVersion.preferredVersion.version;
// update apiBase
this.apiBase = this.getUrl();
}
async list({ namespace = "" } = {}, query?: IKubeApiQueryParams): Promise<T[]> {
await this.getPreferredVersion();
return await super.list({namespace}, query);
}
async get({ name = "", namespace = "default" } = {}, query?: IKubeApiQueryParams): Promise<T> {
await this.getPreferredVersion();
return super.get({ name, namespace }, query);
}
getUrl({ name = "", namespace = "" } = {}, query?: Partial<IKubeApiQueryParams>) {
const { apiPrefix, apiGroup, apiVersion, apiResource, preferredVersion, isNamespaced } = this;
const resourcePath = createKubeApiURL({
apiPrefix: apiPrefix,
apiVersion: `${apiGroup}/${preferredVersion ?? apiVersion}`,
resource: apiResource,
namespace: isNamespaced ? namespace : undefined,
name: name,
});
return resourcePath + (query ? `?` + stringify(query) : "");
}
}
@@ -169,12 +169,11 @@ export class AddCluster extends React.Component {
<h2>Clusters associated with Lens</h2>
<p>
Add clusters by clicking the <span className="text-primary">Add Cluster</span> button.
You'll need to obtain a working kubeconfig for the cluster you want to add.
You'll need to obtain a working kubeconfig for the cluster you want to add. You can either browse it from the file system or paste it as a text from the clipboard.
</p>
<p>
Each <a href="https://kubernetes.io/docs/concepts/configuration/organize-cluster-access-kubeconfig/#context" target="_blank">cluster context</a> is added as a separate item in the
left-side cluster menu
to allow you to operate easily on multiple clusters and/or contexts.
Selected <a href="https://kubernetes.io/docs/concepts/configuration/organize-cluster-access-kubeconfig/#context" target="_blank">cluster contexts</a> are added as a separate item in the
left-side cluster menu to allow you to operate easily on multiple clusters and/or contexts.
</p>
<p>
For more information on kubeconfig see <a href="https://kubernetes.io/docs/concepts/configuration/organize-cluster-access-kubeconfig/" target="_blank">Kubernetes docs</a>.
@@ -189,20 +188,6 @@ export class AddCluster extends React.Component {
When connecting to a cluster, make sure you have a valid and working kubeconfig for the cluster. Following lists known "gotchas" in some authentication types used in kubeconfig with Lens
app.
</p>
<a href="https://kubernetes.io/docs/reference/access-authn-authz/authentication/#option-1-oidc-authenticator" target="_blank">
<h3>OIDC (OpenID Connect)</h3>
</a>
<p>
When connecting Lens to OIDC enabled cluster, there's few things you as a user need to take into account.
</p>
<p><b>Dedicated refresh token</b></p>
<p>
As Lens app utilized kubeconfig is "disconnected" from your main kubeconfig Lens needs to have it's own refresh token it utilizes.
If you share the refresh token with e.g. <code>kubectl</code> who ever uses the token first will invalidate it for the next user.
One way to achieve this is with <a href="https://github.com/int128/kubelogin" target="_blank">kubelogin</a> tool by removing the tokens
(both <code>id_token</code> and <code>refresh_token</code>) from
the config and issuing <code>kubelogin</code> command. That'll take you through the login process and will result you having "dedicated" refresh token.
</p>
<h3>Exec auth plugins</h3>
<p>
When using <a href="https://kubernetes.io/docs/reference/access-authn-authz/authentication/#configuration" target="_blank">exec auth</a> plugins make sure the paths that are used to call
@@ -25,7 +25,6 @@
.version {
.Select {
width: 80px;
min-width: 80px;
white-space: nowrap;
}
@@ -1,7 +1,6 @@
import "./cluster-settings.scss";
import React from "react";
import { Link } from "react-router-dom";
import { observer } from "mobx-react";
import { Features } from "./features";
import { Removal } from "./removal";
@@ -15,6 +14,25 @@ import { navigate } from "../../navigation";
@observer
export class ClusterSettings extends React.Component {
async componentDidMount() {
window.addEventListener('keydown', this.onEscapeKey);
}
componentWillUnmount() {
window.removeEventListener('keydown', this.onEscapeKey);
}
onEscapeKey = (evt: KeyboardEvent) => {
if (evt.code === "Escape") {
evt.stopPropagation();
this.close();
}
}
close() {
navigate("/");
}
render() {
const cluster = getMatchedCluster();
if (!cluster) return null;
@@ -26,7 +44,7 @@ export class ClusterSettings extends React.Component {
showTooltip={false}
/>
<h2>{cluster.preferences.clusterName}</h2>
<Icon material="close" onClick={() => navigate("/")} big/>
<Icon material="close" onClick={this.close} big/>
</>
);
return (
@@ -22,7 +22,7 @@ export class Status extends React.Component<Props> {
const rows = [
["Online Status", cluster.online ? "online" : `offline (${cluster.failureReason || "unknown reason"})`],
["Distribution", cluster.distribution],
["Kerbel Version", cluster.version],
["Kernel Version", cluster.version],
["API Address", cluster.apiUrl],
["Nodes Count", cluster.nodes || "0"]
];
@@ -102,13 +102,13 @@ export class CRDDetails extends React.Component<Props> {
</TableHead>
{
printerColumns.map((column, index) => {
const { name, type, JSONPath } = column;
const { name, type, jsonPath } = column;
return (
<TableRow key={index}>
<TableCell className="name">{name}</TableCell>
<TableCell className="type">{type}</TableCell>
<TableCell className="json-path">
<Badge label={JSONPath}/>
<Badge label={jsonPath}/>
</TableCell>
</TableRow>
)
@@ -57,7 +57,7 @@ export class CrdResourceDetails extends React.Component<Props> {
<KubeObjectMeta object={object}/>
{extraColumns.map(column => {
const { name } = column;
const value = jsonPath.query(object, column.JSONPath.slice(1));
const value = jsonPath.query(object, (column.jsonPath).slice(1));
return (
<DrawerItem key={name} name={name}>
<CrdColumnValue value={value} />
@@ -57,7 +57,7 @@ export class CrdResources extends React.Component<Props> {
[sortBy.age]: (item: KubeObject) => item.metadata.creationTimestamp,
}
extraColumns.forEach(column => {
sortingCallbacks[column.name] = (item: KubeObject) => jsonPath.query(item, column.JSONPath.slice(1))
sortingCallbacks[column.name] = (item: KubeObject) => jsonPath.query(item, column.jsonPath.slice(1))
})
// todo: merge extra columns and other params to predefined view
const { List } = apiManager.getViews(crd.getResourceApiBase());
@@ -88,9 +88,9 @@ export class CrdResources extends React.Component<Props> {
renderTableContents={(crdInstance: KubeObject) => [
crdInstance.getName(),
isNamespaced && crdInstance.getNs(),
...extraColumns.map(column =>
jsonPath.query(crdInstance, column.JSONPath.slice(1))
),
...extraColumns.map(column => {
return jsonPath.query(crdInstance, (column.jsonPath).slice(1))
}),
crdInstance.getAge(),
]}
renderItemMenu={(item: KubeObject) => {
@@ -0,0 +1,83 @@
import React, { useState } from 'react';
import { Trans } from '@lingui/macro';
import { isPath } from '../input/input.validators';
import { Checkbox } from '../checkbox';
import { Input } from '../input';
import { SubTitle } from '../layout/sub-title';
import { UserPreferences, userStore } from '../../../common/user-store';
import { observer } from 'mobx-react';
import { Kubectl } from '../../../main/kubectl';
import { SelectOption, Select } from '../select';
export const KubectlBinaries = observer(({ preferences }: { preferences: UserPreferences }) => {
const [downloadPath, setDownloadPath] = useState(preferences.downloadBinariesPath || "");
const [binariesPath, setBinariesPath] = useState(preferences.kubectlBinariesPath || "");
const downloadMirrorOptions: SelectOption<string>[] = [
{ value: "default", label: "Default (Google)" },
{ value: "china", label: "China (Azure)" },
]
const save = () => {
preferences.downloadBinariesPath = downloadPath;
preferences.kubectlBinariesPath = binariesPath;
}
const renderPath = () => {
if (preferences.downloadKubectlBinaries) {
return null;
}
return (
<>
<SubTitle title="Path to Kubectl binary"/>
<Input
theme="round-black"
value={binariesPath}
validators={isPath}
onChange={setBinariesPath}
onBlur={save}
/>
<small className="hint">
<Trans>Default:</Trans>{" "}{Kubectl.bundledKubectlPath}
</small>
</>
);
}
return (
<>
<h2><Trans>Kubectl Binary</Trans></h2>
<small className="hint">
<Trans>Download kubectl binaries matching to Kubernetes cluster verison.</Trans>
</small>
<Checkbox
label={<Trans>Download kubectl binaries</Trans>}
value={preferences.downloadKubectlBinaries}
onChange={downloadKubectlBinaries => preferences.downloadKubectlBinaries = downloadKubectlBinaries}
/>
<SubTitle title="Download mirror" />
<Select
placeholder={<Trans>Download mirror for kubectl</Trans>}
options={downloadMirrorOptions}
value={preferences.downloadMirror}
onChange={({ value }: SelectOption) => preferences.downloadMirror = value}
disabled={!preferences.downloadKubectlBinaries}
/>
<SubTitle title="Directory for binaries"/>
<Input
theme="round-black"
value={downloadPath}
placeholder={`Directory to download binaries into`}
validators={isPath}
onChange={setDownloadPath}
onBlur={save}
disabled={!preferences.downloadKubectlBinaries}
/>
<small>
Default: {userStore.getDefaultKubectlPath()}
</small>
{renderPath()}
</>
);
});
@@ -19,6 +19,11 @@
}
}
.SubTitle {
text-transform: none;
margin: 0!important;
}
.repos {
position: relative;
@@ -16,17 +16,14 @@ import { Badge } from "../badge";
import { themeStore } from "../../theme.store";
import { history } from "../../navigation";
import { Tooltip } from "../tooltip";
import { KubectlBinaries } from "./kubectl-binaries";
@observer
export class Preferences extends React.Component {
@observable helmLoading = false;
@observable helmRepos: HelmRepo[] = [];
@observable helmAddedRepos = observable.map<string, HelmRepo>();
@observable downloadMirrorOptions: SelectOption<string>[] = [
{ value: "default", label: "Default (Google)" },
{ value: "china", label: "China (Azure)" },
]
@observable httpProxy = userStore.preferences.httpsProxy || "";
@computed get themeOptions(): SelectOption<string>[] {
return themeStore.themes.map(theme => ({
@@ -43,9 +40,21 @@ export class Preferences extends React.Component {
}
async componentDidMount() {
window.addEventListener('keydown', this.onEscapeKey);
await this.loadHelmRepos();
}
componentWillUnmount() {
window.removeEventListener('keydown', this.onEscapeKey);
}
onEscapeKey = (evt: KeyboardEvent) => {
if (evt.code === "Escape") {
evt.stopPropagation();
history.goBack();
}
}
@action
async loadHelmRepos() {
this.helmLoading = true;
@@ -121,13 +130,19 @@ export class Preferences extends React.Component {
onChange={({ value }: SelectOption) => preferences.colorTheme = value}
/>
<h2><Trans>Download Mirror</Trans></h2>
<Select
placeholder={<Trans>Download mirror for kubectl</Trans>}
options={this.downloadMirrorOptions}
value={preferences.downloadMirror}
onChange={({ value }: SelectOption) => preferences.downloadMirror = value}
<h2><Trans>HTTP Proxy</Trans></h2>
<Input
theme="round-black"
placeholder={_i18n._(t`Type HTTP proxy url (example: http://proxy.acme.org:8080)`)}
value={this.httpProxy}
onChange={v => this.httpProxy = v}
onBlur={() => preferences.httpsProxy = this.httpProxy}
/>
<small className="hint">
<Trans>Proxy is used only for non-cluster communication.</Trans>
</small>
<KubectlBinaries preferences={preferences} />
<h2><Trans>Helm</Trans></h2>
<Select
@@ -158,17 +173,6 @@ export class Preferences extends React.Component {
})}
</div>
<h2><Trans>HTTP Proxy</Trans></h2>
<Input
theme="round-black"
placeholder={_i18n._(t`Type HTTP proxy url (example: http://proxy.acme.org:8080)`)}
value={preferences.httpsProxy || ""}
onChange={v => preferences.httpsProxy = v}
/>
<small className="hint">
<Trans>Proxy is used only for non-cluster communication.</Trans>
</small>
<h2><Trans>Certificate Trust</Trans></h2>
<Checkbox
label={<Trans>Allow untrusted Certificate Authorities</Trans>}
@@ -4,6 +4,7 @@
position: relative;
border-radius: $radius;
padding: $radius;
margin-bottom: $padding * 2;
user-select: none;
cursor: pointer;
@@ -33,8 +33,8 @@ export function getMatchedCluster() {
return clusterStore.getById(getMatchedClusterId())
}
// Refresh global menu depending on active route's type (common/cluster view)
if (ipcRenderer) {
// Refresh global menu depending on active route's type (common/cluster view)
const isMainView = !getHostedClusterId();
if (isMainView) {
reaction(() => getMatchedClusterId(), clusterId => {
@@ -43,4 +43,9 @@ if (ipcRenderer) {
fireImmediately: true
})
}
// Reload dashboard
ipcRenderer.on("menu:reload", () => {
location.reload();
});
}
@@ -18,6 +18,10 @@
padding: 0 $spacing; // extra spacing for cluster-icon's badge
margin-bottom: $spacing;
> :last-child {
margin-bottom: $margin;
}
&:empty {
display: none;
}
@@ -10,7 +10,7 @@ import { ClusterId, clusterStore } from "../../../common/cluster-store";
import { workspaceStore } from "../../../common/workspace-store";
import { ClusterIcon } from "../cluster-icon";
import { Icon } from "../icon";
import { cssNames, IClassName } from "../../utils";
import { cssNames, IClassName, autobind } from "../../utils";
import { Badge } from "../badge";
import { navigate } from "../../navigation";
import { addClusterURL } from "../+add-cluster";
@@ -20,6 +20,7 @@ import { Tooltip } from "../tooltip";
import { ConfirmDialog } from "../confirm-dialog";
import { clusterIpc } from "../../../common/cluster-ipc";
import { clusterViewURL, getMatchedClusterId } from "./cluster-view.route";
import { DragDropContext, Droppable, Draggable, DropResult, DroppableProvided, DraggableProvided } from "react-beautiful-dnd";
import { dynamicPages } from "./register-page";
// fixme: allow to rearrange clusters with drag&drop
@@ -88,6 +89,18 @@ export class ClustersMenu extends React.Component<Props> {
})
}
@autobind()
swapClusterIconOrder(result: DropResult) {
if (result.reason === "DROP") {
const { currentWorkspaceId } = workspaceStore;
const {
source: { index: from },
destination: { index: to },
} = result
clusterStore.swapIconOrders(currentWorkspaceId, from, to)
}
}
render() {
const { className } = this.props;
const { newContexts } = userStore;
@@ -95,26 +108,46 @@ export class ClustersMenu extends React.Component<Props> {
return (
<div className={cssNames("ClustersMenu flex column", className)}>
<div className="clusters flex column gaps">
{clusters.map(cluster => {
return (
<ClusterIcon
key={cluster.id}
showErrors={true}
cluster={cluster}
isActive={cluster.id === getMatchedClusterId()}
onClick={() => this.showCluster(cluster.id)}
onContextMenu={() => this.showContextMenu(cluster)}
/>
)
})}
<DragDropContext onDragEnd={this.swapClusterIconOrder}>
<Droppable droppableId="cluster-menu" type="CLUSTER">
{(provided: DroppableProvided) => (
<div
ref={provided.innerRef}
{...provided.droppableProps}
>
{clusters.map((cluster, index) => (
<Draggable draggableId={cluster.id} index={index} key={cluster.id}>
{(provided: DraggableProvided) => (
<div
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
>
<ClusterIcon
key={cluster.id}
showErrors={true}
cluster={cluster}
isActive={cluster.id === getMatchedClusterId()}
onClick={() => this.showCluster(cluster.id)}
onContextMenu={() => this.showContextMenu(cluster)}
/>
</div>
)}
</Draggable>
))}
{provided.placeholder}
</div>
)}
</Droppable>
</DragDropContext>
</div>
<div className="add-cluster" onClick={this.addCluster}>
<Tooltip targetId="add-cluster-icon">
<Trans>Add Cluster</Trans>
</Tooltip>
<Icon big material="add" id="add-cluster-icon"/>
<Icon big material="add" id="add-cluster-icon" />
{newContexts.size > 0 && (
<Badge className="counter" label={newContexts.size} tooltip={<Trans>new</Trans>}/>
<Badge className="counter" label={newContexts.size} tooltip={<Trans>new</Trans>} />
)}
</div>
<div className="dynamic-pages">
@@ -5,7 +5,7 @@
}
&.chart-version {
min-width: 80px;
min-width: 130px;
}
}
}
@@ -2,6 +2,7 @@ import type { InputProps } from "./input";
import { ReactNode } from "react";
import { t } from "@lingui/macro";
import { _i18n } from '../../i18n';
import fse from "fs-extra";
export interface Validator {
debounce?: number; // debounce for async validators in ms
@@ -41,6 +42,12 @@ export const isUrl: Validator = {
validate: value => !!value.match(/^http(s)?:\/\/\w+(\.\w+)*(:[0-9]+)?\/?(\/[\w\-\._~:/?#[\]@!\$&'\(\)\*\+,;=.]*)*$/),
};
export const isPath: Validator = {
condition: ({ type }) => type === "text",
message: () => _i18n._(t`This field must be a path to an existing file`),
validate: value => !value || fse.pathExistsSync(value),
}
export const minLength: Validator = {
condition: ({ minLength }) => !!minLength,
message: (value, { minLength }) => _i18n._(t`Minimum length is ${minLength}`),
@@ -70,11 +70,15 @@ html {
&__menu {
background: var(--select-menu-bgc);
box-shadow: inset 0 0 0 1px var(--select-menu-border-color);
width: max-content;
min-width: 100%;
&-list {
@include custom-scrollbar;
padding-right: 1px;
padding-left: 1px;
width: max-content;
min-width: 100%;
}
&-notice {
@@ -83,6 +87,8 @@ html {
}
&__option {
white-space: nowrap;
&:active {
background: $primary;
}
+1
View File
@@ -69,6 +69,7 @@ export function getSelectedDetails() {
}
export function getDetailsUrl(details: string) {
if (!details) return "";
return getQueryString({
details: details,
selected: getSelectedDetails(),