mirror of
https://github.com/lensapp/lens.git
synced 2025-05-20 05:10:56 +00:00
Merge branch 'master' into feature/catalog-entity-context-menu-visibility
This commit is contained in:
commit
4819ccf8cd
@ -1,19 +1,7 @@
|
||||
variables:
|
||||
YARN_CACHE_FOLDER: $(Pipeline.Workspace)/.yarn
|
||||
pr:
|
||||
branches:
|
||||
include:
|
||||
- master
|
||||
- releases/*
|
||||
paths:
|
||||
exclude:
|
||||
- .github/*
|
||||
- docs/*
|
||||
- mkdocs/*
|
||||
pr: none
|
||||
trigger:
|
||||
branches:
|
||||
include:
|
||||
- '*'
|
||||
tags:
|
||||
include:
|
||||
- "*"
|
||||
@ -57,12 +45,6 @@ jobs:
|
||||
displayName: Run tests
|
||||
- script: make test-extensions
|
||||
displayName: Run In-tree Extension tests
|
||||
- bash: |
|
||||
set -e
|
||||
rm -rf extensions/telemetry
|
||||
make integration-win
|
||||
git checkout extensions/telemetry
|
||||
displayName: Run integration tests
|
||||
- script: make build
|
||||
condition: "and(succeeded(), startsWith(variables['Build.SourceBranch'], 'refs/tags/'))"
|
||||
displayName: Build
|
||||
@ -70,6 +52,8 @@ jobs:
|
||||
WIN_CSC_LINK: $(WIN_CSC_LINK)
|
||||
WIN_CSC_KEY_PASSWORD: $(WIN_CSC_KEY_PASSWORD)
|
||||
GH_TOKEN: $(GH_TOKEN)
|
||||
AWS_ACCESS_KEY_ID: $(AWS_ACCESS_KEY_ID)
|
||||
AWS_SECRET_ACCESS_KEY: $(AWS_SECRET_ACCESS_KEY)
|
||||
- job: macOS
|
||||
pool:
|
||||
vmImage: macOS-10.14
|
||||
@ -102,14 +86,6 @@ jobs:
|
||||
displayName: Run tests
|
||||
- script: make test-extensions
|
||||
displayName: Run In-tree Extension tests
|
||||
- bash: |
|
||||
set -e
|
||||
rm -rf extensions/telemetry
|
||||
make integration-mac
|
||||
git checkout extensions/telemetry
|
||||
displayName: Run integration tests
|
||||
- script: make test-extensions
|
||||
displayName: Run In-tree Extension tests
|
||||
- script: make build
|
||||
condition: "and(succeeded(), startsWith(variables['Build.SourceBranch'], 'refs/tags/'))"
|
||||
displayName: Build
|
||||
@ -119,6 +95,8 @@ jobs:
|
||||
CSC_LINK: $(CSC_LINK)
|
||||
CSC_KEY_PASSWORD: $(CSC_KEY_PASSWORD)
|
||||
GH_TOKEN: $(GH_TOKEN)
|
||||
AWS_ACCESS_KEY_ID: $(AWS_ACCESS_KEY_ID)
|
||||
AWS_SECRET_ACCESS_KEY: $(AWS_SECRET_ACCESS_KEY)
|
||||
- job: Linux
|
||||
pool:
|
||||
vmImage: ubuntu-16.04
|
||||
@ -151,21 +129,6 @@ jobs:
|
||||
displayName: Run tests
|
||||
- script: make test-extensions
|
||||
displayName: Run In-tree Extension tests
|
||||
- bash: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install libgconf-2-4 conntrack -y
|
||||
curl -LO https://storage.googleapis.com/minikube/releases/latest/minikube-linux-amd64
|
||||
sudo install minikube-linux-amd64 /usr/local/bin/minikube
|
||||
sudo minikube start --driver=none
|
||||
# Although the kube and minikube config files are in placed $HOME they are owned by root
|
||||
sudo chown -R $USER $HOME/.kube $HOME/.minikube
|
||||
displayName: Install integration test dependencies
|
||||
- bash: |
|
||||
set -e
|
||||
rm -rf extensions/telemetry
|
||||
xvfb-run --auto-servernum --server-args='-screen 0, 1600x900x24' make integration-linux
|
||||
git checkout extensions/telemetry
|
||||
displayName: Run integration tests
|
||||
- bash: |
|
||||
sudo chown root:root /
|
||||
sudo apt-get update && sudo apt-get install -y snapd
|
||||
@ -181,6 +144,8 @@ jobs:
|
||||
condition: "and(succeeded(), startsWith(variables['Build.SourceBranch'], 'refs/tags/'))"
|
||||
env:
|
||||
GH_TOKEN: $(GH_TOKEN)
|
||||
AWS_ACCESS_KEY_ID: $(AWS_ACCESS_KEY_ID)
|
||||
AWS_SECRET_ACCESS_KEY: $(AWS_SECRET_ACCESS_KEY)
|
||||
- script: make publish-npm
|
||||
displayName: Publish npm package
|
||||
condition: "and(succeeded(), startsWith(variables['Build.SourceBranch'], 'refs/tags/'))"
|
||||
|
||||
71
.github/workflows/test.yml
vendored
Normal file
71
.github/workflows/test.yml
vendored
Normal file
@ -0,0 +1,71 @@
|
||||
name: Test
|
||||
on:
|
||||
- pull_request
|
||||
jobs:
|
||||
build:
|
||||
name: Test
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-16.04, macos-10.15, windows-2019]
|
||||
node-version: [12.x]
|
||||
steps:
|
||||
- name: Checkout Release from lens
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Using Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
|
||||
- run: make node_modules
|
||||
name: Install dependencies
|
||||
|
||||
- run: make build-npm
|
||||
name: Generate npm package
|
||||
|
||||
- run: make -j2 build-extensions
|
||||
name: Build bundled extensions
|
||||
|
||||
- run: make test
|
||||
name: Run tests
|
||||
|
||||
- run: make test-extensions
|
||||
name: Run In-tree Extension tests
|
||||
|
||||
- run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install libgconf-2-4 conntrack -y
|
||||
curl -LO https://storage.googleapis.com/minikube/releases/latest/minikube-linux-amd64
|
||||
sudo install minikube-linux-amd64 /usr/local/bin/minikube
|
||||
sudo minikube start --driver=none
|
||||
# Although the kube and minikube config files are in placed $HOME they are owned by root
|
||||
sudo chown -R $USER $HOME/.kube $HOME/.minikube
|
||||
name: Install integration test dependencies
|
||||
if: runner.os == 'Linux'
|
||||
- run: |
|
||||
set -e
|
||||
rm -rf extensions/telemetry
|
||||
xvfb-run --auto-servernum --server-args='-screen 0, 1600x900x24' make integration-linux
|
||||
git checkout extensions/telemetry
|
||||
name: Run Linux integration tests
|
||||
if: runner.os == 'Linux'
|
||||
|
||||
- run: |
|
||||
set -e
|
||||
rm -rf extensions/telemetry
|
||||
make integration-win
|
||||
git checkout extensions/telemetry
|
||||
name: Run Windows integration tests
|
||||
shell: bash
|
||||
if: runner.os == 'Windows'
|
||||
|
||||
- run: |
|
||||
set -e
|
||||
rm -rf extensions/telemetry
|
||||
make integration-mac
|
||||
git checkout extensions/telemetry
|
||||
name: Run macOS integration tests
|
||||
if: runner.os == 'macOS'
|
||||
2
.yarnrc
2
.yarnrc
@ -1,3 +1,3 @@
|
||||
disturl "https://atom.io/download/electron"
|
||||
target "9.1.0"
|
||||
target "9.4.4"
|
||||
runtime "electron"
|
||||
|
||||
13
package.json
13
package.json
@ -2,7 +2,7 @@
|
||||
"name": "kontena-lens",
|
||||
"productName": "Lens",
|
||||
"description": "Lens - The Kubernetes IDE",
|
||||
"version": "5.0.0-alpha.0",
|
||||
"version": "5.0.0-alpha.1",
|
||||
"main": "static/build/main.js",
|
||||
"copyright": "© 2021, Mirantis, Inc.",
|
||||
"license": "MIT",
|
||||
@ -69,6 +69,7 @@
|
||||
]
|
||||
},
|
||||
"build": {
|
||||
"generateUpdatesFilesForAllChannels": true,
|
||||
"files": [
|
||||
"static/build/main.js"
|
||||
],
|
||||
@ -165,9 +166,9 @@
|
||||
},
|
||||
"publish": [
|
||||
{
|
||||
"provider": "github",
|
||||
"repo": "lens",
|
||||
"owner": "lensapp"
|
||||
"provider": "s3",
|
||||
"bucket": "lens-binaries",
|
||||
"path": "/ide"
|
||||
}
|
||||
],
|
||||
"protocols": {
|
||||
@ -319,8 +320,8 @@
|
||||
"css-loader": "^3.5.3",
|
||||
"deepdash": "^5.3.5",
|
||||
"dompurify": "^2.0.11",
|
||||
"electron": "^9.4.0",
|
||||
"electron-builder": "^22.7.0",
|
||||
"electron": "^9.4.4",
|
||||
"electron-builder": "^22.10.5",
|
||||
"electron-notarize": "^0.3.0",
|
||||
"eslint": "^7.7.0",
|
||||
"eslint-plugin-react": "^7.21.5",
|
||||
|
||||
63
src/common/__tests__/catalog-entity-registry.test.ts
Normal file
63
src/common/__tests__/catalog-entity-registry.test.ts
Normal file
@ -0,0 +1,63 @@
|
||||
import { observable, reaction } from "mobx";
|
||||
import { WebLink } from "../catalog-entities";
|
||||
import { CatalogEntityRegistry } from "../catalog-entity-registry";
|
||||
|
||||
describe("CatalogEntityRegistry", () => {
|
||||
let registry: CatalogEntityRegistry;
|
||||
const entity = new WebLink({
|
||||
apiVersion: "entity.k8slens.dev/v1alpha1",
|
||||
kind: "WebLink",
|
||||
metadata: {
|
||||
uid: "test",
|
||||
name: "test-link",
|
||||
source: "test",
|
||||
labels: {}
|
||||
},
|
||||
spec: {
|
||||
url: "https://k8slens.dev"
|
||||
},
|
||||
status: {
|
||||
phase: "ok"
|
||||
}
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
registry = new CatalogEntityRegistry();
|
||||
});
|
||||
|
||||
describe("addSource", () => {
|
||||
it ("allows to add an observable source", () => {
|
||||
const source = observable.array([]);
|
||||
|
||||
registry.addSource("test", source);
|
||||
expect(registry.items.length).toEqual(0);
|
||||
|
||||
source.push(entity);
|
||||
|
||||
expect(registry.items.length).toEqual(1);
|
||||
});
|
||||
|
||||
it ("added source change triggers reaction", (done) => {
|
||||
const source = observable.array([]);
|
||||
|
||||
registry.addSource("test", source);
|
||||
reaction(() => registry.items, () => {
|
||||
done();
|
||||
});
|
||||
|
||||
source.push(entity);
|
||||
});
|
||||
});
|
||||
|
||||
describe("removeSource", () => {
|
||||
it ("removes source", () => {
|
||||
const source = observable.array([]);
|
||||
|
||||
registry.addSource("test", source);
|
||||
source.push(entity);
|
||||
registry.removeSource("test");
|
||||
|
||||
expect(registry.items.length).toEqual(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -1,5 +1,5 @@
|
||||
import { observable } from "mobx";
|
||||
import { CatalogCategory, CatalogEntity, CatalogEntityMetadata, CatalogEntityStatus } from "../catalog-entity";
|
||||
import { CatalogCategory, CatalogEntity, CatalogEntityData, CatalogEntityMetadata, CatalogEntityStatus } from "../catalog-entity";
|
||||
import { catalogCategoryRegistry } from "../catalog-category-registry";
|
||||
|
||||
export interface WebLinkStatus extends CatalogEntityStatus {
|
||||
@ -17,6 +17,12 @@ export class WebLink implements CatalogEntity {
|
||||
@observable public status: WebLinkStatus;
|
||||
@observable public spec: WebLinkSpec;
|
||||
|
||||
constructor(data: CatalogEntityData) {
|
||||
this.metadata = data.metadata;
|
||||
this.status = data.status as WebLinkStatus;
|
||||
this.spec = data.spec as WebLinkSpec;
|
||||
}
|
||||
|
||||
getId() {
|
||||
return this.metadata.uid;
|
||||
}
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
import { action, computed, observable } from "mobx";
|
||||
import { action, computed, observable, IObservableArray } from "mobx";
|
||||
import { CatalogEntity } from "./catalog-entity";
|
||||
|
||||
export class CatalogEntityRegistry {
|
||||
protected sources = observable.map<string, CatalogEntity[]>([], { deep: true });
|
||||
protected sources = observable.map<string, IObservableArray<CatalogEntity>>([], { deep: true });
|
||||
|
||||
@action addSource(id: string, source: CatalogEntity[]) {
|
||||
@action addSource(id: string, source: IObservableArray<CatalogEntity>) {
|
||||
this.sources.set(id, source);
|
||||
}
|
||||
|
||||
@ -12,14 +12,8 @@ export class CatalogEntityRegistry {
|
||||
this.sources.delete(id);
|
||||
}
|
||||
|
||||
@computed get items() {
|
||||
const catalogItems: CatalogEntity[] = [];
|
||||
|
||||
for (const items of this.sources.values()) {
|
||||
items.forEach((item) => catalogItems.push(item));
|
||||
}
|
||||
|
||||
return catalogItems;
|
||||
@computed get items(): CatalogEntity[] {
|
||||
return Array.from(this.sources.values()).flat();
|
||||
}
|
||||
|
||||
getItemsForApiKind<T extends CatalogEntity>(apiVersion: string, kind: string): T[] {
|
||||
|
||||
@ -4,6 +4,7 @@ import { WindowManager } from "../main/window-manager";
|
||||
import { getExtensionPageUrl } from "./registries/page-registry";
|
||||
import { catalogEntityRegistry } from "../common/catalog-entity-registry";
|
||||
import { CatalogEntity } from "../common/catalog-entity";
|
||||
import { IObservableArray } from "mobx";
|
||||
|
||||
export class LensMainExtension extends LensExtension {
|
||||
appMenus: MenuRegistration[] = [];
|
||||
@ -19,7 +20,7 @@ export class LensMainExtension extends LensExtension {
|
||||
await windowManager.navigate(pageUrl, frameId);
|
||||
}
|
||||
|
||||
addCatalogSource(id: string, source: CatalogEntity[]) {
|
||||
addCatalogSource(id: string, source: IObservableArray<CatalogEntity>) {
|
||||
catalogEntityRegistry.addSource(`${this.name}:${id}`, source);
|
||||
}
|
||||
|
||||
|
||||
@ -14,7 +14,7 @@ import { catalogEntityRegistry } from "../common/catalog-entity-registry";
|
||||
const clusterOwnerRef = "ClusterManager";
|
||||
|
||||
export class ClusterManager extends Singleton {
|
||||
@observable.deep catalogSource: CatalogEntity[] = [];
|
||||
catalogSource = observable.array<CatalogEntity>([]);
|
||||
|
||||
constructor(public readonly port: number) {
|
||||
super();
|
||||
|
||||
@ -5,11 +5,11 @@ import httpProxy from "http-proxy";
|
||||
import url from "url";
|
||||
import * as WebSocket from "ws";
|
||||
import { apiPrefix, apiKubePrefix } from "../common/vars";
|
||||
import { openShell } from "./node-shell-session";
|
||||
import { Router } from "./router";
|
||||
import { ClusterManager } from "./cluster-manager";
|
||||
import { ContextHandler } from "./context-handler";
|
||||
import logger from "./logger";
|
||||
import { NodeShellSession, LocalShellSession } from "./shell-session";
|
||||
|
||||
export class LensProxy {
|
||||
protected origin: string;
|
||||
@ -173,8 +173,12 @@ export class LensProxy {
|
||||
return ws.on("connection", ((socket: WebSocket, req: http.IncomingMessage) => {
|
||||
const cluster = this.clusterManager.getClusterForRequest(req);
|
||||
const nodeParam = url.parse(req.url, true).query["node"]?.toString();
|
||||
const shell = nodeParam
|
||||
? new NodeShellSession(socket, cluster, nodeParam)
|
||||
: new LocalShellSession(socket, cluster);
|
||||
|
||||
openShell(socket, cluster, nodeParam);
|
||||
shell.open()
|
||||
.catch(error => logger.error(`[SHELL-SESSION]: failed to open: ${error}`, { error }));
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@ -1,159 +0,0 @@
|
||||
import * as WebSocket from "ws";
|
||||
import * as pty from "node-pty";
|
||||
import { ShellSession } from "./shell-session";
|
||||
import { v4 as uuid } from "uuid";
|
||||
import * as k8s from "@kubernetes/client-node";
|
||||
import { KubeConfig } from "@kubernetes/client-node";
|
||||
import { Cluster } from "./cluster";
|
||||
import logger from "./logger";
|
||||
import { appEventBus } from "../common/event-bus";
|
||||
|
||||
export class NodeShellSession extends ShellSession {
|
||||
protected nodeName: string;
|
||||
protected podId: string;
|
||||
protected kc: KubeConfig;
|
||||
|
||||
constructor(socket: WebSocket, cluster: Cluster, nodeName: string) {
|
||||
super(socket, cluster);
|
||||
this.nodeName = nodeName;
|
||||
this.podId = `node-shell-${uuid()}`;
|
||||
}
|
||||
|
||||
public async open() {
|
||||
// these are needed by the ShellSession getCachedShellEnv() method
|
||||
this.kubeconfigPath = await this.cluster.getProxyKubeconfigPath();
|
||||
this.kubectlBinDir = await this.kubectl.binDir();
|
||||
|
||||
this.kc = await this.cluster.getProxyKubeconfig();
|
||||
const shell = await this.kubectl.getPath();
|
||||
let args = [];
|
||||
|
||||
if (this.createNodeShellPod(this.podId, this.nodeName)) {
|
||||
await this.waitForRunningPod(this.podId).catch(() => {
|
||||
this.exitAndClean(1001);
|
||||
});
|
||||
}
|
||||
args = ["exec", "-i", "-t", "-n", "kube-system", this.podId, "--", "sh", "-c", "((clear && bash) || (clear && ash) || (clear && sh))"];
|
||||
|
||||
const shellEnv = await this.getCachedShellEnv();
|
||||
|
||||
this.shellProcess = pty.spawn(shell, args, {
|
||||
cols: 80,
|
||||
cwd: this.cwd() || shellEnv["HOME"],
|
||||
env: shellEnv,
|
||||
name: "xterm-256color",
|
||||
rows: 30,
|
||||
});
|
||||
this.running = true;
|
||||
this.pipeStdout();
|
||||
this.pipeStdin();
|
||||
this.closeWebsocketOnProcessExit();
|
||||
this.exitProcessOnWebsocketClose();
|
||||
|
||||
appEventBus.emit({name: "node-shell", action: "open"});
|
||||
}
|
||||
|
||||
protected exitAndClean(code = 1000) {
|
||||
if (this.podId) {
|
||||
this.deleteNodeShellPod();
|
||||
}
|
||||
|
||||
if (code != 1000) {
|
||||
this.sendResponse("Error occurred. ");
|
||||
}
|
||||
}
|
||||
|
||||
protected async createNodeShellPod(podId: string, nodeName: string) {
|
||||
const kc = this.getKubeConfig();
|
||||
const k8sApi = kc.makeApiClient(k8s.CoreV1Api);
|
||||
const pod = {
|
||||
metadata: {
|
||||
name: podId,
|
||||
namespace: "kube-system"
|
||||
},
|
||||
spec: {
|
||||
nodeName,
|
||||
restartPolicy: "Never",
|
||||
terminationGracePeriodSeconds: 0,
|
||||
hostPID: true,
|
||||
hostIPC: true,
|
||||
hostNetwork: true,
|
||||
tolerations: [{
|
||||
operator: "Exists"
|
||||
}],
|
||||
containers: [{
|
||||
name: "shell",
|
||||
image: "docker.io/alpine:3.12",
|
||||
securityContext: {
|
||||
privileged: true,
|
||||
},
|
||||
command: ["nsenter"],
|
||||
args: ["-t", "1", "-m", "-u", "-i", "-n", "sleep", "14000"]
|
||||
}],
|
||||
}
|
||||
} as k8s.V1Pod;
|
||||
|
||||
await k8sApi.createNamespacedPod("kube-system", pod).catch((error) => {
|
||||
logger.error(error);
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
protected getKubeConfig() {
|
||||
if (this.kc) {
|
||||
return this.kc;
|
||||
}
|
||||
this.kc = new k8s.KubeConfig();
|
||||
this.kc.loadFromFile(this.kubeconfigPath);
|
||||
|
||||
return this.kc;
|
||||
}
|
||||
|
||||
protected waitForRunningPod(podId: string) {
|
||||
return new Promise<boolean>(async (resolve, reject) => {
|
||||
const kc = this.getKubeConfig();
|
||||
const watch = new k8s.Watch(kc);
|
||||
const req = await watch.watch(`/api/v1/namespaces/kube-system/pods`, {},
|
||||
// callback is called for each received object.
|
||||
(type, obj) => {
|
||||
if (obj.metadata.name == podId && obj.status.phase === "Running") {
|
||||
resolve(true);
|
||||
}
|
||||
},
|
||||
// done callback is called if the watch terminates normally
|
||||
(err) => {
|
||||
logger.error(err);
|
||||
reject(false);
|
||||
}
|
||||
);
|
||||
|
||||
setTimeout(() => {
|
||||
req.abort();
|
||||
reject(false);
|
||||
}, 120 * 1000);
|
||||
});
|
||||
}
|
||||
|
||||
protected deleteNodeShellPod() {
|
||||
const kc = this.getKubeConfig();
|
||||
const k8sApi = kc.makeApiClient(k8s.CoreV1Api);
|
||||
|
||||
k8sApi.deleteNamespacedPod(this.podId, "kube-system");
|
||||
}
|
||||
}
|
||||
|
||||
export async function openShell(socket: WebSocket, cluster: Cluster, nodeName?: string): Promise<ShellSession> {
|
||||
let shell: ShellSession;
|
||||
|
||||
if (nodeName) {
|
||||
shell = new NodeShellSession(socket, cluster, nodeName);
|
||||
} else {
|
||||
shell = new ShellSession(socket, cluster);
|
||||
}
|
||||
shell.open();
|
||||
|
||||
return shell;
|
||||
}
|
||||
@ -1,231 +0,0 @@
|
||||
import * as pty from "node-pty";
|
||||
import * as WebSocket from "ws";
|
||||
import { EventEmitter } from "events";
|
||||
import path from "path";
|
||||
import shellEnv from "shell-env";
|
||||
import { app } from "electron";
|
||||
import { Kubectl } from "./kubectl";
|
||||
import { Cluster } from "./cluster";
|
||||
import { helmCli } from "./helm/helm-cli";
|
||||
import { isWindows } from "../common/vars";
|
||||
import { appEventBus } from "../common/event-bus";
|
||||
import { userStore } from "../common/user-store";
|
||||
import { clearKubeconfigEnvVars } from "./utils/clear-kube-env-vars";
|
||||
|
||||
export class ShellSession extends EventEmitter {
|
||||
static shellEnvs: Map<string, any> = new Map();
|
||||
|
||||
protected websocket: WebSocket;
|
||||
protected shellProcess: pty.IPty;
|
||||
protected kubeconfigPath: string;
|
||||
protected nodeShellPod: string;
|
||||
protected kubectl: Kubectl;
|
||||
protected kubectlBinDir: string;
|
||||
protected kubectlPathDir: string;
|
||||
protected helmBinDir: string;
|
||||
protected running = false;
|
||||
protected cluster: Cluster;
|
||||
|
||||
constructor(socket: WebSocket, cluster: Cluster) {
|
||||
super();
|
||||
this.websocket = socket;
|
||||
this.kubectl = new Kubectl(cluster.version);
|
||||
this.cluster = cluster;
|
||||
}
|
||||
|
||||
public async open() {
|
||||
this.kubeconfigPath = await this.cluster.getProxyKubeconfigPath();
|
||||
this.kubectlBinDir = await this.kubectl.binDir();
|
||||
const pathFromPreferences = userStore.preferences.kubectlBinariesPath || this.kubectl.getBundledPath();
|
||||
|
||||
this.kubectlPathDir = userStore.preferences.downloadKubectlBinaries ? this.kubectlBinDir : path.dirname(pathFromPreferences);
|
||||
this.helmBinDir = helmCli.getBinaryDir();
|
||||
const env = await this.getCachedShellEnv();
|
||||
const shell = env.PTYSHELL;
|
||||
const args = await this.getShellArgs(shell);
|
||||
|
||||
this.shellProcess = pty.spawn(shell, args, {
|
||||
cols: 80,
|
||||
cwd: this.cwd() || env.HOME,
|
||||
env,
|
||||
name: "xterm-256color",
|
||||
rows: 30,
|
||||
});
|
||||
this.running = true;
|
||||
|
||||
this.pipeStdout();
|
||||
this.pipeStdin();
|
||||
this.closeWebsocketOnProcessExit();
|
||||
this.exitProcessOnWebsocketClose();
|
||||
|
||||
appEventBus.emit({name: "shell", action: "open"});
|
||||
}
|
||||
|
||||
protected cwd(): string {
|
||||
const { preferences } = this.cluster;
|
||||
|
||||
if(!preferences || !preferences.terminalCWD || preferences.terminalCWD === "") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return preferences.terminalCWD;
|
||||
}
|
||||
|
||||
protected async getShellArgs(shell: string): Promise<Array<string>> {
|
||||
switch(path.basename(shell)) {
|
||||
case "powershell.exe":
|
||||
return ["-NoExit", "-command", `& {Set-Location $Env:USERPROFILE; $Env:PATH="${this.helmBinDir};${this.kubectlPathDir};$Env:PATH"}`];
|
||||
case "bash":
|
||||
return ["--init-file", path.join(this.kubectlBinDir, ".bash_set_path")];
|
||||
case "fish":
|
||||
return ["--login", "--init-command", `export PATH="${this.helmBinDir}:${this.kubectlPathDir}:$PATH"; export KUBECONFIG="${this.kubeconfigPath}"`];
|
||||
case "zsh":
|
||||
return ["--login"];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
protected async getCachedShellEnv() {
|
||||
const { id: clusterId } = this.cluster;
|
||||
|
||||
let env = ShellSession.shellEnvs.get(clusterId);
|
||||
|
||||
if (!env) {
|
||||
env = await this.getShellEnv();
|
||||
ShellSession.shellEnvs.set(clusterId, env);
|
||||
} else {
|
||||
// refresh env in the background
|
||||
this.getShellEnv().then((shellEnv: any) => {
|
||||
ShellSession.shellEnvs.set(clusterId, shellEnv);
|
||||
});
|
||||
}
|
||||
|
||||
return env;
|
||||
}
|
||||
|
||||
protected async getShellEnv() {
|
||||
const env = clearKubeconfigEnvVars(JSON.parse(JSON.stringify(await shellEnv())));
|
||||
const pathStr = [this.kubectlBinDir, this.helmBinDir, process.env.PATH].join(path.delimiter);
|
||||
const shell = userStore.preferences.shell || process.env.SHELL || process.env.PTYSHELL;
|
||||
const { preferences } = this.cluster;
|
||||
|
||||
if(isWindows) {
|
||||
env["SystemRoot"] = process.env.SystemRoot;
|
||||
env["PTYSHELL"] = shell || "powershell.exe";
|
||||
env["PATH"] = pathStr;
|
||||
env["LENS_SESSION"] = "true";
|
||||
const lensWslEnv = "KUBECONFIG/up:LENS_SESSION/u";
|
||||
|
||||
if (process.env.WSLENV != undefined) {
|
||||
env["WSLENV"] = `${process.env["WSLENV"]}:${lensWslEnv}`;
|
||||
} else {
|
||||
env["WSLENV"] = lensWslEnv;
|
||||
}
|
||||
} else if(shell !== undefined) {
|
||||
env["PTYSHELL"] = shell;
|
||||
env["PATH"] = pathStr;
|
||||
} else {
|
||||
env["PTYSHELL"] = ""; // blank runs the system default shell
|
||||
}
|
||||
|
||||
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();
|
||||
env["KUBECONFIG"] = this.kubeconfigPath;
|
||||
env["TERM_PROGRAM"] = app.getName();
|
||||
env["TERM_PROGRAM_VERSION"] = app.getVersion();
|
||||
|
||||
if (preferences.httpsProxy) {
|
||||
env["HTTPS_PROXY"] = preferences.httpsProxy;
|
||||
}
|
||||
const no_proxy = ["localhost", "127.0.0.1", env["NO_PROXY"]];
|
||||
|
||||
env["NO_PROXY"] = no_proxy.filter(address => !!address).join();
|
||||
|
||||
if (env.DEBUG) { // do not pass debug option to bash
|
||||
delete env["DEBUG"];
|
||||
}
|
||||
|
||||
return(env);
|
||||
}
|
||||
|
||||
protected pipeStdout() {
|
||||
// send shell output to websocket
|
||||
this.shellProcess.onData(((data: string) => {
|
||||
this.sendResponse(data);
|
||||
}));
|
||||
}
|
||||
|
||||
protected pipeStdin() {
|
||||
// write websocket messages to shellProcess
|
||||
this.websocket.on("message", (data: string) => {
|
||||
if (!this.running) { return; }
|
||||
|
||||
const message = Buffer.from(data.slice(1, data.length), "base64").toString();
|
||||
|
||||
switch (data[0]) {
|
||||
case "0":
|
||||
this.shellProcess.write(message);
|
||||
break;
|
||||
case "4":
|
||||
const resizeMsgObj = JSON.parse(message);
|
||||
|
||||
this.shellProcess.resize(resizeMsgObj["Width"], resizeMsgObj["Height"]);
|
||||
break;
|
||||
case "9":
|
||||
this.emit("newToken", message);
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
protected exit(code = 1000) {
|
||||
if (this.websocket.readyState == this.websocket.OPEN) this.websocket.close(code);
|
||||
this.emit("exit");
|
||||
}
|
||||
|
||||
protected closeWebsocketOnProcessExit() {
|
||||
this.shellProcess.onExit(({ exitCode }) => {
|
||||
this.running = false;
|
||||
let timeout = 0;
|
||||
|
||||
if (exitCode > 0) {
|
||||
this.sendResponse("Terminal will auto-close in 15 seconds ...");
|
||||
timeout = 15*1000;
|
||||
}
|
||||
setTimeout(() => {
|
||||
this.exit();
|
||||
}, timeout);
|
||||
});
|
||||
}
|
||||
|
||||
protected exitProcessOnWebsocketClose() {
|
||||
this.websocket.on("close", () => {
|
||||
this.killShellProcess();
|
||||
});
|
||||
}
|
||||
|
||||
protected killShellProcess(){
|
||||
if(this.running) {
|
||||
// On Windows we need to kill the shell process by pid, since Lens won't respond after a while if using `this.shellProcess.kill()`
|
||||
if (isWindows) {
|
||||
try {
|
||||
process.kill(this.shellProcess.pid);
|
||||
} catch(e) {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
this.shellProcess.kill();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected sendResponse(msg: string) {
|
||||
this.websocket.send(`1${Buffer.from(msg).toString("base64")}`);
|
||||
}
|
||||
}
|
||||
2
src/main/shell-session/index.ts
Normal file
2
src/main/shell-session/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from "./node-shell-session";
|
||||
export * from "./local-shell-session";
|
||||
40
src/main/shell-session/local-shell-session.ts
Normal file
40
src/main/shell-session/local-shell-session.ts
Normal file
@ -0,0 +1,40 @@
|
||||
import path from "path";
|
||||
import { helmCli } from "../helm/helm-cli";
|
||||
import { userStore } from "../../common/user-store";
|
||||
import { ShellSession } from "./shell-session";
|
||||
|
||||
export class LocalShellSession extends ShellSession {
|
||||
ShellType = "shell";
|
||||
|
||||
protected getPathEntries(): string[] {
|
||||
return [helmCli.getBinaryDir()];
|
||||
}
|
||||
|
||||
public async open() {
|
||||
|
||||
const env = await this.getCachedShellEnv();
|
||||
const shell = env.PTYSHELL;
|
||||
const args = await this.getShellArgs(shell);
|
||||
|
||||
super.open(env.PTYSHELL, args, env);
|
||||
}
|
||||
|
||||
protected async getShellArgs(shell: string): Promise<string[]> {
|
||||
const helmpath = helmCli.getBinaryDir();
|
||||
const pathFromPreferences = userStore.preferences.kubectlBinariesPath || this.kubectl.getBundledPath();
|
||||
const kubectlPathDir = userStore.preferences.downloadKubectlBinaries ? await this.kubectlBinDirP : path.dirname(pathFromPreferences);
|
||||
|
||||
switch(path.basename(shell)) {
|
||||
case "powershell.exe":
|
||||
return ["-NoExit", "-command", `& {Set-Location $Env:USERPROFILE; $Env:PATH="${helmpath};${kubectlPathDir};$Env:PATH"}`];
|
||||
case "bash":
|
||||
return ["--init-file", path.join(await this.kubectlBinDirP, ".bash_set_path")];
|
||||
case "fish":
|
||||
return ["--login", "--init-command", `export PATH="${helmpath}:${kubectlPathDir}:$PATH"; export KUBECONFIG="${this.kubeconfigPathP}"`];
|
||||
case "zsh":
|
||||
return ["--login"];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
108
src/main/shell-session/node-shell-session.ts
Normal file
108
src/main/shell-session/node-shell-session.ts
Normal file
@ -0,0 +1,108 @@
|
||||
import * as WebSocket from "ws";
|
||||
import { v4 as uuid } from "uuid";
|
||||
import * as k8s from "@kubernetes/client-node";
|
||||
import { KubeConfig } from "@kubernetes/client-node";
|
||||
import { Cluster } from "../cluster";
|
||||
import { ShellOpenError, ShellSession } from "./shell-session";
|
||||
|
||||
export class NodeShellSession extends ShellSession {
|
||||
ShellType = "node-shell";
|
||||
|
||||
protected podId = `node-shell-${uuid()}`;
|
||||
protected kc: KubeConfig;
|
||||
|
||||
constructor(socket: WebSocket, cluster: Cluster, protected nodeName: string) {
|
||||
super(socket, cluster);
|
||||
}
|
||||
|
||||
public async open() {
|
||||
this.kc = await this.cluster.getProxyKubeconfig();
|
||||
const shell = await this.kubectl.getPath();
|
||||
|
||||
try {
|
||||
await this.createNodeShellPod();
|
||||
await this.waitForRunningPod();
|
||||
} catch (error) {
|
||||
this.deleteNodeShellPod();
|
||||
this.sendResponse("Error occurred. ");
|
||||
|
||||
throw new ShellOpenError("failed to create node pod", error);
|
||||
}
|
||||
|
||||
const args = ["exec", "-i", "-t", "-n", "kube-system", this.podId, "--", "sh", "-c", "((clear && bash) || (clear && ash) || (clear && sh))"];
|
||||
const env = await this.getCachedShellEnv();
|
||||
|
||||
super.open(shell, args, env);
|
||||
}
|
||||
|
||||
protected createNodeShellPod() {
|
||||
return this
|
||||
.kc
|
||||
.makeApiClient(k8s.CoreV1Api)
|
||||
.createNamespacedPod("kube-system", {
|
||||
metadata: {
|
||||
name: this.podId,
|
||||
namespace: "kube-system"
|
||||
},
|
||||
spec: {
|
||||
nodeName: this.nodeName,
|
||||
restartPolicy: "Never",
|
||||
terminationGracePeriodSeconds: 0,
|
||||
hostPID: true,
|
||||
hostIPC: true,
|
||||
hostNetwork: true,
|
||||
tolerations: [{
|
||||
operator: "Exists"
|
||||
}],
|
||||
containers: [{
|
||||
name: "shell",
|
||||
image: "docker.io/alpine:3.12",
|
||||
securityContext: {
|
||||
privileged: true,
|
||||
},
|
||||
command: ["nsenter"],
|
||||
args: ["-t", "1", "-m", "-u", "-i", "-n", "sleep", "14000"]
|
||||
}],
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
protected waitForRunningPod(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const watch = new k8s.Watch(this.kc);
|
||||
|
||||
watch
|
||||
.watch(`/api/v1/namespaces/kube-system/pods`,
|
||||
{},
|
||||
// callback is called for each received object.
|
||||
(type, obj) => {
|
||||
if (obj.metadata.name == this.podId && obj.status.phase === "Running") {
|
||||
resolve();
|
||||
}
|
||||
},
|
||||
// done callback is called if the watch terminates normally
|
||||
(err) => {
|
||||
console.log(err);
|
||||
reject(err);
|
||||
}
|
||||
)
|
||||
.then(req => {
|
||||
setTimeout(() => {
|
||||
console.log("aborting");
|
||||
req.abort();
|
||||
}, 2 * 60 * 1000);
|
||||
})
|
||||
.catch(err => {
|
||||
console.log("watch failed");
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
protected deleteNodeShellPod() {
|
||||
this
|
||||
.kc
|
||||
.makeApiClient(k8s.CoreV1Api)
|
||||
.deleteNamespacedPod(this.podId, "kube-system");
|
||||
}
|
||||
}
|
||||
179
src/main/shell-session/shell-session.ts
Normal file
179
src/main/shell-session/shell-session.ts
Normal file
@ -0,0 +1,179 @@
|
||||
import { Cluster } from "../cluster";
|
||||
import { Kubectl } from "../kubectl";
|
||||
import * as WebSocket from "ws";
|
||||
import shellEnv from "shell-env";
|
||||
import { app } from "electron";
|
||||
import { clearKubeconfigEnvVars } from "../utils/clear-kube-env-vars";
|
||||
import path from "path";
|
||||
import { isWindows } from "../../common/vars";
|
||||
import { userStore } from "../../common/user-store";
|
||||
import * as pty from "node-pty";
|
||||
import { appEventBus } from "../../common/event-bus";
|
||||
|
||||
export class ShellOpenError extends Error {
|
||||
constructor(message: string, public cause: Error) {
|
||||
super(`${message}: ${cause}`);
|
||||
this.name = this.constructor.name;
|
||||
Error.captureStackTrace(this);
|
||||
}
|
||||
}
|
||||
|
||||
export abstract class ShellSession {
|
||||
abstract ShellType: string;
|
||||
|
||||
static shellEnvs: Map<string, Record<string, any>> = new Map();
|
||||
|
||||
protected kubectl: Kubectl;
|
||||
protected running = false;
|
||||
protected shellProcess: pty.IPty;
|
||||
protected kubectlBinDirP: Promise<string>;
|
||||
protected kubeconfigPathP: Promise<string>;
|
||||
|
||||
protected get cwd(): string | undefined {
|
||||
return this.cluster.preferences?.terminalCWD;
|
||||
}
|
||||
|
||||
constructor(protected websocket: WebSocket, protected cluster: Cluster) {
|
||||
this.kubectl = new Kubectl(cluster.version);
|
||||
this.kubeconfigPathP = this.cluster.getProxyKubeconfigPath();
|
||||
this.kubectlBinDirP = this.kubectl.binDir();
|
||||
}
|
||||
|
||||
open(shell: string, args: string[], env: Record<string, any>): void {
|
||||
this.shellProcess = pty.spawn(shell, args, {
|
||||
cols: 80,
|
||||
cwd: this.cwd || env.HOME,
|
||||
env,
|
||||
name: "xterm-256color",
|
||||
rows: 30,
|
||||
});
|
||||
this.running = true;
|
||||
|
||||
this.shellProcess.onData(data => this.sendResponse(data));
|
||||
this.shellProcess.onExit(({ exitCode }) => {
|
||||
this.running = false;
|
||||
|
||||
if (exitCode > 0) {
|
||||
this.sendResponse("Terminal will auto-close in 15 seconds ...");
|
||||
setTimeout(() => this.exit(), 15 * 1000);
|
||||
} else {
|
||||
this.exit();
|
||||
}
|
||||
});
|
||||
|
||||
this.websocket
|
||||
.on("message", (data: string) => {
|
||||
if (!this.running) {
|
||||
return;
|
||||
}
|
||||
|
||||
const message = Buffer.from(data.slice(1, data.length), "base64").toString();
|
||||
|
||||
switch (data[0]) {
|
||||
case "0":
|
||||
this.shellProcess.write(message);
|
||||
break;
|
||||
case "4":
|
||||
const { Width, Height } = JSON.parse(message);
|
||||
|
||||
this.shellProcess.resize(Width, Height);
|
||||
break;
|
||||
}
|
||||
})
|
||||
.on("close", () => {
|
||||
if (this.running) {
|
||||
try {
|
||||
process.kill(this.shellProcess.pid);
|
||||
} catch (e) {
|
||||
}
|
||||
}
|
||||
|
||||
this.running = false;
|
||||
});
|
||||
|
||||
appEventBus.emit({ name: this.ShellType, action: "open" });
|
||||
}
|
||||
|
||||
protected getPathEntries(): string[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
protected async getCachedShellEnv() {
|
||||
const { id: clusterId } = this.cluster;
|
||||
|
||||
let env = ShellSession.shellEnvs.get(clusterId);
|
||||
|
||||
if (!env) {
|
||||
env = await this.getShellEnv();
|
||||
ShellSession.shellEnvs.set(clusterId, env);
|
||||
} else {
|
||||
// refresh env in the background
|
||||
this.getShellEnv().then((shellEnv: any) => {
|
||||
ShellSession.shellEnvs.set(clusterId, shellEnv);
|
||||
});
|
||||
}
|
||||
|
||||
return env;
|
||||
}
|
||||
|
||||
protected async getShellEnv() {
|
||||
const env = clearKubeconfigEnvVars(JSON.parse(JSON.stringify(await shellEnv())));
|
||||
const pathStr = [...this.getPathEntries(), await this.kubectlBinDirP, process.env.PATH].join(path.delimiter);
|
||||
const shell = userStore.preferences.shell || process.env.SHELL || process.env.PTYSHELL;
|
||||
|
||||
delete env.DEBUG; // don't pass DEBUG into shells
|
||||
|
||||
if (isWindows) {
|
||||
env.SystemRoot = process.env.SystemRoot;
|
||||
env.PTYSHELL = shell || "powershell.exe";
|
||||
env.PATH = pathStr;
|
||||
env.LENS_SESSION = "true";
|
||||
env.WSLENV = [
|
||||
process.env.WSLENV,
|
||||
"KUBECONFIG/up:LENS_SESSION/u"
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(":");
|
||||
} else if (shell !== undefined) {
|
||||
env.PTYSHELL = shell;
|
||||
env.PATH = pathStr;
|
||||
} else {
|
||||
env.PTYSHELL = ""; // blank runs the system default shell
|
||||
}
|
||||
|
||||
if (path.basename(env.PTYSHELL) === "zsh") {
|
||||
env.OLD_ZDOTDIR = env.ZDOTDIR || env.HOME;
|
||||
env.ZDOTDIR = this.kubectlBinDirP;
|
||||
env.DISABLE_AUTO_UPDATE = "true";
|
||||
}
|
||||
|
||||
env.PTYPID = process.pid.toString();
|
||||
env.KUBECONFIG = await this.kubeconfigPathP;
|
||||
env.TERM_PROGRAM = app.getName();
|
||||
env.TERM_PROGRAM_VERSION = app.getVersion();
|
||||
|
||||
if (this.cluster.preferences.httpsProxy) {
|
||||
env.HTTPS_PROXY = this.cluster.preferences.httpsProxy;
|
||||
}
|
||||
|
||||
env.NO_PROXY = [
|
||||
"localhost",
|
||||
"127.0.0.1",
|
||||
env.NO_PROXY
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join();
|
||||
|
||||
return env;
|
||||
}
|
||||
|
||||
protected exit(code = 1000) {
|
||||
if (this.websocket.readyState == this.websocket.OPEN) {
|
||||
this.websocket.close(code);
|
||||
}
|
||||
}
|
||||
|
||||
protected sendResponse(msg: string) {
|
||||
this.websocket.send(`1${Buffer.from(msg).toString("base64")}`);
|
||||
}
|
||||
}
|
||||
@ -1,15 +1,48 @@
|
||||
.CatalogPage {
|
||||
--width: 100%;
|
||||
--height: 100%;
|
||||
--nav-column-width: 230px;
|
||||
text-align: left;
|
||||
--nav-column-width: 200px;
|
||||
|
||||
.sidebarRegion {
|
||||
justify-content: flex-start;
|
||||
background-color: var(--sidebarBackground);
|
||||
|
||||
.sidebarHeader {
|
||||
background: var(--sidebarLogoBackground);
|
||||
height: var(--main-layout-header);
|
||||
padding: 4px;
|
||||
color: var(--textColorAccent);
|
||||
font-weight: bold;
|
||||
font-size: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-left: 10px;
|
||||
}
|
||||
|
||||
> .sidebar {
|
||||
width: 100%;
|
||||
|
||||
.sidebarTabs {
|
||||
margin-top: 5px;
|
||||
|
||||
.Tab {
|
||||
padding: 7px 10px;
|
||||
font-weight: normal;
|
||||
font-size: 14px;
|
||||
border-radius: 0;
|
||||
height: 36px;
|
||||
|
||||
&.active {
|
||||
background-color: var(--blue);
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.contentRegion {
|
||||
.content {
|
||||
> .content {
|
||||
padding: 20px 20px;
|
||||
}
|
||||
}
|
||||
|
||||
@ -114,10 +114,12 @@ export class Catalog extends React.Component {
|
||||
renderNavigation() {
|
||||
return (
|
||||
<Tabs className="flex column" scrollable={false} onChange={this.onTabChange} value={this.activeTab}>
|
||||
<div className="header">Catalog</div>
|
||||
{ this.categories.map((category, index) => {
|
||||
return <Tab value={category.getId()} key={index} label={category.metadata.name} data-testid={`${category.getId()}-tab`} />;
|
||||
})}
|
||||
<div className="sidebarHeader">Catalog</div>
|
||||
<div className="sidebarTabs">
|
||||
{ this.categories.map((category, index) => {
|
||||
return <Tab value={category.getId()} key={index} label={category.metadata.name} data-testid={`${category.getId()}-tab`} />;
|
||||
})}
|
||||
</div>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
|
||||
@ -2,7 +2,21 @@
|
||||
|
||||
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.2.0-rc.3 (current version)
|
||||
## 5.0.0-alpha.1 (current version)
|
||||
|
||||
- Workspaces are replaced by Catalog & Hotbar
|
||||
|
||||
## 4.2.1
|
||||
|
||||
- User is now notified if helm list fails
|
||||
- Sorting order is now saved when switching views
|
||||
- Fix: Node shells failing to open
|
||||
- Fix: Tray icon is now reactive to changes
|
||||
- Fix: Whole window is used for displaying workspace overview
|
||||
- Fix: Workspace overview is now reactive to cluster changes
|
||||
- Fix: Exported ClusterStore now enforces more invariants
|
||||
|
||||
## 4.2.0
|
||||
|
||||
- Add lens:// protocol handling with a routing mechanism
|
||||
- Add common app routes to the protocol renderer router from the documentation
|
||||
@ -38,6 +52,12 @@ Here you can find description of changes we've built into each release. While we
|
||||
- Fix: Set initial cursor position for the editor to beginning
|
||||
- Fix: Highlight sidebar's active section
|
||||
|
||||
## 4.1.5
|
||||
|
||||
- Proxy should listen only on loopback device
|
||||
- Fix extension command palette loading
|
||||
- Fix Lens not clearing other KUBECONFIG env vars
|
||||
|
||||
## 4.1.4
|
||||
|
||||
- Ignore clusters with invalid kubeconfig
|
||||
|
||||
Loading…
Reference in New Issue
Block a user