1
0
mirror of https://github.com/lensapp/lens.git synced 2025-05-20 05:10:56 +00:00

Merge pull request #1834 from lensapp/release/v4.0.5

Release v4.0.5
This commit is contained in:
Jari Kolehmainen 2020-12-23 15:05:05 +02:00 committed by GitHub
commit 686b368ef5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
34 changed files with 589 additions and 362 deletions

View File

@ -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

View File

@ -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: {

View File

@ -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",

View File

@ -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();

View File

@ -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() {

View File

@ -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();

View File

@ -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) {

View File

@ -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"
}
}

View File

@ -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("");

View File

@ -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 [];

View File

@ -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(() => {

View File

@ -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"],

View File

@ -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;

View File

@ -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();

View File

@ -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>
));
}

View File

@ -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(),
]}
/>

View File

@ -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; };
}

View File

@ -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={{

View File

@ -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>
);
}

View File

@ -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} />

View File

@ -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>
);

View File

@ -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}

View File

@ -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;

View File

@ -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) {

View File

@ -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}
/>

View File

@ -0,0 +1,7 @@
import React from "react";
export const SidebarContext = React.createContext<SidebarContextValue>({ pinned: false });
export type SidebarContextValue = {
pinned: boolean;
};

View 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;
}
}
}
}
}

View 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>
);
}
}

View File

@ -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;

View File

@ -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>
);
}
}

View 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']");
});
});

View 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}`;
}
}

View File

@ -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

View File

@ -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"