mirror of
https://github.com/lensapp/lens.git
synced 2025-05-20 05:10:56 +00:00
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:
commit
b1783d6150
44 files changed
+877
-291
No files matched your search
@@ -13,6 +13,7 @@ export const clusterIpc = {
|
||||
}
|
||||
},
|
||||
}),
|
||||
|
||||
activate: createIpcChannel({
|
||||
channel: "cluster:activate",
|
||||
handle: (clusterId: ClusterId) => {
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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();
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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() {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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' },
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -69,6 +69,7 @@ export function getSelectedDetails() {
|
||||
}
|
||||
|
||||
export function getDetailsUrl(details: string) {
|
||||
if (!details) return "";
|
||||
return getQueryString({
|
||||
details: details,
|
||||
selected: getSelectedDetails(),
|
||||
|
||||
Reference in new issue
Block a user