mirror of
https://github.com/lensapp/lens.git
synced 2025-05-20 05:10:56 +00:00
commit
686b368ef5
@ -39,7 +39,7 @@ spec:
|
||||
serviceAccountName: kube-state-metrics
|
||||
containers:
|
||||
- name: kube-state-metrics
|
||||
image: quay.io/coreos/kube-state-metrics:v1.9.5
|
||||
image: quay.io/coreos/kube-state-metrics:v1.9.7
|
||||
ports:
|
||||
- name: metrics
|
||||
containerPort: 8080
|
||||
|
||||
@ -26,7 +26,7 @@ export interface MetricsConfiguration {
|
||||
|
||||
export class MetricsFeature extends ClusterFeature.Feature {
|
||||
name = "metrics";
|
||||
latestVersion = "v2.17.2-lens1";
|
||||
latestVersion = "v2.17.2-lens2";
|
||||
|
||||
templateContext: MetricsConfiguration = {
|
||||
persistence: {
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
"name": "kontena-lens",
|
||||
"productName": "Lens",
|
||||
"description": "Lens - The Kubernetes IDE",
|
||||
"version": "4.0.4",
|
||||
"version": "4.0.5",
|
||||
"main": "static/build/main.js",
|
||||
"copyright": "© 2020, Mirantis, Inc.",
|
||||
"license": "MIT",
|
||||
@ -239,6 +239,7 @@
|
||||
"node-pty": "^0.9.0",
|
||||
"npm": "^6.14.8",
|
||||
"openid-client": "^3.15.2",
|
||||
"p-limit": "^3.1.0",
|
||||
"path-to-regexp": "^6.1.0",
|
||||
"proper-lockfile": "^4.1.1",
|
||||
"request": "^2.88.2",
|
||||
@ -290,7 +291,6 @@
|
||||
"@types/jsonpath": "^0.2.0",
|
||||
"@types/lodash": "^4.14.155",
|
||||
"@types/marked": "^0.7.4",
|
||||
"@types/material-ui": "^0.21.7",
|
||||
"@types/md5-file": "^4.0.2",
|
||||
"@types/mini-css-extract-plugin": "^0.9.1",
|
||||
"@types/mock-fs": "^4.10.0",
|
||||
|
||||
@ -10,7 +10,7 @@ jest.mock("chokidar", () => ({
|
||||
jest.mock("../extension-installer", () => ({
|
||||
extensionInstaller: {
|
||||
extensionPackagesRoot: "",
|
||||
installPackages: jest.fn()
|
||||
installPackage: jest.fn()
|
||||
}
|
||||
}));
|
||||
|
||||
@ -41,7 +41,7 @@ describe("ExtensionDiscovery", () => {
|
||||
// Need to force isLoaded to be true so that the file watching is started
|
||||
extensionDiscovery.isLoaded = true;
|
||||
|
||||
await extensionDiscovery.initMain();
|
||||
await extensionDiscovery.watchExtensions();
|
||||
|
||||
extensionDiscovery.events.on("add", (extension: InstalledExtension) => {
|
||||
expect(extension).toEqual({
|
||||
@ -81,7 +81,7 @@ describe("ExtensionDiscovery", () => {
|
||||
// Need to force isLoaded to be true so that the file watching is started
|
||||
extensionDiscovery.isLoaded = true;
|
||||
|
||||
await extensionDiscovery.initMain();
|
||||
await extensionDiscovery.watchExtensions();
|
||||
|
||||
const onAdd = jest.fn();
|
||||
|
||||
|
||||
@ -55,6 +55,7 @@ export class ExtensionDiscovery {
|
||||
protected bundledFolderPath: string;
|
||||
|
||||
private loadStarted = false;
|
||||
private extensions: Map<string, InstalledExtension> = new Map();
|
||||
|
||||
// True if extensions have been loaded from the disk after app startup
|
||||
@observable isLoaded = false;
|
||||
@ -69,13 +70,6 @@ export class ExtensionDiscovery {
|
||||
this.events = new EventEmitter();
|
||||
}
|
||||
|
||||
// Each extension is added as a single dependency to this object, which is written as package.json.
|
||||
// Each dependency key is the name of the dependency, and
|
||||
// each dependency value is the non-symlinked path to the dependency (folder).
|
||||
protected packagesJson: PackageJson = {
|
||||
dependencies: {}
|
||||
};
|
||||
|
||||
get localFolderPath(): string {
|
||||
return path.join(os.homedir(), ".k8slens", "extensions");
|
||||
}
|
||||
@ -119,7 +113,6 @@ export class ExtensionDiscovery {
|
||||
}
|
||||
|
||||
async initMain() {
|
||||
this.watchExtensions();
|
||||
handleRequest(ExtensionDiscovery.extensionDiscoveryChannel, () => this.toJSON());
|
||||
|
||||
reaction(() => this.toJSON(), () => {
|
||||
@ -141,6 +134,7 @@ export class ExtensionDiscovery {
|
||||
watch(this.localFolderPath, {
|
||||
// For adding and removing symlinks to work, the depth has to be 1.
|
||||
depth: 1,
|
||||
ignoreInitial: true,
|
||||
// Try to wait until the file has been completely copied.
|
||||
// The OS might emit an event for added file even it's not completely written to the filesysten.
|
||||
awaitWriteFinish: {
|
||||
@ -176,8 +170,9 @@ export class ExtensionDiscovery {
|
||||
await this.removeSymlinkByManifestPath(manifestPath);
|
||||
|
||||
// Install dependencies for the new extension
|
||||
await this.installPackages();
|
||||
await this.installPackage(extension.absolutePath);
|
||||
|
||||
this.extensions.set(extension.id, extension);
|
||||
logger.info(`${logModule} Added extension ${extension.manifest.name}`);
|
||||
this.events.emit("add", extension);
|
||||
}
|
||||
@ -197,23 +192,19 @@ export class ExtensionDiscovery {
|
||||
const extensionFolderName = path.basename(filePath);
|
||||
|
||||
if (path.relative(this.localFolderPath, filePath) === extensionFolderName) {
|
||||
const extensionName: string | undefined = Object
|
||||
.entries(this.packagesJson.dependencies)
|
||||
.find(([, extensionFolder]) => filePath === extensionFolder)?.[0];
|
||||
const extension = Array.from(this.extensions.values()).find((extension) => extension.absolutePath === filePath);
|
||||
|
||||
if (extension) {
|
||||
const extensionName = extension.manifest.name;
|
||||
|
||||
if (extensionName !== undefined) {
|
||||
// If the extension is deleted manually while the application is running, also remove the symlink
|
||||
await this.removeSymlinkByPackageName(extensionName);
|
||||
|
||||
delete this.packagesJson.dependencies[extensionName];
|
||||
|
||||
// Reinstall dependencies to remove the extension from package.json
|
||||
await this.installPackages();
|
||||
|
||||
// The path to the manifest file is the lens extension id
|
||||
// Note that we need to use the symlinked path
|
||||
const lensExtensionId = path.join(this.nodeModulesPath, extensionName, manifestFilename);
|
||||
const lensExtensionId = extension.manifestPath;
|
||||
|
||||
this.extensions.delete(extension.id);
|
||||
logger.info(`${logModule} removed extension ${extensionName}`);
|
||||
this.events.emit("remove", lensExtensionId as LensExtensionId);
|
||||
} else {
|
||||
@ -296,7 +287,7 @@ export class ExtensionDiscovery {
|
||||
await fs.ensureDir(this.nodeModulesPath);
|
||||
await fs.ensureDir(this.localFolderPath);
|
||||
|
||||
const extensions = await this.loadExtensions();
|
||||
const extensions = await this.ensureExtensions();
|
||||
|
||||
this.isLoaded = true;
|
||||
|
||||
@ -335,7 +326,6 @@ export class ExtensionDiscovery {
|
||||
manifestJson = __non_webpack_require__(manifestPath);
|
||||
const installedManifestPath = this.getInstalledManifestPath(manifestJson.name);
|
||||
|
||||
this.packagesJson.dependencies[manifestJson.name] = path.dirname(manifestPath);
|
||||
const isEnabled = isBundled || extensionsStore.isEnabled(installedManifestPath);
|
||||
|
||||
return {
|
||||
@ -347,29 +337,46 @@ export class ExtensionDiscovery {
|
||||
isEnabled
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(`${logModule}: can't install extension at ${manifestPath}: ${error}`, { manifestJson });
|
||||
logger.error(`${logModule}: can't load extension manifest at ${manifestPath}: ${error}`, { manifestJson });
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async loadExtensions(): Promise<Map<LensExtensionId, InstalledExtension>> {
|
||||
async ensureExtensions(): Promise<Map<LensExtensionId, InstalledExtension>> {
|
||||
const bundledExtensions = await this.loadBundledExtensions();
|
||||
|
||||
await this.installPackages(); // install in-tree as a separate step
|
||||
const localExtensions = await this.loadFromFolder(this.localFolderPath);
|
||||
await this.installBundledPackages(this.packageJsonPath, bundledExtensions);
|
||||
|
||||
await this.installPackages();
|
||||
const extensions = bundledExtensions.concat(localExtensions);
|
||||
const userExtensions = await this.loadFromFolder(this.localFolderPath);
|
||||
|
||||
return new Map(extensions.map(extension => [extension.id, extension]));
|
||||
for (const extension of userExtensions) {
|
||||
if (await fs.pathExists(extension.manifestPath) === false) {
|
||||
await this.installPackage(extension.absolutePath);
|
||||
}
|
||||
}
|
||||
const extensions = bundledExtensions.concat(userExtensions);
|
||||
|
||||
return this.extensions = new Map(extensions.map(extension => [extension.id, extension]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Write package.json to file system and install dependencies.
|
||||
*/
|
||||
installPackages() {
|
||||
return extensionInstaller.installPackages(this.packageJsonPath, this.packagesJson);
|
||||
async installBundledPackages(packageJsonPath: string, extensions: InstalledExtension[]) {
|
||||
const packagesJson: PackageJson = {
|
||||
dependencies: {}
|
||||
};
|
||||
|
||||
extensions.forEach((extension) => {
|
||||
packagesJson.dependencies[extension.manifest.name] = extension.absolutePath;
|
||||
});
|
||||
|
||||
return await extensionInstaller.installPackages(packageJsonPath, packagesJson);
|
||||
}
|
||||
|
||||
async installPackage(name: string) {
|
||||
return extensionInstaller.installPackage(name);
|
||||
}
|
||||
|
||||
async loadBundledExtensions() {
|
||||
|
||||
@ -30,12 +30,49 @@ export class ExtensionInstaller {
|
||||
return __non_webpack_require__.resolve("npm/bin/npm-cli");
|
||||
}
|
||||
|
||||
installDependencies(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
/**
|
||||
* Write package.json to the file system and execute npm install for it.
|
||||
*/
|
||||
async installPackages(packageJsonPath: string, packagesJson: PackageJson): Promise<void> {
|
||||
// Mutual exclusion to install packages in sequence
|
||||
await this.installLock.acquireAsync();
|
||||
|
||||
try {
|
||||
// Write the package.json which will be installed in .installDependencies()
|
||||
await fs.writeFile(path.join(packageJsonPath), JSON.stringify(packagesJson, null, 2), {
|
||||
mode: 0o600
|
||||
});
|
||||
|
||||
logger.info(`${logModule} installing dependencies at ${extensionPackagesRoot()}`);
|
||||
const child = child_process.fork(this.npmPath, ["install", "--no-audit", "--only=prod", "--prefer-offline", "--no-package-lock"], {
|
||||
await this.npm(["install", "--no-audit", "--only=prod", "--prefer-offline", "--no-package-lock"]);
|
||||
logger.info(`${logModule} dependencies installed at ${extensionPackagesRoot()}`);
|
||||
} finally {
|
||||
this.installLock.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Install single package using npm
|
||||
*/
|
||||
async installPackage(name: string): Promise<void> {
|
||||
// Mutual exclusion to install packages in sequence
|
||||
await this.installLock.acquireAsync();
|
||||
|
||||
try {
|
||||
logger.info(`${logModule} installing package from ${name} to ${extensionPackagesRoot()}`);
|
||||
await this.npm(["install", "--no-audit", "--only=prod", "--prefer-offline", "--no-package-lock", "--no-save", name]);
|
||||
logger.info(`${logModule} package ${name} installed to ${extensionPackagesRoot()}`);
|
||||
} finally {
|
||||
this.installLock.release();
|
||||
}
|
||||
}
|
||||
|
||||
private npm(args: string[]): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = child_process.fork(this.npmPath, args, {
|
||||
cwd: extensionPackagesRoot(),
|
||||
silent: true
|
||||
silent: true,
|
||||
env: {}
|
||||
});
|
||||
let stderr = "";
|
||||
|
||||
@ -56,25 +93,6 @@ export class ExtensionInstaller {
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Write package.json to the file system and execute npm install for it.
|
||||
*/
|
||||
async installPackages(packageJsonPath: string, packagesJson: PackageJson): Promise<void> {
|
||||
// Mutual exclusion to install packages in sequence
|
||||
await this.installLock.acquireAsync();
|
||||
|
||||
try {
|
||||
// Write the package.json which will be installed in .installDependencies()
|
||||
await fs.writeFile(path.join(packageJsonPath), JSON.stringify(packagesJson, null, 2), {
|
||||
mode: 0o600
|
||||
});
|
||||
|
||||
await this.installDependencies();
|
||||
} finally {
|
||||
this.installLock.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const extensionInstaller = new ExtensionInstaller();
|
||||
|
||||
@ -12,6 +12,7 @@ import type { LensExtension, LensExtensionConstructor, LensExtensionId } from ".
|
||||
import type { LensMainExtension } from "./lens-main-extension";
|
||||
import type { LensRendererExtension } from "./lens-renderer-extension";
|
||||
import * as registries from "./registries";
|
||||
import fs from "fs";
|
||||
|
||||
// lazy load so that we get correct userData
|
||||
export function extensionPackagesRoot() {
|
||||
@ -71,7 +72,7 @@ export class ExtensionLoader {
|
||||
}
|
||||
|
||||
await Promise.all([this.whenLoaded, extensionsStore.whenLoaded]);
|
||||
|
||||
|
||||
// save state on change `extension.isEnabled`
|
||||
reaction(() => this.storeState, extensionsState => {
|
||||
extensionsStore.mergeState(extensionsState);
|
||||
@ -115,7 +116,6 @@ export class ExtensionLoader {
|
||||
protected async initMain() {
|
||||
this.isLoaded = true;
|
||||
this.loadOnMain();
|
||||
this.broadcastExtensions();
|
||||
|
||||
reaction(() => this.toJSON(), () => {
|
||||
this.broadcastExtensions();
|
||||
@ -136,7 +136,7 @@ export class ExtensionLoader {
|
||||
this.syncExtensions(extensions);
|
||||
|
||||
const receivedExtensionIds = extensions.map(([lensExtensionId]) => lensExtensionId);
|
||||
|
||||
|
||||
// Remove deleted extensions in renderer side only
|
||||
this.extensions.forEach((_, lensExtensionId) => {
|
||||
if (!receivedExtensionIds.includes(lensExtensionId)) {
|
||||
@ -276,6 +276,12 @@ export class ExtensionLoader {
|
||||
}
|
||||
|
||||
if (extEntrypoint !== "") {
|
||||
if (!fs.existsSync(extEntrypoint)) {
|
||||
console.log(`${logModule}: entrypoint ${extEntrypoint} not found, skipping ...`);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
return __non_webpack_require__(extEntrypoint).default;
|
||||
}
|
||||
} catch (err) {
|
||||
|
||||
@ -16,8 +16,10 @@
|
||||
"name": "Mirantis, Inc.",
|
||||
"email": "info@k8slens.dev"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^14.14.6",
|
||||
"dependencies": {
|
||||
"@types/node": "*",
|
||||
"@types/react-select": "*",
|
||||
"@material-ui/core": "*",
|
||||
"conf": "^7.0.1"
|
||||
}
|
||||
}
|
||||
|
||||
@ -36,10 +36,26 @@ export class DistributionDetector extends BaseClusterDetector {
|
||||
return { value: "digitalocean", accuracy: 90};
|
||||
}
|
||||
|
||||
if (this.isVMWare()) {
|
||||
return { value: "vmware", accuracy: 90};
|
||||
}
|
||||
|
||||
if (this.isMirantis()) {
|
||||
return { value: "mirantis", accuracy: 90};
|
||||
}
|
||||
|
||||
if (this.isAlibaba()) {
|
||||
return { value: "alibaba", accuracy: 90};
|
||||
}
|
||||
|
||||
if (this.isHuawei()) {
|
||||
return { value: "huawei", accuracy: 90};
|
||||
}
|
||||
|
||||
if (this.isTke()) {
|
||||
return { value: "tencent", accuracy: 90};
|
||||
}
|
||||
|
||||
if (this.isMinikube()) {
|
||||
return { value: "minikube", accuracy: 80};
|
||||
}
|
||||
@ -115,10 +131,18 @@ export class DistributionDetector extends BaseClusterDetector {
|
||||
return this.cluster.contextName === "docker-desktop";
|
||||
}
|
||||
|
||||
protected isTke() {
|
||||
return this.version.includes("-tke.");
|
||||
}
|
||||
|
||||
protected isCustom() {
|
||||
return this.version.includes("+");
|
||||
}
|
||||
|
||||
protected isVMWare() {
|
||||
return this.version.includes("+vmware");
|
||||
}
|
||||
|
||||
protected isRke() {
|
||||
return this.version.includes("-rancher");
|
||||
}
|
||||
@ -127,6 +151,14 @@ export class DistributionDetector extends BaseClusterDetector {
|
||||
return this.version.includes("+k3s");
|
||||
}
|
||||
|
||||
protected isAlibaba() {
|
||||
return this.version.includes("-aliyun");
|
||||
}
|
||||
|
||||
protected isHuawei() {
|
||||
return this.version.includes("-CCE");
|
||||
}
|
||||
|
||||
protected async isOpenshift() {
|
||||
try {
|
||||
const response = await this.k8sRequest("");
|
||||
|
||||
@ -11,10 +11,11 @@ import { Kubectl } from "./kubectl";
|
||||
import { KubeconfigManager } from "./kubeconfig-manager";
|
||||
import { loadConfig } from "../common/kube-helpers";
|
||||
import request, { RequestPromiseOptions } from "request-promise-native";
|
||||
import { apiResources } from "../common/rbac";
|
||||
import { apiResources, KubeApiResource } from "../common/rbac";
|
||||
import logger from "./logger";
|
||||
import { VersionDetector } from "./cluster-detectors/version-detector";
|
||||
import { detectorRegistry } from "./cluster-detectors/detector-registry";
|
||||
import plimit from "p-limit";
|
||||
|
||||
export enum ClusterStatus {
|
||||
AccessGranted = 2,
|
||||
@ -78,6 +79,7 @@ export class Cluster implements ClusterModel, ClusterState {
|
||||
protected kubeconfigManager: KubeconfigManager;
|
||||
protected eventDisposers: Function[] = [];
|
||||
protected activated = false;
|
||||
private resourceAccessStatuses: Map<KubeApiResource, boolean> = new Map();
|
||||
|
||||
whenInitialized = when(() => this.initialized);
|
||||
whenReady = when(() => this.ready);
|
||||
@ -379,6 +381,7 @@ export class Cluster implements ClusterModel, ClusterState {
|
||||
this.accessible = false;
|
||||
this.ready = false;
|
||||
this.activated = false;
|
||||
this.resourceAccessStatuses.clear();
|
||||
this.pushState();
|
||||
}
|
||||
|
||||
@ -484,6 +487,8 @@ export class Cluster implements ClusterModel, ClusterState {
|
||||
|
||||
this.metadata.version = versionData.value;
|
||||
|
||||
this.failureReason = null;
|
||||
|
||||
return ClusterStatus.AccessGranted;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to connect cluster "${this.contextName}": ${error}`);
|
||||
@ -643,17 +648,30 @@ export class Cluster implements ClusterModel, ClusterState {
|
||||
if (!this.allowedNamespaces.length) {
|
||||
return [];
|
||||
}
|
||||
const resourceAccessStatuses = await Promise.all(
|
||||
apiResources.map(apiResource => this.canI({
|
||||
resource: apiResource.resource,
|
||||
group: apiResource.group,
|
||||
verb: "list",
|
||||
namespace: this.allowedNamespaces[0]
|
||||
}))
|
||||
);
|
||||
const resources = apiResources.filter((resource) => this.resourceAccessStatuses.get(resource) === undefined);
|
||||
const apiLimit = plimit(5); // 5 concurrent api requests
|
||||
const requests = [];
|
||||
|
||||
for (const apiResource of resources) {
|
||||
requests.push(apiLimit(async () => {
|
||||
for (const namespace of this.allowedNamespaces.slice(0, 10)) {
|
||||
if (!this.resourceAccessStatuses.get(apiResource)) {
|
||||
const result = await this.canI({
|
||||
resource: apiResource.resource,
|
||||
group: apiResource.group,
|
||||
verb: "list",
|
||||
namespace
|
||||
});
|
||||
|
||||
this.resourceAccessStatuses.set(apiResource, result);
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
await Promise.all(requests);
|
||||
|
||||
return apiResources
|
||||
.filter((resource, i) => resourceAccessStatuses[i])
|
||||
.filter((resource) => this.resourceAccessStatuses.get(resource))
|
||||
.map(apiResource => apiResource.resource);
|
||||
} catch (error) {
|
||||
return [];
|
||||
|
||||
@ -103,7 +103,6 @@ app.on("ready", async () => {
|
||||
}
|
||||
|
||||
extensionLoader.init();
|
||||
|
||||
extensionDiscovery.init();
|
||||
windowManager = WindowManager.getInstance<WindowManager>(proxyPort);
|
||||
|
||||
@ -111,6 +110,9 @@ app.on("ready", async () => {
|
||||
try {
|
||||
const extensions = await extensionDiscovery.load();
|
||||
|
||||
// Start watching after bundled extensions are loaded
|
||||
extensionDiscovery.watchExtensions();
|
||||
|
||||
// Subscribe to extensions that are copied or deleted to/from the extensions folder
|
||||
extensionDiscovery.events.on("add", (extension: InstalledExtension) => {
|
||||
extensionLoader.addExtension(extension);
|
||||
@ -122,6 +124,8 @@ app.on("ready", async () => {
|
||||
extensionLoader.initExtensions(extensions);
|
||||
} catch (error) {
|
||||
dialog.showErrorBox("Lens Error", `Could not load extensions${error?.message ? `: ${error.message}` : ""}`);
|
||||
console.error(error);
|
||||
console.trace();
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
|
||||
@ -22,10 +22,11 @@ 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.14"],
|
||||
["1.16", "1.16.15"],
|
||||
["1.17", bundledVersion],
|
||||
["1.18", "1.18.8"],
|
||||
["1.19", "1.19.0"]
|
||||
["1.18", "1.18.14"],
|
||||
["1.19", "1.19.5"],
|
||||
["1.20", "1.20.0"]
|
||||
]);
|
||||
const packageMirrors: Map<string, string> = new Map([
|
||||
["default", "https://storage.googleapis.com/kubernetes-release/release"],
|
||||
|
||||
@ -120,6 +120,14 @@ export class LensProxy {
|
||||
protected createProxy(): httpProxy {
|
||||
const proxy = httpProxy.createProxyServer();
|
||||
|
||||
proxy.on("proxyRes", (proxyRes, req) => {
|
||||
const retryCounterId = this.getRequestId(req);
|
||||
|
||||
if (this.retryCounters.has(retryCounterId)) {
|
||||
this.retryCounters.delete(retryCounterId);
|
||||
}
|
||||
});
|
||||
|
||||
proxy.on("error", (error, req, res, target) => {
|
||||
if (this.closed) {
|
||||
return;
|
||||
|
||||
@ -120,6 +120,7 @@ export class ShellSession extends EventEmitter {
|
||||
if(path.basename(env["PTYSHELL"]) === "zsh") {
|
||||
env["OLD_ZDOTDIR"] = env.ZDOTDIR || env.HOME;
|
||||
env["ZDOTDIR"] = this.kubectlBinDir;
|
||||
env["DISABLE_AUTO_UPDATE"] = "true";
|
||||
}
|
||||
|
||||
env["PTYPID"] = process.pid.toString();
|
||||
|
||||
@ -13,6 +13,7 @@ import { crdStore } from "./crd.store";
|
||||
import { KubeObjectMeta } from "../kube-object/kube-object-meta";
|
||||
import { Input } from "../input";
|
||||
import { AdditionalPrinterColumnsV1, CustomResourceDefinition } from "../../api/endpoints/crd.api";
|
||||
import { parseJsonPath } from "../../utils/jsonPath";
|
||||
|
||||
interface Props extends KubeObjectDetailsProps<CustomResourceDefinition> {
|
||||
}
|
||||
@ -46,7 +47,7 @@ export class CrdResourceDetails extends React.Component<Props> {
|
||||
renderAdditionalColumns(crd: CustomResourceDefinition, columns: AdditionalPrinterColumnsV1[]) {
|
||||
return columns.map(({ name, jsonPath: jp }) => (
|
||||
<DrawerItem key={name} name={name} renderBoolean>
|
||||
{convertSpecValue(jsonPath.value(crd, jp.slice(1)))}
|
||||
{convertSpecValue(jsonPath.value(crd, parseJsonPath(jp.slice(1))))}
|
||||
</DrawerItem>
|
||||
));
|
||||
}
|
||||
|
||||
@ -12,6 +12,7 @@ import { autorun, computed } from "mobx";
|
||||
import { crdStore } from "./crd.store";
|
||||
import { TableSortCallback } from "../table";
|
||||
import { apiManager } from "../../api/api-manager";
|
||||
import { parseJsonPath } from "../../utils/jsonPath";
|
||||
|
||||
interface Props extends RouteComponentProps<ICRDRouteParams> {
|
||||
}
|
||||
@ -61,7 +62,7 @@ export class CrdResources extends React.Component<Props> {
|
||||
};
|
||||
|
||||
extraColumns.forEach(column => {
|
||||
sortingCallbacks[column.name] = (item: KubeObject) => jsonPath.value(item, column.jsonPath.slice(1));
|
||||
sortingCallbacks[column.name] = (item: KubeObject) => jsonPath.value(item, parseJsonPath(column.jsonPath.slice(1)));
|
||||
});
|
||||
|
||||
return (
|
||||
@ -91,10 +92,18 @@ export class CrdResources extends React.Component<Props> {
|
||||
renderTableContents={(crdInstance: KubeObject) => [
|
||||
crdInstance.getName(),
|
||||
isNamespaced && crdInstance.getNs(),
|
||||
...extraColumns.map(column => ({
|
||||
renderBoolean: true,
|
||||
children: JSON.stringify(jsonPath.value(crdInstance, column.jsonPath.slice(1))),
|
||||
})),
|
||||
...extraColumns.map((column) => {
|
||||
let value = jsonPath.value(crdInstance, parseJsonPath(column.jsonPath.slice(1)));
|
||||
|
||||
if (Array.isArray(value) || typeof value === "object") {
|
||||
value = JSON.stringify(value);
|
||||
}
|
||||
|
||||
return {
|
||||
renderBoolean: true,
|
||||
children: value,
|
||||
};
|
||||
}),
|
||||
crdInstance.getAge(),
|
||||
]}
|
||||
/>
|
||||
|
||||
@ -43,10 +43,10 @@ export class NamespaceStore extends KubeObjectStore<Namespace> {
|
||||
}
|
||||
|
||||
subscribe(apis = [this.api]) {
|
||||
const { allowedNamespaces } = getHostedCluster();
|
||||
const { accessibleNamespaces } = getHostedCluster();
|
||||
|
||||
// if user has given static list of namespaces let's not start watches because watch adds stuff that's not wanted
|
||||
if (allowedNamespaces.length > 0) {
|
||||
if (accessibleNamespaces.length > 0) {
|
||||
return () => { return; };
|
||||
}
|
||||
|
||||
|
||||
@ -134,7 +134,7 @@ export class Nodes extends React.Component<Props> {
|
||||
<KubeObjectListLayout
|
||||
className="Nodes"
|
||||
store={nodesStore} isClusterScoped
|
||||
isReady={nodesStore.isLoaded && nodesStore.metricsLoaded}
|
||||
isReady={nodesStore.isLoaded}
|
||||
dependentStores={[podsStore]}
|
||||
isSelectable={false}
|
||||
sortingCallbacks={{
|
||||
|
||||
@ -14,7 +14,6 @@ import { statefulSetStore } from "../+workloads-statefulsets/statefulset.store";
|
||||
import { replicaSetStore } from "../+workloads-replicasets/replicasets.store";
|
||||
import { jobStore } from "../+workloads-jobs/job.store";
|
||||
import { cronJobStore } from "../+workloads-cronjobs/cronjob.store";
|
||||
import { Spinner } from "../spinner";
|
||||
import { Events } from "../+events";
|
||||
import { KubeObjectStore } from "../../kube-object.store";
|
||||
import { isAllowedResource } from "../../../common/rbac";
|
||||
@ -24,7 +23,6 @@ interface Props extends RouteComponentProps<IWorkloadsOverviewRouteParams> {
|
||||
|
||||
@observer
|
||||
export class WorkloadsOverview extends React.Component<Props> {
|
||||
@observable isReady = false;
|
||||
@observable isUnmounting = false;
|
||||
|
||||
async componentDidMount() {
|
||||
@ -61,10 +59,13 @@ export class WorkloadsOverview extends React.Component<Props> {
|
||||
if (isAllowedResource("events")) {
|
||||
stores.push(eventStore);
|
||||
}
|
||||
this.isReady = stores.every(store => store.isLoaded);
|
||||
await Promise.all(stores.map(store => store.loadAll()));
|
||||
this.isReady = true;
|
||||
const unsubscribeList = stores.map(store => store.subscribe());
|
||||
|
||||
const unsubscribeList: Array<() => void> = [];
|
||||
|
||||
for (const store of stores) {
|
||||
await store.loadAll();
|
||||
unsubscribeList.push(store.subscribe());
|
||||
}
|
||||
|
||||
await when(() => this.isUnmounting);
|
||||
unsubscribeList.forEach(dispose => dispose());
|
||||
@ -74,11 +75,7 @@ export class WorkloadsOverview extends React.Component<Props> {
|
||||
this.isUnmounting = true;
|
||||
}
|
||||
|
||||
renderContents() {
|
||||
if (!this.isReady) {
|
||||
return <Spinner center/>;
|
||||
}
|
||||
|
||||
get contents() {
|
||||
return (
|
||||
<>
|
||||
<OverviewStatuses/>
|
||||
@ -94,7 +91,7 @@ export class WorkloadsOverview extends React.Component<Props> {
|
||||
render() {
|
||||
return (
|
||||
<div className="WorkloadsOverview flex column gaps">
|
||||
{this.renderContents()}
|
||||
{this.contents}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -27,6 +27,7 @@ interface OptionalProps {
|
||||
showSubmitClose?: boolean;
|
||||
showInlineInfo?: boolean;
|
||||
showNotifications?: boolean;
|
||||
showStatusPanel?: boolean;
|
||||
}
|
||||
|
||||
@observer
|
||||
@ -38,6 +39,7 @@ export class InfoPanel extends Component<Props> {
|
||||
showSubmitClose: true,
|
||||
showInlineInfo: true,
|
||||
showNotifications: true,
|
||||
showStatusPanel: true,
|
||||
};
|
||||
|
||||
@observable error = "";
|
||||
@ -93,7 +95,7 @@ export class InfoPanel extends Component<Props> {
|
||||
}
|
||||
|
||||
render() {
|
||||
const { className, controls, submitLabel, disableSubmit, error, submittingMessage, showButtons, showSubmitClose } = this.props;
|
||||
const { className, controls, submitLabel, disableSubmit, error, submittingMessage, showButtons, showSubmitClose, showStatusPanel } = this.props;
|
||||
const { submit, close, submitAndClose, waiting } = this;
|
||||
const isDisabled = !!(disableSubmit || waiting || error);
|
||||
|
||||
@ -102,9 +104,11 @@ export class InfoPanel extends Component<Props> {
|
||||
<div className="controls">
|
||||
{controls}
|
||||
</div>
|
||||
<div className="info flex gaps align-center">
|
||||
{waiting ? <><Spinner /> {submittingMessage}</> : this.renderErrorIcon()}
|
||||
</div>
|
||||
{showStatusPanel && (
|
||||
<div className="flex gaps align-center">
|
||||
{waiting ? <><Spinner /> {submittingMessage}</> : this.renderErrorIcon()}
|
||||
</div>
|
||||
)}
|
||||
{showButtons && (
|
||||
<>
|
||||
<Button plain label={<Trans>Cancel</Trans>} onClick={close} />
|
||||
|
||||
@ -22,10 +22,9 @@ interface Props extends PodLogSearchProps {
|
||||
}
|
||||
|
||||
export const PodLogControls = observer((props: Props) => {
|
||||
const { tabData, save, reload, tabId, logs } = props;
|
||||
const { tabData, save, reload, logs } = props;
|
||||
const { selectedContainer, showTimestamps, previous } = tabData;
|
||||
const rawLogs = podLogsStore.logs.get(tabId) || [];
|
||||
const since = rawLogs.length ? podLogsStore.getTimestamps(rawLogs[0]) : null;
|
||||
const since = logs.length ? podLogsStore.getTimestamps(logs[0]) : null;
|
||||
const pod = new Pod(tabData.pod);
|
||||
|
||||
const toggleTimestamps = () => {
|
||||
@ -39,8 +38,9 @@ export const PodLogControls = observer((props: Props) => {
|
||||
|
||||
const downloadLogs = () => {
|
||||
const fileName = selectedContainer ? selectedContainer.name : pod.getName();
|
||||
const logsToDownload = showTimestamps ? logs : podLogsStore.logsWithoutTimestamps;
|
||||
|
||||
saveFileDialog(`${fileName}.log`, logs.join("\n"), "text/plain");
|
||||
saveFileDialog(`${fileName}.log`, logsToDownload.join("\n"), "text/plain");
|
||||
};
|
||||
|
||||
const onContainerChange = (option: SelectOption) => {
|
||||
@ -118,7 +118,10 @@ export const PodLogControls = observer((props: Props) => {
|
||||
tooltip={_i18n._(t`Save`)}
|
||||
className="download-icon"
|
||||
/>
|
||||
<PodLogSearch {...props} />
|
||||
<PodLogSearch
|
||||
{...props}
|
||||
logs={showTimestamps ? logs : podLogsStore.logsWithoutTimestamps}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -5,7 +5,7 @@ import AnsiUp from "ansi_up";
|
||||
import DOMPurify from "dompurify";
|
||||
import debounce from "lodash/debounce";
|
||||
import { Trans } from "@lingui/macro";
|
||||
import { action, observable } from "mobx";
|
||||
import { action, computed, observable } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import { Align, ListOnScrollProps } from "react-window";
|
||||
|
||||
@ -15,7 +15,7 @@ import { Button } from "../button";
|
||||
import { Icon } from "../icon";
|
||||
import { Spinner } from "../spinner";
|
||||
import { VirtualList } from "../virtual-list";
|
||||
import { logRange } from "./pod-logs.store";
|
||||
import { podLogsStore } from "./pod-logs.store";
|
||||
|
||||
interface Props {
|
||||
logs: string[]
|
||||
@ -47,23 +47,25 @@ export class PodLogList extends React.Component<Props> {
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (logs == prevProps.logs || !this.virtualListDiv.current) return;
|
||||
|
||||
const newLogsLoaded = prevProps.logs.length < logs.length;
|
||||
const scrolledToBeginning = this.virtualListDiv.current.scrollTop === 0;
|
||||
const fewLogsLoaded = logs.length < logRange;
|
||||
|
||||
if (this.isLastLineVisible) {
|
||||
if (this.isLastLineVisible || prevProps.logs.length == 0) {
|
||||
this.scrollToBottom(); // Scroll down to keep user watching/reading experience
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (scrolledToBeginning && newLogsLoaded) {
|
||||
this.virtualListDiv.current.scrollTop = (logs.length - prevProps.logs.length) * this.lineHeight;
|
||||
}
|
||||
const firstLineContents = prevProps.logs[0];
|
||||
const lineToScroll = this.props.logs.findIndex((value) => value == firstLineContents);
|
||||
|
||||
if (fewLogsLoaded) {
|
||||
this.isJumpButtonVisible = false;
|
||||
if (lineToScroll !== -1) {
|
||||
this.scrollToItem(lineToScroll, "start");
|
||||
}
|
||||
}
|
||||
|
||||
if (!logs.length) {
|
||||
@ -71,6 +73,20 @@ export class PodLogList extends React.Component<Props> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns logs with or without timestamps regarding to showTimestamps prop
|
||||
*/
|
||||
@computed
|
||||
get logs() {
|
||||
const showTimestamps = podLogsStore.getData(this.props.id).showTimestamps;
|
||||
|
||||
if (!showTimestamps) {
|
||||
return podLogsStore.logsWithoutTimestamps;
|
||||
}
|
||||
|
||||
return this.props.logs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if JumpToBottom button should be visible and sets its observable
|
||||
* @param props Scrolling props from virtual list core
|
||||
@ -115,7 +131,6 @@ export class PodLogList extends React.Component<Props> {
|
||||
@action
|
||||
scrollToBottom = () => {
|
||||
if (!this.virtualListDiv.current) return;
|
||||
this.isJumpButtonVisible = false;
|
||||
this.virtualListDiv.current.scrollTop = this.virtualListDiv.current.scrollHeight;
|
||||
};
|
||||
|
||||
@ -123,7 +138,13 @@ export class PodLogList extends React.Component<Props> {
|
||||
this.virtualListRef.current.scrollToItem(index, align);
|
||||
};
|
||||
|
||||
onScroll = debounce((props: ListOnScrollProps) => {
|
||||
onScroll = (props: ListOnScrollProps) => {
|
||||
if (!this.virtualListDiv.current) return;
|
||||
this.isLastLineVisible = false;
|
||||
this.onScrollDebounced(props);
|
||||
};
|
||||
|
||||
onScrollDebounced = debounce((props: ListOnScrollProps) => {
|
||||
if (!this.virtualListDiv.current) return;
|
||||
this.setButtonVisibility(props);
|
||||
this.setLastLineVisibility(props);
|
||||
@ -137,7 +158,7 @@ export class PodLogList extends React.Component<Props> {
|
||||
*/
|
||||
getLogRow = (rowIndex: number) => {
|
||||
const { searchQuery, isActiveOverlay } = searchStore;
|
||||
const item = this.props.logs[rowIndex];
|
||||
const item = this.logs[rowIndex];
|
||||
const contents: React.ReactElement[] = [];
|
||||
const ansiToHtml = (ansi: string) => DOMPurify.sanitize(colorConverter.ansi_to_html(ansi));
|
||||
|
||||
@ -179,15 +200,15 @@ export class PodLogList extends React.Component<Props> {
|
||||
};
|
||||
|
||||
render() {
|
||||
const { logs, isLoading } = this.props;
|
||||
const isInitLoading = isLoading && !logs.length;
|
||||
const rowHeights = new Array(logs.length).fill(this.lineHeight);
|
||||
const { isLoading } = this.props;
|
||||
const isInitLoading = isLoading && !this.logs.length;
|
||||
const rowHeights = new Array(this.logs.length).fill(this.lineHeight);
|
||||
|
||||
if (isInitLoading) {
|
||||
return <Spinner center/>;
|
||||
}
|
||||
|
||||
if (!logs.length) {
|
||||
if (!this.logs.length) {
|
||||
return (
|
||||
<div className="PodLogList flex box grow align-center justify-center">
|
||||
<Trans>There are no logs available for container</Trans>
|
||||
@ -198,7 +219,7 @@ export class PodLogList extends React.Component<Props> {
|
||||
return (
|
||||
<div className={cssNames("PodLogList flex", { isLoading })}>
|
||||
<VirtualList
|
||||
items={logs}
|
||||
items={this.logs}
|
||||
rowHeights={rowHeights}
|
||||
getRow={this.getLogRow}
|
||||
onScroll={this.onScroll}
|
||||
|
||||
@ -12,10 +12,13 @@ export interface PodLogSearchProps {
|
||||
onSearch: (query: string) => void
|
||||
toPrevOverlay: () => void
|
||||
toNextOverlay: () => void
|
||||
}
|
||||
|
||||
interface Props extends PodLogSearchProps {
|
||||
logs: string[]
|
||||
}
|
||||
|
||||
export const PodLogSearch = observer((props: PodLogSearchProps) => {
|
||||
export const PodLogSearch = observer((props: Props) => {
|
||||
const { logs, onSearch, toPrevOverlay, toNextOverlay } = props;
|
||||
const { setNextOverlayActive, setPrevOverlayActive, searchQuery, occurrences, activeFind, totalFinds } = searchStore;
|
||||
const jumpDisabled = !searchQuery || !occurrences.length;
|
||||
|
||||
@ -27,11 +27,11 @@ export class PodLogsStore extends DockTabStore<IPodLogsData> {
|
||||
private refresher = interval(10, () => {
|
||||
const id = dockStore.selectedTabId;
|
||||
|
||||
if (!this.logs.get(id)) return;
|
||||
if (!this.podLogs.get(id)) return;
|
||||
this.loadMore(id);
|
||||
});
|
||||
|
||||
@observable logs = observable.map<TabId, PodLogLine[]>();
|
||||
@observable podLogs = observable.map<TabId, PodLogLine[]>();
|
||||
@observable newLogSince = observable.map<TabId, string>(); // Timestamp after which all logs are considered to be new
|
||||
|
||||
constructor() {
|
||||
@ -48,7 +48,7 @@ export class PodLogsStore extends DockTabStore<IPodLogsData> {
|
||||
}
|
||||
}, { delay: 500 });
|
||||
|
||||
reaction(() => this.logs.get(dockStore.selectedTabId), () => {
|
||||
reaction(() => this.podLogs.get(dockStore.selectedTabId), () => {
|
||||
this.setNewLogSince(dockStore.selectedTabId);
|
||||
});
|
||||
|
||||
@ -72,7 +72,7 @@ export class PodLogsStore extends DockTabStore<IPodLogsData> {
|
||||
});
|
||||
|
||||
this.refresher.start();
|
||||
this.logs.set(tabId, logs);
|
||||
this.podLogs.set(tabId, logs);
|
||||
} catch ({error}) {
|
||||
const message = [
|
||||
_i18n._(t`Failed to load logs: ${error.message}`),
|
||||
@ -80,7 +80,7 @@ export class PodLogsStore extends DockTabStore<IPodLogsData> {
|
||||
];
|
||||
|
||||
this.refresher.stop();
|
||||
this.logs.set(tabId, message);
|
||||
this.podLogs.set(tabId, message);
|
||||
}
|
||||
};
|
||||
|
||||
@ -91,14 +91,14 @@ export class PodLogsStore extends DockTabStore<IPodLogsData> {
|
||||
* @param tabId
|
||||
*/
|
||||
loadMore = async (tabId: TabId) => {
|
||||
if (!this.logs.get(tabId).length) return;
|
||||
const oldLogs = this.logs.get(tabId);
|
||||
if (!this.podLogs.get(tabId).length) return;
|
||||
const oldLogs = this.podLogs.get(tabId);
|
||||
const logs = await this.loadLogs(tabId, {
|
||||
sinceTime: this.getLastSinceTime(tabId)
|
||||
});
|
||||
|
||||
// Add newly received logs to bottom
|
||||
this.logs.set(tabId, [...oldLogs, ...logs]);
|
||||
this.podLogs.set(tabId, [...oldLogs, ...logs]);
|
||||
};
|
||||
|
||||
/**
|
||||
@ -134,7 +134,7 @@ export class PodLogsStore extends DockTabStore<IPodLogsData> {
|
||||
* @param tabId
|
||||
*/
|
||||
setNewLogSince(tabId: TabId) {
|
||||
if (!this.logs.has(tabId) || !this.logs.get(tabId).length || this.newLogSince.has(tabId)) return;
|
||||
if (!this.podLogs.has(tabId) || !this.podLogs.get(tabId).length || this.newLogSince.has(tabId)) return;
|
||||
const timestamp = this.getLastSinceTime(tabId);
|
||||
|
||||
this.newLogSince.set(tabId, timestamp.split(".")[0]); // Removing milliseconds from string
|
||||
@ -147,18 +147,38 @@ export class PodLogsStore extends DockTabStore<IPodLogsData> {
|
||||
@computed
|
||||
get lines() {
|
||||
const id = dockStore.selectedTabId;
|
||||
const logs = this.logs.get(id);
|
||||
const logs = this.podLogs.get(id);
|
||||
|
||||
return logs ? logs.length : 0;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Returns logs with timestamps for selected tab
|
||||
*/
|
||||
get logs() {
|
||||
const id = dockStore.selectedTabId;
|
||||
|
||||
if (!this.podLogs.has(id)) return [];
|
||||
|
||||
return this.podLogs.get(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes timestamps from each log line and returns changed logs
|
||||
* @returns Logs without timestamps
|
||||
*/
|
||||
get logsWithoutTimestamps() {
|
||||
return this.logs.map(item => this.removeTimestamps(item));
|
||||
}
|
||||
|
||||
/**
|
||||
* It gets timestamps from all logs then returns last one + 1 second
|
||||
* (this allows to avoid getting the last stamp in the selection)
|
||||
* @param tabId
|
||||
*/
|
||||
getLastSinceTime(tabId: TabId) {
|
||||
const logs = this.logs.get(tabId);
|
||||
const logs = this.podLogs.get(tabId);
|
||||
const timestamps = this.getTimestamps(logs[logs.length - 1]);
|
||||
const stamp = new Date(timestamps ? timestamps[0] : null);
|
||||
|
||||
@ -176,7 +196,7 @@ export class PodLogsStore extends DockTabStore<IPodLogsData> {
|
||||
}
|
||||
|
||||
clearLogs(tabId: TabId) {
|
||||
this.logs.delete(tabId);
|
||||
this.podLogs.delete(tabId);
|
||||
}
|
||||
|
||||
clearData(tabId: TabId) {
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import React from "react";
|
||||
import { computed, observable, reaction } from "mobx";
|
||||
import { observable, reaction } from "mobx";
|
||||
import { disposeOnUnmount, observer } from "mobx-react";
|
||||
|
||||
import { searchStore } from "../../../common/search-store";
|
||||
@ -79,31 +79,15 @@ export class PodLogs extends React.Component<Props> {
|
||||
}, 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* Computed prop which returns logs with or without timestamps added to each line
|
||||
* @returns {Array} An array log items
|
||||
*/
|
||||
@computed
|
||||
get logs(): string[] {
|
||||
if (!podLogsStore.logs.has(this.tabId)) return [];
|
||||
const logs = podLogsStore.logs.get(this.tabId);
|
||||
const { getData, removeTimestamps } = podLogsStore;
|
||||
const { showTimestamps } = getData(this.tabId);
|
||||
|
||||
if (!showTimestamps) {
|
||||
return logs.map(item => removeTimestamps(item));
|
||||
}
|
||||
|
||||
return logs;
|
||||
}
|
||||
|
||||
render() {
|
||||
const logs = podLogsStore.logs;
|
||||
|
||||
const controls = (
|
||||
<PodLogControls
|
||||
ready={!this.isLoading}
|
||||
tabId={this.tabId}
|
||||
tabData={this.tabData}
|
||||
logs={this.logs}
|
||||
logs={logs}
|
||||
save={this.save}
|
||||
reload={this.reload}
|
||||
onSearch={this.onSearch}
|
||||
@ -119,11 +103,12 @@ export class PodLogs extends React.Component<Props> {
|
||||
controls={controls}
|
||||
showSubmitClose={false}
|
||||
showButtons={false}
|
||||
showStatusPanel={false}
|
||||
/>
|
||||
<PodLogList
|
||||
logs={logs}
|
||||
id={this.tabId}
|
||||
isLoading={this.isLoading}
|
||||
logs={this.logs}
|
||||
load={this.load}
|
||||
ref={this.logListElement}
|
||||
/>
|
||||
|
||||
7
src/renderer/components/layout/sidebar-context.ts
Normal file
7
src/renderer/components/layout/sidebar-context.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import React from "react";
|
||||
|
||||
export const SidebarContext = React.createContext<SidebarContextValue>({ pinned: false });
|
||||
|
||||
export type SidebarContextValue = {
|
||||
pinned: boolean;
|
||||
};
|
||||
76
src/renderer/components/layout/sidebar-nav-item.scss
Normal file
76
src/renderer/components/layout/sidebar-nav-item.scss
Normal file
@ -0,0 +1,76 @@
|
||||
.SidebarNavItem {
|
||||
$itemSpacing: floor($unit / 2.6) floor($unit / 1.6);
|
||||
|
||||
width: 100%;
|
||||
user-select: none;
|
||||
flex-shrink: 0;
|
||||
|
||||
.nav-item {
|
||||
cursor: pointer;
|
||||
width: inherit;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
text-decoration: none;
|
||||
border: none;
|
||||
padding: $itemSpacing;
|
||||
|
||||
&.active, &:hover {
|
||||
background: $lensBlue;
|
||||
color: $sidebarActiveColor;
|
||||
}
|
||||
}
|
||||
|
||||
.expand-icon {
|
||||
--size: 20px;
|
||||
}
|
||||
|
||||
.sub-menu {
|
||||
border-left: 4px solid transparent;
|
||||
|
||||
&.active {
|
||||
border-left-color: $lensBlue;
|
||||
}
|
||||
|
||||
a, .SidebarNavItem {
|
||||
display: block;
|
||||
border: none;
|
||||
text-decoration: none;
|
||||
color: $textColorPrimary;
|
||||
font-weight: normal;
|
||||
padding-left: 40px; // parent icon width
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
line-height: 0px; // hidden by default
|
||||
max-height: 0px;
|
||||
opacity: 0;
|
||||
transition: 125ms line-height ease-out, 200ms 100ms opacity;
|
||||
|
||||
&.visible {
|
||||
line-height: 28px;
|
||||
max-height: 1000px;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&.active, &:hover {
|
||||
color: $sidebarSubmenuActiveColor;
|
||||
}
|
||||
}
|
||||
|
||||
.sub-menu-parent {
|
||||
padding-left: 27px;
|
||||
font-weight: 500;
|
||||
|
||||
.nav-item {
|
||||
&:hover {
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.sub-menu {
|
||||
a {
|
||||
padding-left: $padding * 3;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
83
src/renderer/components/layout/sidebar-nav-item.tsx
Normal file
83
src/renderer/components/layout/sidebar-nav-item.tsx
Normal file
@ -0,0 +1,83 @@
|
||||
import "./sidebar-nav-item.scss";
|
||||
|
||||
import React from "react";
|
||||
import { computed, observable, reaction } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import { NavLink } from "react-router-dom";
|
||||
|
||||
import { createStorage, cssNames } from "../../utils";
|
||||
import { Icon } from "../icon";
|
||||
import { SidebarContext } from "./sidebar-context";
|
||||
|
||||
import type { TabLayoutRoute } from "./tab-layout";
|
||||
import type { SidebarContextValue } from "./sidebar-context";
|
||||
|
||||
interface SidebarNavItemProps {
|
||||
id: string; // Used to save nav item collapse/expand state in local storage
|
||||
url: string;
|
||||
text: React.ReactNode | string;
|
||||
className?: string;
|
||||
icon?: React.ReactNode;
|
||||
isHidden?: boolean;
|
||||
isActive?: boolean;
|
||||
subMenus?: TabLayoutRoute[];
|
||||
}
|
||||
|
||||
const navItemStorage = createStorage<[string, boolean][]>("sidebar_menu_item", []);
|
||||
const navItemState = observable.map<string, boolean>(navItemStorage.get());
|
||||
|
||||
reaction(() => [...navItemState], (value) => navItemStorage.set(value));
|
||||
|
||||
@observer
|
||||
export class SidebarNavItem extends React.Component<SidebarNavItemProps> {
|
||||
static contextType = SidebarContext;
|
||||
public context: SidebarContextValue;
|
||||
|
||||
@computed get isExpanded() {
|
||||
return navItemState.get(this.props.id);
|
||||
}
|
||||
|
||||
toggleSubMenu = () => {
|
||||
navItemState.set(this.props.id, !this.isExpanded);
|
||||
};
|
||||
|
||||
render() {
|
||||
const { isHidden, isActive, subMenus = [], icon, text, url, children, className, id } = this.props;
|
||||
|
||||
if (isHidden) {
|
||||
return null;
|
||||
}
|
||||
const extendedView = (subMenus.length > 0 || children) && this.context.pinned;
|
||||
|
||||
if (extendedView) {
|
||||
return (
|
||||
<div className={cssNames("SidebarNavItem", className)} data-test-id={id}>
|
||||
<div className={cssNames("nav-item", { active: isActive })} onClick={this.toggleSubMenu}>
|
||||
{icon}
|
||||
<span className="link-text">{text}</span>
|
||||
<Icon className="expand-icon" material={this.isExpanded ? "keyboard_arrow_up" : "keyboard_arrow_down"}/>
|
||||
</div>
|
||||
<ul className={cssNames("sub-menu", { active: isActive })}>
|
||||
{subMenus.map(({ title, url }) => (
|
||||
<NavLink key={url} to={url} className={cssNames({ visible: this.isExpanded })}>
|
||||
{title}
|
||||
</NavLink>
|
||||
))}
|
||||
{React.Children.toArray(children).map((child: React.ReactElement<any>) => {
|
||||
return React.cloneElement(child, {
|
||||
className: cssNames(child.props.className, { visible: this.isExpanded }),
|
||||
});
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<NavLink className={cssNames("SidebarNavItem", className)} to={url} isActive={() => isActive}>
|
||||
{icon}
|
||||
<span className="link-text">{text}</span>
|
||||
</NavLink>
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,22 +1,7 @@
|
||||
.Sidebar {
|
||||
$iconSize: 24px;
|
||||
$activeBgc: $lensBlue;
|
||||
$activeTextColor: $sidebarActiveColor;
|
||||
$itemSpacing: floor($unit / 2.6) floor($unit / 1.6);
|
||||
|
||||
@mixin activeLinkState {
|
||||
&.active {
|
||||
background: $activeBgc;
|
||||
color: $activeTextColor;
|
||||
}
|
||||
@media (hover: hover) { // only for devices supported "true" hover (with mouse or similar)
|
||||
&:hover {
|
||||
background: $activeBgc;
|
||||
color: $activeTextColor;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.pinned {
|
||||
.sidebar-nav {
|
||||
overflow: auto;
|
||||
@ -77,13 +62,16 @@
|
||||
}
|
||||
|
||||
> a {
|
||||
@include activeLinkState;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
text-decoration: none;
|
||||
border: none;
|
||||
padding: $itemSpacing;
|
||||
|
||||
&.active, &:hover {
|
||||
background: $lensBlue;
|
||||
color: $sidebarActiveColor;
|
||||
}
|
||||
}
|
||||
|
||||
hr {
|
||||
@ -91,78 +79,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.SidebarNavItem {
|
||||
width: 100%;
|
||||
user-select: none;
|
||||
flex-shrink: 0;
|
||||
|
||||
.nav-item {
|
||||
@include activeLinkState;
|
||||
|
||||
cursor: pointer;
|
||||
width: inherit;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
text-decoration: none;
|
||||
border: none;
|
||||
padding: $itemSpacing;
|
||||
}
|
||||
|
||||
.expand-icon {
|
||||
--size: 20px;
|
||||
}
|
||||
|
||||
.sub-menu {
|
||||
border-left: 4px solid transparent;
|
||||
|
||||
&.active {
|
||||
border-left-color: $activeBgc;
|
||||
}
|
||||
|
||||
a, .SidebarNavItem {
|
||||
display: block;
|
||||
border: none;
|
||||
text-decoration: none;
|
||||
color: $textColorPrimary;
|
||||
font-weight: normal;
|
||||
padding-left: 40px; // parent icon width
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
line-height: 0px; // hidden by default
|
||||
max-height: 0px;
|
||||
opacity: 0;
|
||||
transition: 125ms line-height ease-out, 200ms 100ms opacity;
|
||||
|
||||
&.visible {
|
||||
line-height: 28px;
|
||||
max-height: 1000px;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&.active, &:hover {
|
||||
color: $sidebarSubmenuActiveColor;
|
||||
}
|
||||
}
|
||||
|
||||
.sub-menu-parent {
|
||||
padding-left: 27px;
|
||||
font-weight: 500;
|
||||
|
||||
.nav-item {
|
||||
&:hover {
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.sub-menu {
|
||||
a {
|
||||
padding-left: $padding * 3;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.loading {
|
||||
padding: $padding;
|
||||
text-align: center;
|
||||
|
||||
@ -1,12 +1,11 @@
|
||||
import type { TabLayoutRoute } from "./tab-layout";
|
||||
import "./sidebar.scss";
|
||||
|
||||
import React from "react";
|
||||
import { computed, observable, reaction } from "mobx";
|
||||
import type { TabLayoutRoute } from "./tab-layout";
|
||||
import { observer } from "mobx-react";
|
||||
import { NavLink } from "react-router-dom";
|
||||
import { Trans } from "@lingui/macro";
|
||||
import { createStorage, cssNames } from "../../utils";
|
||||
import { cssNames } from "../../utils";
|
||||
import { Icon } from "../icon";
|
||||
import { workloadsRoute, workloadsURL } from "../+workloads/workloads.route";
|
||||
import { namespacesRoute, namespacesURL } from "../+namespaces/namespaces.route";
|
||||
@ -30,12 +29,8 @@ import { isActiveRoute } from "../../navigation";
|
||||
import { isAllowedResource } from "../../../common/rbac";
|
||||
import { Spinner } from "../spinner";
|
||||
import { ClusterPageMenuRegistration, clusterPageMenuRegistry, clusterPageRegistry, getExtensionPageUrl } from "../../../extensions/registries";
|
||||
|
||||
const SidebarContext = React.createContext<SidebarContextValue>({ pinned: false });
|
||||
|
||||
type SidebarContextValue = {
|
||||
pinned: boolean;
|
||||
};
|
||||
import { SidebarNavItem } from "./sidebar-nav-item";
|
||||
import { SidebarContext } from "./sidebar-context";
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
@ -69,6 +64,7 @@ export class Sidebar extends React.Component<Props> {
|
||||
return (
|
||||
<SidebarNavItem
|
||||
key={group}
|
||||
id={`crd-${group}`}
|
||||
className="sub-menu-parent"
|
||||
url={crdURL({ query: { groups: group } })}
|
||||
subMenus={submenus}
|
||||
@ -105,6 +101,7 @@ export class Sidebar extends React.Component<Props> {
|
||||
return clusterPageMenuRegistry.getRootItems().map((menuItem, index) => {
|
||||
const registeredPage = clusterPageRegistry.getByPageMenuTarget(menuItem.target);
|
||||
const tabRoutes = this.getTabLayoutRoutes(menuItem);
|
||||
const id = `registered-item-${index}`;
|
||||
let pageUrl: string;
|
||||
let isActive = false;
|
||||
|
||||
@ -122,7 +119,8 @@ export class Sidebar extends React.Component<Props> {
|
||||
|
||||
return (
|
||||
<SidebarNavItem
|
||||
key={`registered-item-${index}`}
|
||||
key={id}
|
||||
id={id}
|
||||
url={pageUrl}
|
||||
text={menuItem.title}
|
||||
icon={<menuItem.components.Icon/>}
|
||||
@ -155,7 +153,7 @@ export class Sidebar extends React.Component<Props> {
|
||||
</div>
|
||||
<div className="sidebar-nav flex column box grow-fixed">
|
||||
<SidebarNavItem
|
||||
testId="cluster"
|
||||
id="cluster"
|
||||
isActive={isActiveRoute(clusterRoute)}
|
||||
isHidden={!isAllowedResource("nodes")}
|
||||
url={clusterURL()}
|
||||
@ -163,7 +161,7 @@ export class Sidebar extends React.Component<Props> {
|
||||
icon={<Icon svg="kube"/>}
|
||||
/>
|
||||
<SidebarNavItem
|
||||
testId="nodes"
|
||||
id="nodes"
|
||||
isActive={isActiveRoute(nodesRoute)}
|
||||
isHidden={!isAllowedResource("nodes")}
|
||||
url={nodesURL()}
|
||||
@ -171,7 +169,7 @@ export class Sidebar extends React.Component<Props> {
|
||||
icon={<Icon svg="nodes"/>}
|
||||
/>
|
||||
<SidebarNavItem
|
||||
testId="workloads"
|
||||
id="workloads"
|
||||
isActive={isActiveRoute(workloadsRoute)}
|
||||
isHidden={Workloads.tabRoutes.length == 0}
|
||||
url={workloadsURL({ query })}
|
||||
@ -180,7 +178,7 @@ export class Sidebar extends React.Component<Props> {
|
||||
icon={<Icon svg="workloads"/>}
|
||||
/>
|
||||
<SidebarNavItem
|
||||
testId="config"
|
||||
id="config"
|
||||
isActive={isActiveRoute(configRoute)}
|
||||
isHidden={Config.tabRoutes.length == 0}
|
||||
url={configURL({ query })}
|
||||
@ -189,7 +187,7 @@ export class Sidebar extends React.Component<Props> {
|
||||
icon={<Icon material="list"/>}
|
||||
/>
|
||||
<SidebarNavItem
|
||||
testId="networks"
|
||||
id="networks"
|
||||
isActive={isActiveRoute(networkRoute)}
|
||||
isHidden={Network.tabRoutes.length == 0}
|
||||
url={networkURL({ query })}
|
||||
@ -198,7 +196,7 @@ export class Sidebar extends React.Component<Props> {
|
||||
icon={<Icon material="device_hub"/>}
|
||||
/>
|
||||
<SidebarNavItem
|
||||
testId="storage"
|
||||
id="storage"
|
||||
isActive={isActiveRoute(storageRoute)}
|
||||
isHidden={Storage.tabRoutes.length == 0}
|
||||
url={storageURL({ query })}
|
||||
@ -207,7 +205,7 @@ export class Sidebar extends React.Component<Props> {
|
||||
text={<Trans>Storage</Trans>}
|
||||
/>
|
||||
<SidebarNavItem
|
||||
testId="namespaces"
|
||||
id="namespaces"
|
||||
isActive={isActiveRoute(namespacesRoute)}
|
||||
isHidden={!isAllowedResource("namespaces")}
|
||||
url={namespacesURL()}
|
||||
@ -215,7 +213,7 @@ export class Sidebar extends React.Component<Props> {
|
||||
text={<Trans>Namespaces</Trans>}
|
||||
/>
|
||||
<SidebarNavItem
|
||||
testId="events"
|
||||
id="events"
|
||||
isActive={isActiveRoute(eventRoute)}
|
||||
isHidden={!isAllowedResource("events")}
|
||||
url={eventsURL({ query })}
|
||||
@ -223,7 +221,7 @@ export class Sidebar extends React.Component<Props> {
|
||||
text={<Trans>Events</Trans>}
|
||||
/>
|
||||
<SidebarNavItem
|
||||
testId="apps"
|
||||
id="apps"
|
||||
isActive={isActiveRoute(appsRoute)}
|
||||
url={appsURL({ query })}
|
||||
subMenus={Apps.tabRoutes}
|
||||
@ -231,7 +229,7 @@ export class Sidebar extends React.Component<Props> {
|
||||
text={<Trans>Apps</Trans>}
|
||||
/>
|
||||
<SidebarNavItem
|
||||
testId="users"
|
||||
id="users"
|
||||
isActive={isActiveRoute(usersManagementRoute)}
|
||||
url={usersManagementURL({ query })}
|
||||
subMenus={UserManagement.tabRoutes}
|
||||
@ -239,7 +237,7 @@ export class Sidebar extends React.Component<Props> {
|
||||
text={<Trans>Access Control</Trans>}
|
||||
/>
|
||||
<SidebarNavItem
|
||||
testId="custom-resources"
|
||||
id="custom-resources"
|
||||
isActive={isActiveRoute(crdRoute)}
|
||||
isHidden={!isAllowedResource("customresourcedefinitions")}
|
||||
url={crdURL()}
|
||||
@ -256,79 +254,3 @@ export class Sidebar extends React.Component<Props> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
interface SidebarNavItemProps {
|
||||
url: string;
|
||||
text: React.ReactNode | string;
|
||||
className?: string;
|
||||
icon?: React.ReactNode;
|
||||
isHidden?: boolean;
|
||||
isActive?: boolean;
|
||||
subMenus?: TabLayoutRoute[];
|
||||
testId?: string; // data-test-id="" property for integration tests
|
||||
}
|
||||
|
||||
const navItemStorage = createStorage<[string, boolean][]>("sidebar_menu_item", []);
|
||||
const navItemState = observable.map<string, boolean>(navItemStorage.get());
|
||||
|
||||
reaction(() => [...navItemState], (value) => navItemStorage.set(value));
|
||||
|
||||
@observer
|
||||
class SidebarNavItem extends React.Component<SidebarNavItemProps> {
|
||||
static contextType = SidebarContext;
|
||||
public context: SidebarContextValue;
|
||||
|
||||
get itemId() {
|
||||
const url = new URL(this.props.url, `${window.location.protocol}//${window.location.host}`);
|
||||
|
||||
return url.pathname; // pathname without get params
|
||||
}
|
||||
|
||||
@computed get isExpanded() {
|
||||
return navItemState.get(this.itemId);
|
||||
}
|
||||
|
||||
toggleSubMenu = () => {
|
||||
navItemState.set(this.itemId, !this.isExpanded);
|
||||
};
|
||||
|
||||
render() {
|
||||
const { isHidden, isActive, subMenus = [], icon, text, url, children, className, testId } = this.props;
|
||||
|
||||
if (isHidden) {
|
||||
return null;
|
||||
}
|
||||
const extendedView = (subMenus.length > 0 || children) && this.context.pinned;
|
||||
|
||||
if (extendedView) {
|
||||
return (
|
||||
<div className={cssNames("SidebarNavItem", className)} data-test-id={testId}>
|
||||
<div className={cssNames("nav-item", { active: isActive })} onClick={this.toggleSubMenu}>
|
||||
{icon}
|
||||
<span className="link-text">{text}</span>
|
||||
<Icon className="expand-icon" material={this.isExpanded ? "keyboard_arrow_up" : "keyboard_arrow_down"}/>
|
||||
</div>
|
||||
<ul className={cssNames("sub-menu", { active: isActive })}>
|
||||
{subMenus.map(({ title, url }) => (
|
||||
<NavLink key={url} to={url} className={cssNames({ visible: this.isExpanded })}>
|
||||
{title}
|
||||
</NavLink>
|
||||
))}
|
||||
{React.Children.toArray(children).map((child: React.ReactElement<any>) => {
|
||||
return React.cloneElement(child, {
|
||||
className: cssNames(child.props.className, { visible: this.isExpanded }),
|
||||
});
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<NavLink className={cssNames("SidebarNavItem", className)} to={url} isActive={() => isActive}>
|
||||
{icon}
|
||||
<span className="link-text">{text}</span>
|
||||
</NavLink>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
41
src/renderer/utils/__tests__/jsonPath.test.tsx
Normal file
41
src/renderer/utils/__tests__/jsonPath.test.tsx
Normal file
@ -0,0 +1,41 @@
|
||||
import { parseJsonPath } from "../jsonPath";
|
||||
|
||||
describe("parseJsonPath", () => {
|
||||
test("should convert \\. to use indexed notation", () => {
|
||||
const res = parseJsonPath(".metadata.labels.kubesphere\\.io/alias-name");
|
||||
|
||||
expect(res).toBe(".metadata.labels['kubesphere.io/alias-name']");
|
||||
});
|
||||
|
||||
test("should convert keys with escpaped charatecrs to use indexed notation", () => {
|
||||
const res = parseJsonPath(".metadata.labels.kubesphere\\\"io/alias-name");
|
||||
|
||||
expect(res).toBe(".metadata.labels['kubesphere\"io/alias-name']");
|
||||
});
|
||||
|
||||
test("should convert '-' to use indexed notation", () => {
|
||||
const res = parseJsonPath(".metadata.labels.alias-name");
|
||||
|
||||
expect(res).toBe(".metadata.labels['alias-name']");
|
||||
});
|
||||
|
||||
test("should handle scenario when both \\. and indexed notation are present", () => {
|
||||
const rest = parseJsonPath(".metadata.labels\\.serving['some.other.item']");
|
||||
|
||||
expect(rest).toBe(".metadata['labels.serving']['some.other.item']");
|
||||
});
|
||||
|
||||
|
||||
test("should not touch given jsonPath if no invalid characters present", () => {
|
||||
const res = parseJsonPath(".status.conditions[?(@.type=='Ready')].status");
|
||||
|
||||
expect(res).toBe(".status.conditions[?(@.type=='Ready')].status");
|
||||
});
|
||||
|
||||
test("strips '\\' away from the result", () => {
|
||||
const res = parseJsonPath(".metadata.labels['serving\\.knative\\.dev/configuration']");
|
||||
|
||||
expect(res).toBe(".metadata.labels['serving.knative.dev/configuration']");
|
||||
});
|
||||
|
||||
});
|
||||
35
src/renderer/utils/jsonPath.ts
Normal file
35
src/renderer/utils/jsonPath.ts
Normal file
@ -0,0 +1,35 @@
|
||||
// Helper to convert strings used for jsonPath where \. or - is present to use indexed notation,
|
||||
// for example: .metadata.labels.kubesphere\.io/alias-name -> .metadata.labels['kubesphere\.io/alias-name']
|
||||
|
||||
export function parseJsonPath(jsonPath: string) {
|
||||
let pathExpression = jsonPath;
|
||||
|
||||
if (jsonPath.match(/[\\-]/g)) { // search for '\' and '-'
|
||||
const [first, ...rest] = jsonPath.split(/(?<=\w)\./); // split jsonPath by '.' (\. cases are ignored)
|
||||
|
||||
pathExpression = `${convertToIndexNotation(first, true)}${rest.map(value => convertToIndexNotation(value)).join("")}`;
|
||||
}
|
||||
|
||||
// strip '\' characters from the result
|
||||
return pathExpression.replace(/\\/g, "");
|
||||
}
|
||||
|
||||
function convertToIndexNotation(key: string, firstItem = false) {
|
||||
if (key.match(/[\\-]/g)) { // check if found '\' and '-' in key
|
||||
if (key.includes("[")) { // handle cases where key contains [...]
|
||||
const keyToConvert = key.match(/^.*(?=\[)/g); // get the text from the key before '['
|
||||
|
||||
if (keyToConvert && keyToConvert[0].match(/[\\-]/g)) { // check if that part contains illegal characters
|
||||
return key.replace(keyToConvert[0], `['${keyToConvert[0]}']`); // surround key with '[' and ']'
|
||||
} else {
|
||||
return `.${key}`; // otherwise return as is with leading '.'
|
||||
}
|
||||
}
|
||||
|
||||
return `['${key}']`;
|
||||
} else { // no illegal chracters found, do not touch
|
||||
const prefix = firstItem ? "" : ".";
|
||||
|
||||
return `${prefix}${key}`;
|
||||
}
|
||||
}
|
||||
@ -2,9 +2,19 @@
|
||||
|
||||
Here you can find description of changes we've built into each release. While we try our best to make each upgrade automatic and as smooth as possible, there may be some cases where you might need to do something to ensure the application works smoothly. So please read through the release highlights!
|
||||
|
||||
## 4.0.4 (current version)
|
||||
## 4.0.5 (current version)
|
||||
|
||||
We are aware some users are encountering issues and regressions from previous version. Many of these issues are something we have not seen as part of our automated or manual testing process. To make it worse, some of them are really difficult to reproduce. We want to ensure we are putting all our energy and effort trying to resolve these issues. We hope you are patient. Expect to see new patch releases still in the coming days! Fixes in this version:
|
||||
- Fix: add missing Kubernetes distro detectors
|
||||
- Fix: improve how Workloads Overview is loaded
|
||||
- Fix: race conditions on extension loader
|
||||
- Fix: pod logs scrolling issues
|
||||
- Fix: render node list before metrics are available
|
||||
- Fix: kube-state-metrics v1.9.7
|
||||
- Fix: CRD sidebar expand/collapse
|
||||
- Fix: disable oh-my-zsh auto-update prompt when resolving shell environment
|
||||
- Add kubectl 1.20 support to Lens Smart Terminal
|
||||
- Optimise performance during cluster connect
|
||||
## 4.0.4
|
||||
|
||||
- Fix errors on Kubernetes v1.20
|
||||
- Update bundled kubectl to v1.17.15
|
||||
|
||||
27
yarn.lock
27
yarn.lock
@ -2116,14 +2116,6 @@
|
||||
resolved "https://registry.yarnpkg.com/@types/marked/-/marked-0.7.4.tgz#607685669bb1bbde2300bc58ba43486cbbee1f0a"
|
||||
integrity sha512-fdg0NO4qpuHWtZk6dASgsrBggY+8N4dWthl1bAQG9ceKUNKFjqpHaDKCAhRUI6y8vavG7hLSJ4YBwJtZyZEXqw==
|
||||
|
||||
"@types/material-ui@^0.21.7":
|
||||
version "0.21.7"
|
||||
resolved "https://registry.yarnpkg.com/@types/material-ui/-/material-ui-0.21.7.tgz#2a4ab77a56a16adef044ba607edde5214151a5d8"
|
||||
integrity sha512-OxGu+Jfm3d8IVYu5w2cqosSFU+8KJYCeVjw1jLZ7DzgoE7KpSFFpbDJKWhV1FAf/HEQXzL1IpX6PmLwINlE4Xg==
|
||||
dependencies:
|
||||
"@types/react" "*"
|
||||
"@types/react-addons-linked-state-mixin" "*"
|
||||
|
||||
"@types/md5-file@^4.0.2":
|
||||
version "4.0.2"
|
||||
resolved "https://registry.yarnpkg.com/@types/md5-file/-/md5-file-4.0.2.tgz#c7241e88f4aa17218c774befb0fc34f33f21fe36"
|
||||
@ -2266,13 +2258,6 @@
|
||||
resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.3.tgz#7ee330ba7caafb98090bece86a5ee44115904c2c"
|
||||
integrity sha512-ewFXqrQHlFsgc09MK5jP5iR7vumV/BYayNC6PgJO2LPe8vrnNFyjQjSppfEngITi0qvfKtzFvgKymGheFM9UOA==
|
||||
|
||||
"@types/react-addons-linked-state-mixin@*":
|
||||
version "0.14.21"
|
||||
resolved "https://registry.yarnpkg.com/@types/react-addons-linked-state-mixin/-/react-addons-linked-state-mixin-0.14.21.tgz#3abf296fe09d036c233ebe55f4562f3e6233af49"
|
||||
integrity sha512-3UF7Szd3JyuU+z90kqu8L4VdDWp7SUC0eRjV2QmMEliaHODGLi5XyO5ctS50K/lG6fjC0dSAPVbvnqv0nPoGMQ==
|
||||
dependencies:
|
||||
"@types/react" "*"
|
||||
|
||||
"@types/react-beautiful-dnd@^13.0.0":
|
||||
version "13.0.0"
|
||||
resolved "https://registry.yarnpkg.com/@types/react-beautiful-dnd/-/react-beautiful-dnd-13.0.0.tgz#e60d3d965312fcf1516894af92dc3e9249587db4"
|
||||
@ -11209,6 +11194,13 @@ p-limit@^2.0.0, p-limit@^2.2.0, p-limit@^2.3.0:
|
||||
dependencies:
|
||||
p-try "^2.0.0"
|
||||
|
||||
p-limit@^3.1.0:
|
||||
version "3.1.0"
|
||||
resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-3.1.0.tgz#e1daccbe78d0d1388ca18c64fea38e3e57e3706b"
|
||||
integrity sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==
|
||||
dependencies:
|
||||
yocto-queue "^0.1.0"
|
||||
|
||||
p-locate@^2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-2.0.0.tgz#20a0103b222a70c8fd39cc2e580680f3dde5ec43"
|
||||
@ -15572,6 +15564,11 @@ yn@3.1.1:
|
||||
resolved "https://registry.yarnpkg.com/yn/-/yn-3.1.1.tgz#1e87401a09d767c1d5eab26a6e4c185182d2eb50"
|
||||
integrity sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==
|
||||
|
||||
yocto-queue@^0.1.0:
|
||||
version "0.1.0"
|
||||
resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b"
|
||||
integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==
|
||||
|
||||
zip-stream@^1.2.0:
|
||||
version "1.2.0"
|
||||
resolved "https://registry.yarnpkg.com/zip-stream/-/zip-stream-1.2.0.tgz#a8bc45f4c1b49699c6b90198baacaacdbcd4ba04"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user