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

Merge branch 'master' into preferences-redesign

This commit is contained in:
Alex Andreev 2021-04-01 09:59:17 +03:00
commit 987be6c52e
35 changed files with 807 additions and 407 deletions

View File

@ -1,3 +1,8 @@
CMD_ARGS = $(filter-out $@,$(MAKECMDGOALS))
%:
@:
EXTENSIONS_DIR = ./extensions
extensions = $(foreach dir, $(wildcard $(EXTENSIONS_DIR)/*), ${dir})
extension_node_modules = $(foreach dir, $(wildcard $(EXTENSIONS_DIR)/*), ${dir}/node_modules)
@ -16,16 +21,16 @@ node_modules: yarn.lock
yarn install --frozen-lockfile
yarn check --verify-tree --integrity
static/build/LensDev.html:
static/build/LensDev.html: node_modules
yarn compile:renderer
.PHONY: compile-dev
compile-dev:
compile-dev: node_modules
yarn compile:main --cache
yarn compile:renderer --cache
.PHONY: dev
dev: node_modules binaries/client build-extensions static/build/LensDev.html
dev: binaries/client build-extensions static/build/LensDev.html
yarn dev
.PHONY: lint
@ -34,7 +39,7 @@ lint:
.PHONY: test
test: binaries/client
yarn test
yarn run jest $(or $(CMD_ARGS), "src")
.PHONY: integration-linux
integration-linux: binaries/client build-extension-types build-extensions
@ -58,10 +63,6 @@ integration-win: binaries/client build-extension-types build-extensions
yarn build:win
yarn integration
.PHONY: test-app
test-app:
yarn test
.PHONY: build
build: node_modules binaries/client build-extensions
ifeq "$(DETECTED_OS)" "Windows"
@ -77,7 +78,7 @@ $(extension_dists): src/extensions/npm/extensions/dist
cd $(@:/dist=) && npm run build
.PHONY: build-extensions
build-extensions: $(extension_node_modules) $(extension_dists)
build-extensions: node_modules $(extension_node_modules) $(extension_dists)
.PHONY: test-extensions
test-extensions: $(extension_node_modules)

9
SECURITY.md Normal file
View File

@ -0,0 +1,9 @@
# Security Policy
## Reporting a Vulnerability
Team Lens encourages users who become aware of a security vulnerability in Lens to contact Team Lens with details of the vulnerability. Team Lens has established an email address that should be used for reporting a vulnerability. Please send descriptions of any vulnerabilities found to security@k8slens.dev. Please include details on the software and hardware configuration of your system so that we can duplicate the issue being reported.
Team Lens hopes that users encountering a new vulnerability will contact us privately as it is in the best interests of our users that Team Lens has an opportunity to investigate and confirm a suspected vulnerability before it becomes public knowledge.
In the case of vulnerabilities found in third-party software components used in Lens, please also notify Team Lens as described above.

View File

@ -1,9 +1,18 @@
/**
* @jest-environment node
*/
/*
Cluster tests are run if there is a pre-existing minikube cluster. Before running cluster tests the TEST_NAMESPACE
namespace is removed, if it exists, from the minikube cluster. Resources are created as part of the cluster tests in the
TEST_NAMESPACE namespace. This is done to minimize destructive impact of the cluster tests on an existing minikube
cluster and vice versa.
*/
import { Application } from "spectron";
import * as utils from "../helpers/utils";
import { listHelmRepositories } from "../helpers/utils";
import { fail } from "assert";
jest.setTimeout(60000);
// FIXME (!): improve / simplify all css-selectors + use [data-test-id="some-id"] (already used in some tests below)
@ -11,9 +20,11 @@ describe("Lens integration tests", () => {
let app: Application;
describe("app start", () => {
beforeAll(async () => app = await utils.appStart(), 20000);
utils.beforeAllWrapped(async () => {
app = await utils.appStart();
});
afterAll(async () => {
utils.afterAllWrapped(async () => {
if (app?.isRunning()) {
await utils.tearDown(app);
}

View File

@ -33,10 +33,12 @@ describe("Lens cluster pages", () => {
};
describe("cluster add", () => {
beforeAll(async () => app = await utils.appStart(), 20000);
utils.beforeAllWrapped(async () => {
app = await utils.appStart();
});
afterAll(async () => {
if (app && app.isRunning()) {
utils.afterAllWrapped(async () => {
if (app?.isRunning()) {
return utils.tearDown(app);
}
});
@ -64,11 +66,10 @@ describe("Lens cluster pages", () => {
}
describe("cluster pages", () => {
utils.beforeAllWrapped(appStartAddCluster);
beforeAll(appStartAddCluster, 40000);
afterAll(async () => {
if (app && app.isRunning()) {
utils.afterAllWrapped(async () => {
if (app?.isRunning()) {
return utils.tearDown(app);
}
});
@ -355,10 +356,10 @@ describe("Lens cluster pages", () => {
});
describe("viewing pod logs", () => {
beforeEach(appStartAddCluster, 40000);
utils.beforeEachWrapped(appStartAddCluster);
afterEach(async () => {
if (app && app.isRunning()) {
utils.afterEachWrapped(async () => {
if (app?.isRunning()) {
return utils.tearDown(app);
}
});
@ -405,10 +406,10 @@ describe("Lens cluster pages", () => {
});
describe("cluster operations", () => {
beforeEach(appStartAddCluster, 40000);
utils.beforeEachWrapped(appStartAddCluster);
afterEach(async () => {
if (app && app.isRunning()) {
utils.afterEachWrapped(async () => {
if (app?.isRunning()) {
return utils.tearDown(app);
}
});

View File

@ -7,9 +7,11 @@ describe("Lens command palette", () => {
let app: Application;
describe("menu", () => {
beforeAll(async () => app = await utils.appStart(), 20000);
utils.beforeAllWrapped(async () => {
app = await utils.appStart();
});
afterAll(async () => {
utils.afterAllWrapped(async () => {
if (app?.isRunning()) {
await utils.tearDown(app);
}

View File

@ -13,13 +13,13 @@ describe("Lens integration tests", () => {
const ready = minikubeReady("workspace-int-tests");
utils.describeIf(ready)("workspaces", () => {
beforeAll(async () => {
utils.beforeAllWrapped(async () => {
app = await utils.appStart();
await utils.clickWhatsNew(app);
}, 20000);
});
afterAll(async () => {
if (app && app.isRunning()) {
utils.afterAllWrapped(async () => {
if (app?.isRunning()) {
return utils.tearDown(app);
}
});

View File

@ -8,6 +8,39 @@ const AppPaths: Partial<Record<NodeJS.Platform, string>> = {
"darwin": "./dist/mac/Lens.app/Contents/MacOS/Lens",
};
interface DoneCallback {
(...args: any[]): any;
fail(error?: string | { message: string }): any;
}
/**
* This is necessary because Jest doesn't do this correctly.
* @param fn The function to call
*/
export function wrapJestLifecycle(fn: () => Promise<void>): (done: DoneCallback) => void {
return function (done: DoneCallback) {
fn()
.then(() => done())
.catch(error => done.fail(error));
};
}
export function beforeAllWrapped(fn: () => Promise<void>): void {
beforeAll(wrapJestLifecycle(fn));
}
export function beforeEachWrapped(fn: () => Promise<void>): void {
beforeEach(wrapJestLifecycle(fn));
}
export function afterAllWrapped(fn: () => Promise<void>): void {
afterAll(wrapJestLifecycle(fn));
}
export function afterEachWrapped(fn: () => Promise<void>): void {
afterEach(wrapJestLifecycle(fn));
}
export function itIf(condition: boolean) {
return condition ? it : it.skip;
}

View File

@ -2,7 +2,7 @@
"name": "kontena-lens",
"productName": "Lens",
"description": "Lens - The Kubernetes IDE",
"version": "4.2.0-rc.2",
"version": "4.2.0-rc.3",
"main": "static/build/main.js",
"copyright": "© 2021, Mirantis, Inc.",
"license": "MIT",
@ -26,7 +26,6 @@
"build:linux": "yarn run compile && electron-builder --linux --dir -c.productName=Lens",
"build:mac": "yarn run compile && electron-builder --mac --dir -c.productName=Lens",
"build:win": "yarn run compile && electron-builder --win --dir -c.productName=Lens",
"test": "scripts/test.sh",
"integration": "jest --runInBand integration",
"dist": "yarn run compile && electron-builder --publish onTag",
"dist:win": "yarn run compile && electron-builder --publish onTag --x64 --ia32",
@ -36,7 +35,7 @@
"download:kubectl": "yarn run ts-node build/download_kubectl.ts",
"download:helm": "yarn run ts-node build/download_helm.ts",
"build:tray-icons": "yarn run ts-node build/build_tray_icon.ts",
"lint": "yarn run eslint $@ --ext js,ts,tsx --max-warnings=0 .",
"lint": "yarn run eslint --ext js,ts,tsx --max-warnings=0 .",
"lint:fix": "yarn run lint --fix",
"mkdocs-serve-local": "docker build -t mkdocs-serve-local:latest mkdocs/ && docker run --rm -it -p 8000:8000 -v ${PWD}:/docs mkdocs-serve-local:latest",
"verify-docs": "docker build -t mkdocs-serve-local:latest mkdocs/ && docker run --rm -v ${PWD}:/docs mkdocs-serve-local:latest build --strict",
@ -52,7 +51,6 @@
"jest": {
"collectCoverage": false,
"verbose": true,
"testEnvironment": "node",
"transform": {
"^.+\\.tsx?$": "ts-jest"
},

View File

@ -1 +0,0 @@
jest --env=jsdom ${1:-src}

View File

@ -35,7 +35,8 @@ jest.mock("electron", () => {
app: {
getVersion: () => "99.99.99",
getPath: () => "tmp",
getLocale: () => "en"
getLocale: () => "en",
setLoginItemSettings: jest.fn(),
},
ipcMain: {
handle: jest.fn(),
@ -99,7 +100,7 @@ describe("empty config", () => {
it("removes cluster from store", async () => {
await clusterStore.removeById("foo");
expect(clusterStore.getById("foo")).toBeUndefined();
expect(clusterStore.getById("foo")).toBeNull();
});
it("sets active cluster", () => {
@ -248,7 +249,7 @@ describe("config with existing clusters", () => {
expect(storedCluster).toBeTruthy();
const storedCluster2 = clusterStore.getById("cluster2");
expect(storedCluster2).toBeUndefined();
expect(storedCluster2).toBeNull();
});
it("allows getting all of the clusters", async () => {

View File

@ -1,7 +1,3 @@
/**
* @jest-environment jsdom
*/
import { SearchStore } from "../search-store";
let searchStore: SearchStore = null;

View File

@ -1,3 +1,7 @@
import { Console } from "console";
console = new Console(process.stdout, process.stderr);
import mockFs from "mock-fs";
jest.mock("electron", () => {
@ -5,7 +9,8 @@ jest.mock("electron", () => {
app: {
getVersion: () => "99.99.99",
getPath: () => "tmp",
getLocale: () => "en"
getLocale: () => "en",
setLoginItemSettings: jest.fn(),
}
};
});

View File

@ -5,7 +5,8 @@ jest.mock("electron", () => {
app: {
getVersion: () => "99.99.99",
getPath: () => "tmp",
getLocale: () => "en"
getLocale: () => "en",
setLoginItemSettings: jest.fn(),
},
ipcMain: {
handle: jest.fn(),

View File

@ -19,7 +19,7 @@ export interface BaseStoreParams<T = any> extends ConfOptions<T> {
* Note: T should only contain base JSON serializable types.
*/
export abstract class BaseStore<T = any> extends Singleton {
protected storeConfig: Config<T>;
protected storeConfig?: Config<T>;
protected syncDisposers: Function[] = [];
whenLoaded = when(() => this.isLoaded);
@ -36,7 +36,7 @@ export abstract class BaseStore<T = any> extends Singleton {
}
get name() {
return path.basename(this.storeConfig.path);
return path.basename(this.path);
}
protected get syncRendererChannel() {
@ -48,7 +48,7 @@ export abstract class BaseStore<T = any> extends Singleton {
}
get path() {
return this.storeConfig.path;
return this.storeConfig?.path || "";
}
protected async init() {
@ -82,10 +82,13 @@ export abstract class BaseStore<T = any> extends Singleton {
protected async saveToFile(model: T) {
logger.info(`[STORE]: SAVING ${this.path}`);
// todo: update when fixed https://github.com/sindresorhus/conf/issues/114
Object.entries(model).forEach(([key, value]) => {
this.storeConfig.set(key, value);
});
if (this.storeConfig) {
for (const [key, value] of Object.entries(model)) {
this.storeConfig.set(key, value);
}
}
}
enableSync() {

View File

@ -218,8 +218,12 @@ export class ClusterStore extends BaseStore<ClusterStoreModel> {
}
@action
setActive(id: ClusterId) {
const clusterId = this.clusters.has(id) ? id : null;
setActive(clusterId: ClusterId) {
const cluster = this.clusters.get(clusterId);
if (!cluster?.enabled) {
clusterId = null;
}
this.activeCluster = clusterId;
workspaceStore.setLastActiveClusterId(clusterId);
@ -251,8 +255,8 @@ export class ClusterStore extends BaseStore<ClusterStoreModel> {
return this.clusters.size > 0;
}
getById(id: ClusterId): Cluster {
return this.clusters.get(id);
getById(id: ClusterId): Cluster | null {
return this.clusters.get(id) ?? null;
}
getByWorkspaceId(workspaceId: string): Cluster[] {

View File

@ -25,7 +25,7 @@ export class ClusterStore extends Singleton {
* Set active cluster id
*/
set activeClusterId(id : ClusterId) {
internalClusterStore.activeCluster = id;
internalClusterStore.setActive(id);
}
/**
@ -38,12 +38,8 @@ export class ClusterStore extends Singleton {
/**
* Get active cluster (a cluster which is currently visible)
*/
get activeCluster(): Cluster {
if (!this.activeClusterId) {
return null;
}
return this.getById(this.activeClusterId);
get activeCluster(): Cluster | null {
return internalClusterStore.active;
}
/**
@ -104,7 +100,7 @@ export class ClusterStore extends Singleton {
* @param model cluster
*/
async removeCluster(model: ClusterModel) {
return this.removeById(model.id);
return internalClusterStore.removeById(model.id);
}
/**

View File

@ -4,3 +4,7 @@ fetchMock.enableMocks();
// Mock __non_webpack_require__ for tests
globalThis.__non_webpack_require__ = jest.fn();
process.on("unhandledRejection", (err) => {
fail(err);
});

View File

@ -0,0 +1,40 @@
import { Router } from "../router";
const staticRoot = __dirname;
class TestRouter extends Router {
protected resolveStaticRootPath() {
return staticRoot;
}
}
describe("Router", () => {
it("blocks path traversal attacks", async () => {
const router = new TestRouter();
const res = {
statusCode: 200,
end: jest.fn()
};
await router.handleStaticFile("../index.ts", res as any, {} as any, 0);
expect(res.statusCode).toEqual(404);
});
it("serves files under static root", async () => {
const router = new TestRouter();
const res = {
statusCode: 200,
write: jest.fn(),
setHeader: jest.fn(),
end: jest.fn()
};
const req = {
url: ""
};
await router.handleStaticFile("router.test.ts", res as any, req as any, 0);
expect(res.statusCode).toEqual(200);
});
});

View File

@ -28,7 +28,7 @@ export class LensProxy {
}
listen(port = this.port): this {
this.proxyServer = this.buildCustomProxy().listen(port);
this.proxyServer = this.buildCustomProxy().listen(port, "127.0.0.1");
logger.info(`[LENS-PROXY]: Proxy server has started at ${this.origin}`);
return this;

View File

@ -40,10 +40,16 @@ export interface LensApiRequest<P = any> {
export class Router {
protected router: any;
protected staticRootPath: string;
public constructor() {
this.router = new Call.Router();
this.addRoutes();
this.staticRootPath = this.resolveStaticRootPath();
}
protected resolveStaticRootPath() {
return path.resolve(__static);
}
public async route(cluster: Cluster, req: http.IncomingMessage, res: http.ServerResponse): Promise<boolean> {
@ -102,7 +108,15 @@ export class Router {
}
async handleStaticFile(filePath: string, res: http.ServerResponse, req: http.IncomingMessage, retryCount = 0) {
const asset = path.join(__static, filePath);
const asset = path.join(this.staticRootPath, filePath);
const normalizedFilePath = path.resolve(asset);
if (!normalizedFilePath.startsWith(this.staticRootPath)) {
res.statusCode = 404;
res.end();
return;
}
try {
const filename = path.basename(req.url);

View File

@ -70,7 +70,7 @@ class HelmApiRoute extends LensApi {
this.respondJson(response, result);
} catch (error) {
logger.debug(error);
this.respondText(response, error);
this.respondText(response, error, 422);
}
}
@ -83,7 +83,7 @@ class HelmApiRoute extends LensApi {
this.respondJson(response, result);
} catch(error) {
logger.debug(error);
this.respondText(response, error);
this.respondText(response, error, 422);
}
}

View File

@ -1,6 +1,6 @@
import path from "path";
import packageInfo from "../../package.json";
import { Menu, NativeImage, Tray } from "electron";
import { Menu, Tray } from "electron";
import { autorun } from "mobx";
import { showAbout } from "./menu";
import { checkForUpdates } from "./app-updater";
@ -13,6 +13,8 @@ import logger from "./logger";
import { isDevelopment, isWindows } from "../common/vars";
import { exitApp } from "./exit-app";
const TRAY_LOG_PREFIX = "[TRAY]";
// note: instance of Tray should be saved somewhere, otherwise it disappears
export let tray: Tray;
@ -25,94 +27,92 @@ export function getTrayIcon(): string {
}
export function initTray(windowManager: WindowManager) {
const dispose = autorun(() => {
try {
const menu = createTrayMenu(windowManager);
const icon = getTrayIcon();
buildTray(getTrayIcon(), menu, windowManager);
} catch (err) {
logger.error(`[TRAY]: building failed: ${err}`);
}
});
tray = new Tray(icon);
tray.setToolTip(packageInfo.description);
tray.setIgnoreDoubleClickEvents(true);
if (isWindows) {
tray.on("click", () => {
windowManager
.ensureMainWindow()
.catch(error => logger.error(`${TRAY_LOG_PREFIX}: Failed to open lens`, { error }));
});
}
const disposers = [
autorun(() => {
try {
const menu = createTrayMenu(windowManager);
tray.setContextMenu(menu);
} catch (error) {
logger.error(`${TRAY_LOG_PREFIX}: building failed`, { error });
}
}),
];
return () => {
dispose();
disposers.forEach(disposer => disposer());
tray?.destroy();
tray = null;
};
}
function buildTray(icon: string | NativeImage, menu: Menu, windowManager: WindowManager) {
if (!tray) {
tray = new Tray(icon);
tray.setToolTip(packageInfo.description);
tray.setIgnoreDoubleClickEvents(true);
tray.setImage(icon);
tray.setContextMenu(menu);
if (isWindows) {
tray.on("click", () => {
windowManager.ensureMainWindow();
});
}
}
return tray;
}
function createTrayMenu(windowManager: WindowManager): Menu {
return Menu.buildFromTemplate([
{
label: "Open Lens",
async click() {
await windowManager.ensureMainWindow();
click() {
windowManager
.ensureMainWindow()
.catch(error => logger.error(`${TRAY_LOG_PREFIX}: Failed to open lens`, { error }));
},
},
{
label: "Preferences",
click() {
windowManager.navigate(preferencesURL());
windowManager
.navigate(preferencesURL())
.catch(error => logger.error(`${TRAY_LOG_PREFIX}: Failed to nativate to Preferences`, { error }));
},
},
{
label: "Clusters",
submenu: workspaceStore.enabledWorkspacesList
.filter(workspace => clusterStore.getByWorkspaceId(workspace.id).length > 0) // hide empty workspaces
.map(workspace => {
const clusters = clusterStore.getByWorkspaceId(workspace.id);
return {
label: workspace.name,
toolTip: workspace.description,
submenu: clusters.map(cluster => {
const { id: clusterId, name: label, online, workspace } = cluster;
return {
label: `${online ? "✓" : "\x20".repeat(3)/*offset*/}${label}`,
toolTip: clusterId,
async click() {
workspaceStore.setActive(workspace);
windowManager.navigate(clusterViewURL({ params: { clusterId } }));
}
};
})
};
}),
.map(workspace => [workspace, clusterStore.getByWorkspaceId(workspace.id)] as const)
.map(([workspace, clusters]) => ({
label: workspace.name,
toolTip: workspace.description,
enabled: clusters.length > 0,
submenu: clusters.map(({ id: clusterId, name: label, online, workspace }) => ({
checked: online,
type: "checkbox",
label,
toolTip: clusterId,
click() {
workspaceStore.setActive(workspace);
windowManager
.navigate(clusterViewURL({ params: { clusterId } }))
.catch(error => logger.error(`${TRAY_LOG_PREFIX}: Failed to nativate to cluster`, { clusterId, error }));
}
}))
})),
},
{
label: "Check for updates",
async click() {
await checkForUpdates();
await windowManager.ensureMainWindow();
click() {
checkForUpdates()
.then(() => windowManager.ensureMainWindow());
},
},
{
label: "About Lens",
async click() {
// note: argument[1] (browserWindow) not available when app is not focused / hidden
const browserWindow = await windowManager.ensureMainWindow();
showAbout(browserWindow);
click() {
windowManager.ensureMainWindow()
.then(showAbout)
.catch(error => logger.error(`${TRAY_LOG_PREFIX}: Failed to show Lens About view`, { error }));
},
},
{ type: "separator" },

View File

@ -1,134 +1,115 @@
jest.mock("../kube-object");
jest.mock("../kube-api");
jest.mock("../api-manager", () => ({
apiManager() {
return {
registerStore: jest.fn(),
};
}
}));
import { IKubeApiParsed, parseKubeApi } from "../kube-api-parse";
interface KubeApiParseTestData {
url: string;
expected: Required<IKubeApiParsed>;
}
/**
* [<input-url>, <expected-result>]
*/
type KubeApiParseTestData = [string, Required<IKubeApiParsed>];
const tests: KubeApiParseTestData[] = [
{
url: "/apis/apiextensions.k8s.io/v1beta1/customresourcedefinitions/prometheuses.monitoring.coreos.com",
expected: {
apiBase: "/apis/apiextensions.k8s.io/v1beta1/customresourcedefinitions",
apiPrefix: "/apis",
apiGroup: "apiextensions.k8s.io",
apiVersion: "v1beta1",
apiVersionWithGroup: "apiextensions.k8s.io/v1beta1",
namespace: undefined,
resource: "customresourcedefinitions",
name: "prometheuses.monitoring.coreos.com"
},
},
{
url: "/api/v1/namespaces/kube-system/pods/coredns-6955765f44-v8p27",
expected: {
apiBase: "/api/v1/pods",
apiPrefix: "/api",
apiGroup: "",
apiVersion: "v1",
apiVersionWithGroup: "v1",
namespace: "kube-system",
resource: "pods",
name: "coredns-6955765f44-v8p27"
},
},
{
url: "/apis/stable.example.com/foo1/crontabs",
expected: {
apiBase: "/apis/stable.example.com/foo1/crontabs",
apiPrefix: "/apis",
apiGroup: "stable.example.com",
apiVersion: "foo1",
apiVersionWithGroup: "stable.example.com/foo1",
resource: "crontabs",
name: undefined,
namespace: undefined,
},
},
{
url: "/apis/cluster.k8s.io/v1alpha1/clusters",
expected: {
apiBase: "/apis/cluster.k8s.io/v1alpha1/clusters",
apiPrefix: "/apis",
apiGroup: "cluster.k8s.io",
apiVersion: "v1alpha1",
apiVersionWithGroup: "cluster.k8s.io/v1alpha1",
resource: "clusters",
name: undefined,
namespace: undefined,
},
},
{
url: "/api/v1/namespaces",
expected: {
apiBase: "/api/v1/namespaces",
apiPrefix: "/api",
apiGroup: "",
apiVersion: "v1",
apiVersionWithGroup: "v1",
resource: "namespaces",
name: undefined,
namespace: undefined,
},
},
{
url: "/api/v1/secrets",
expected: {
apiBase: "/api/v1/secrets",
apiPrefix: "/api",
apiGroup: "",
apiVersion: "v1",
apiVersionWithGroup: "v1",
resource: "secrets",
name: undefined,
namespace: undefined,
},
},
{
url: "/api/v1/nodes/minikube",
expected: {
apiBase: "/api/v1/nodes",
apiPrefix: "/api",
apiGroup: "",
apiVersion: "v1",
apiVersionWithGroup: "v1",
resource: "nodes",
name: "minikube",
namespace: undefined,
},
},
{
url: "/api/foo-bar/nodes/minikube",
expected: {
apiBase: "/api/foo-bar/nodes",
apiPrefix: "/api",
apiGroup: "",
apiVersion: "foo-bar",
apiVersionWithGroup: "foo-bar",
resource: "nodes",
name: "minikube",
namespace: undefined,
},
},
{
url: "/api/v1/namespaces/kube-public",
expected: {
apiBase: "/api/v1/namespaces",
apiPrefix: "/api",
apiGroup: "",
apiVersion: "v1",
apiVersionWithGroup: "v1",
resource: "namespaces",
name: "kube-public",
namespace: undefined,
},
},
["/apis/apiextensions.k8s.io/v1beta1/customresourcedefinitions/prometheuses.monitoring.coreos.com", {
apiBase: "/apis/apiextensions.k8s.io/v1beta1/customresourcedefinitions",
apiPrefix: "/apis",
apiGroup: "apiextensions.k8s.io",
apiVersion: "v1beta1",
apiVersionWithGroup: "apiextensions.k8s.io/v1beta1",
namespace: undefined,
resource: "customresourcedefinitions",
name: "prometheuses.monitoring.coreos.com"
}],
["/api/v1/namespaces/kube-system/pods/coredns-6955765f44-v8p27", {
apiBase: "/api/v1/pods",
apiPrefix: "/api",
apiGroup: "",
apiVersion: "v1",
apiVersionWithGroup: "v1",
namespace: "kube-system",
resource: "pods",
name: "coredns-6955765f44-v8p27"
}],
["/apis/stable.example.com/foo1/crontabs", {
apiBase: "/apis/stable.example.com/foo1/crontabs",
apiPrefix: "/apis",
apiGroup: "stable.example.com",
apiVersion: "foo1",
apiVersionWithGroup: "stable.example.com/foo1",
resource: "crontabs",
name: undefined,
namespace: undefined,
}],
["/apis/cluster.k8s.io/v1alpha1/clusters", {
apiBase: "/apis/cluster.k8s.io/v1alpha1/clusters",
apiPrefix: "/apis",
apiGroup: "cluster.k8s.io",
apiVersion: "v1alpha1",
apiVersionWithGroup: "cluster.k8s.io/v1alpha1",
resource: "clusters",
name: undefined,
namespace: undefined,
}],
["/api/v1/namespaces", {
apiBase: "/api/v1/namespaces",
apiPrefix: "/api",
apiGroup: "",
apiVersion: "v1",
apiVersionWithGroup: "v1",
resource: "namespaces",
name: undefined,
namespace: undefined,
}],
["/api/v1/secrets", {
apiBase: "/api/v1/secrets",
apiPrefix: "/api",
apiGroup: "",
apiVersion: "v1",
apiVersionWithGroup: "v1",
resource: "secrets",
name: undefined,
namespace: undefined,
}],
["/api/v1/nodes/minikube", {
apiBase: "/api/v1/nodes",
apiPrefix: "/api",
apiGroup: "",
apiVersion: "v1",
apiVersionWithGroup: "v1",
resource: "nodes",
name: "minikube",
namespace: undefined,
}],
["/api/foo-bar/nodes/minikube", {
apiBase: "/api/foo-bar/nodes",
apiPrefix: "/api",
apiGroup: "",
apiVersion: "foo-bar",
apiVersionWithGroup: "foo-bar",
resource: "nodes",
name: "minikube",
namespace: undefined,
}],
["/api/v1/namespaces/kube-public", {
apiBase: "/api/v1/namespaces",
apiPrefix: "/api",
apiGroup: "",
apiVersion: "v1",
apiVersionWithGroup: "v1",
resource: "namespaces",
name: "kube-public",
namespace: undefined,
}],
];
describe("parseApi unit tests", () => {
for (const { url, expected } of tests) {
test(`testing "${url}"`, () => {
expect(parseKubeApi(url)).toStrictEqual(expected);
});
}
it.each(tests)("testing %s", (url, expected) => {
expect(parseKubeApi(url)).toStrictEqual(expected);
});
});

View File

@ -28,7 +28,7 @@ describe("KubeApi", () => {
};
}
});
const apiBase = "/apis/networking.k8s.io/v1/ingresses";
const fallbackApiBase = "/apis/extensions/v1beta1/ingresses";
const kubeApi = new KubeApi({
@ -36,7 +36,7 @@ describe("KubeApi", () => {
fallbackApiBases: [fallbackApiBase],
checkPreferredVersion: true,
});
await kubeApi.get();
expect(kubeApi.apiPrefix).toEqual("/apis");
expect(kubeApi.apiGroup).toEqual("networking.k8s.io");

View File

@ -0,0 +1,303 @@
import { Pod } from "../endpoints";
interface GetDummyPodOptions {
running?: number;
dead?: number;
initRunning?: number;
initDead?: number;
}
function getDummyPodDefaultOptions(): Required<GetDummyPodOptions> {
return {
running: 0,
dead: 0,
initDead: 0,
initRunning: 0,
};
}
function getDummyPod(opts: GetDummyPodOptions = getDummyPodDefaultOptions()): Pod {
const pod = new Pod({
apiVersion: "v1",
kind: "Pod",
metadata: {
uid: "1",
name: "test",
resourceVersion: "v1",
selfLink: "http"
}
});
pod.spec = {
containers: [],
initContainers: [],
serviceAccount: "dummy",
serviceAccountName: "dummy",
};
pod.status = {
phase: "Running",
conditions: [],
hostIP: "10.0.0.1",
podIP: "10.0.0.1",
startTime: "now",
containerStatuses: [],
initContainerStatuses: [],
};
for (let i = 0; i < opts.running; i += 1) {
const name = `container_r_${i}`;
pod.spec.containers.push({
image: "dummy",
imagePullPolicy: "dummy",
name,
});
pod.status.containerStatuses.push({
image: "dummy",
imageID: "dummy",
name,
ready: true,
restartCount: i,
state: {
running: {
startedAt: "before"
},
}
});
}
for (let i = 0; i < opts.dead; i += 1) {
const name = `container_d_${i}`;
pod.spec.containers.push({
image: "dummy",
imagePullPolicy: "dummy",
name,
});
pod.status.containerStatuses.push({
image: "dummy",
imageID: "dummy",
name,
ready: false,
restartCount: i,
state: {
terminated: {
startedAt: "before",
exitCode: i+1,
finishedAt: "later",
reason: `reason_${i}`
}
}
});
}
for (let i = 0; i < opts.initRunning; i += 1) {
const name = `container_ir_${i}`;
pod.spec.initContainers.push({
image: "dummy",
imagePullPolicy: "dummy",
name,
});
pod.status.initContainerStatuses.push({
image: "dummy",
imageID: "dummy",
name,
ready: true,
restartCount: i,
state: {
running: {
startedAt: "before"
}
}
});
}
for (let i = 0; i < opts.initDead; i += 1) {
const name = `container_id_${i}`;
pod.spec.initContainers.push({
image: "dummy",
imagePullPolicy: "dummy",
name,
});
pod.status.initContainerStatuses.push({
image: "dummy",
imageID: "dummy",
name,
ready: false,
restartCount: i,
state: {
terminated: {
startedAt: "before",
exitCode: i+1,
finishedAt: "later",
reason: `reason_${i}`
}
}
});
}
return pod;
}
describe("Pods", () => {
const podTests = [];
for (let r = 0; r < 10; r += 1) {
for (let d = 0; d < 10; d += 1) {
for (let ir = 0; ir < 10; ir += 1) {
for (let id = 0; id < 10; id += 1) {
podTests.push([r, d, ir, id]);
}
}
}
}
describe.each(podTests)("for [%d running, %d dead] & initial [%d running, %d dead]", (running, dead, initRunning, initDead) => {
const pod = getDummyPod({ running, dead, initRunning, initDead });
function getNamedContainer(name: string) {
return {
image: "dummy",
imagePullPolicy: "dummy",
name
};
}
it("getRunningContainers should return only running and init running", () => {
const res = [
...Array.from(new Array(running), (val, index) => getNamedContainer(`container_r_${index}`)),
...Array.from(new Array(initRunning), (val, index) => getNamedContainer(`container_ir_${index}`)),
];
expect(pod.getRunningContainers()).toStrictEqual(res);
});
it("getAllContainers should return all containers", () => {
const res = [
...Array.from(new Array(running), (val, index) => getNamedContainer(`container_r_${index}`)),
...Array.from(new Array(dead), (val, index) => getNamedContainer(`container_d_${index}`)),
...Array.from(new Array(initRunning), (val, index) => getNamedContainer(`container_ir_${index}`)),
...Array.from(new Array(initDead), (val, index) => getNamedContainer(`container_id_${index}`)),
];
expect(pod.getAllContainers()).toStrictEqual(res);
});
it("getRestartsCount should return total restart counts", () => {
function sum(len: number): number {
let res = 0;
for (let i = 0; i < len; i += 1) {
res += i;
}
return res;
}
expect(pod.getRestartsCount()).toStrictEqual(sum(running) + sum(dead));
});
it("hasIssues should return false", () => {
expect(pod.hasIssues()).toStrictEqual(false);
});
});
describe("getSelectedNodeOs", () => {
it("should return stable", () => {
const pod = getDummyPod();
pod.spec.nodeSelector = {
"kubernetes.io/os": "foobar"
};
expect(pod.getSelectedNodeOs()).toStrictEqual("foobar");
});
it("should return beta", () => {
const pod = getDummyPod();
pod.spec.nodeSelector = {
"beta.kubernetes.io/os": "foobar1"
};
expect(pod.getSelectedNodeOs()).toStrictEqual("foobar1");
});
it("should return stable over beta", () => {
const pod = getDummyPod();
pod.spec.nodeSelector = {
"kubernetes.io/os": "foobar2",
"beta.kubernetes.io/os": "foobar3"
};
expect(pod.getSelectedNodeOs()).toStrictEqual("foobar2");
});
it("should return undefined if none set", () => {
const pod = getDummyPod();
expect(pod.getSelectedNodeOs()).toStrictEqual(undefined);
});
});
describe("hasIssues", () => {
it("should return true if a condition isn't ready", () => {
const pod = getDummyPod({ running: 1 });
pod.status.conditions.push({
type: "Ready",
status: "foobar",
lastProbeTime: 1,
lastTransitionTime: "longer ago"
});
expect(pod.hasIssues()).toStrictEqual(true);
});
it("should return false if a condition is non-ready", () => {
const pod = getDummyPod({ running: 1 });
pod.status.conditions.push({
type: "dummy",
status: "foobar",
lastProbeTime: 1,
lastTransitionTime: "longer ago"
});
expect(pod.hasIssues()).toStrictEqual(false);
});
it("should return true if a current container is in a crash loop back off", () => {
const pod = getDummyPod({ running: 1 });
pod.status.containerStatuses[0].state = {
waiting: {
reason: "CrashLookBackOff",
message: "too much foobar"
}
};
expect(pod.hasIssues()).toStrictEqual(true);
});
it("should return true if a current phase isn't running", () => {
const pod = getDummyPod({ running: 1 });
pod.status.phase = "not running";
expect(pod.hasIssues()).toStrictEqual(true);
});
it("should return false if a current phase is running", () => {
const pod = getDummyPod({ running: 1 });
pod.status.phase = "Running";
expect(pod.hasIssues()).toStrictEqual(false);
});
});
});

View File

@ -270,60 +270,55 @@ export class Pod extends WorkloadKubeObject {
}
getAllContainers() {
return this.getContainers().concat(this.getInitContainers());
return [...this.getContainers(), ...this.getInitContainers()];
}
getRunningContainers() {
const statuses = this.getContainerStatuses();
return this.getAllContainers().filter(container => {
return statuses.find(status => status.name === container.name && !!status.state["running"]);
}
const runningContainerNames = new Set(
this.getContainerStatuses()
.filter(({ state }) => state.running)
.map(({ name }) => name)
);
return this.getAllContainers()
.filter(({ name }) => runningContainerNames.has(name));
}
getContainerStatuses(includeInitContainers = true) {
const statuses: IPodContainerStatus[] = [];
const { containerStatuses, initContainerStatuses } = this.status;
const { containerStatuses = [], initContainerStatuses = [] } = this.status ?? {};
if (containerStatuses) {
statuses.push(...containerStatuses);
if (includeInitContainers) {
return [...containerStatuses, ...initContainerStatuses];
}
if (includeInitContainers && initContainerStatuses) {
statuses.push(...initContainerStatuses);
}
return statuses;
return [...containerStatuses];
}
getRestartsCount(): number {
const { containerStatuses } = this.status;
const { containerStatuses = [] } = this.status ?? {};
if (!containerStatuses) return 0;
return containerStatuses.reduce((count, item) => count + item.restartCount, 0);
return containerStatuses.reduce((totalCount, { restartCount }) => totalCount + restartCount, 0);
}
getQosClass() {
return this.status.qosClass || "";
return this.status?.qosClass || "";
}
getReason() {
return this.status.reason || "";
return this.status?.reason || "";
}
getPriorityClassName() {
return this.spec.priorityClassName || "";
}
// Returns one of 5 statuses: Running, Succeeded, Pending, Failed, Evicted
getStatus() {
getStatus(): PodStatus {
const phase = this.getStatusPhase();
const reason = this.getReason();
const goodConditions = ["Initialized", "Ready"].every(condition =>
!!this.getConditions().find(item => item.type === condition && item.status === "True")
);
const trueConditionTypes = new Set(this.getConditions()
.filter(({ status }) => status === "True")
.map(({ type }) => type));
const isInGoodCondition = ["Initialized", "Ready"].every(condition => trueConditionTypes.has(condition));
if (reason === PodStatus.EVICTED) {
return PodStatus.EVICTED;
@ -337,7 +332,7 @@ export class Pod extends WorkloadKubeObject {
return PodStatus.SUCCEEDED;
}
if (phase === PodStatus.RUNNING && goodConditions) {
if (phase === PodStatus.RUNNING && isInGoodCondition) {
return PodStatus.RUNNING;
}
@ -349,37 +344,27 @@ export class Pod extends WorkloadKubeObject {
if (this.getReason() === PodStatus.EVICTED) return "Evicted";
if (this.getStatus() === PodStatus.RUNNING && this.metadata.deletionTimestamp) return "Terminating";
let message = "";
const statuses = this.getContainerStatuses(false); // not including initContainers
if (statuses.length) {
statuses.forEach(status => {
const { state } = status;
for (const { state } of statuses.reverse()) {
if (state.waiting) {
return state.waiting.reason || "Waiting";
}
if (state.waiting) {
const { reason } = state.waiting;
message = reason ? reason : "Waiting";
}
if (state.terminated) {
const { reason } = state.terminated;
message = reason ? reason : "Terminated";
}
});
if (state.terminated) {
return state.terminated.reason || "Terminated";
}
}
if (message) return message;
return this.getStatusPhase();
}
getStatusPhase() {
return this.status.phase;
return this.status?.phase;
}
getConditions() {
return this.status.conditions || [];
return this.status?.conditions || [];
}
getVolumes() {
@ -393,9 +378,7 @@ export class Pod extends WorkloadKubeObject {
}
getNodeSelectors(): string[] {
const { nodeSelector } = this.spec;
if (!nodeSelector) return [];
const { nodeSelector = {} } = this.spec;
return Object.entries(nodeSelector).map(values => values.join(": "));
}
@ -409,20 +392,19 @@ export class Pod extends WorkloadKubeObject {
}
hasIssues() {
const notReady = !!this.getConditions().find(condition => {
return condition.type == "Ready" && condition.status !== "True";
});
const crashLoop = !!this.getContainerStatuses().find(condition => {
const waiting = condition.state.waiting;
for (const { type, status } of this.getConditions()) {
if (type === "Ready" && status !== "True") {
return true;
}
}
return (waiting && waiting.reason == "CrashLoopBackOff");
});
for (const { state } of this.getContainerStatuses()) {
if (state?.waiting?.reason === "CrashLookBackOff") {
return true;
}
}
return (
notReady ||
crashLoop ||
this.getStatusPhase() !== "Running"
);
return this.getStatusPhase() !== "Running";
}
getLivenessProbe(container: IPodContainer) {
@ -476,14 +458,11 @@ export class Pod extends WorkloadKubeObject {
}
getNodeName() {
return this.spec?.nodeName;
return this.spec.nodeName;
}
getSelectedNodeOs() {
if (!this.spec.nodeSelector) return;
if (!this.spec.nodeSelector["kubernetes.io/os"] && !this.spec.nodeSelector["beta.kubernetes.io/os"]) return;
return this.spec.nodeSelector["kubernetes.io/os"] || this.spec.nodeSelector["beta.kubernetes.io/os"];
getSelectedNodeOs(): string | undefined {
return this.spec.nodeSelector?.["kubernetes.io/os"] || this.spec.nodeSelector?.["beta.kubernetes.io/os"];
}
}

View File

@ -6,6 +6,7 @@ import { ItemStore } from "../../item.store";
import { Secret } from "../../api/endpoints";
import { secretsStore } from "../+config-secrets/secrets.store";
import { namespaceStore } from "../+namespaces/namespace.store";
import { Notifications } from "../notifications";
@autobind()
export class ReleaseStore extends ItemStore<HelmRelease> {
@ -67,7 +68,11 @@ export class ReleaseStore extends ItemStore<HelmRelease> {
this.items.replace(this.sortItems(items));
this.isLoaded = true;
} catch (error) {
console.error(`Loading Helm Chart releases has failed: ${error}`);
console.error("Loading Helm Chart releases has failed", error);
if (error.error) {
Notifications.error(error.error);
}
} finally {
this.isLoading = false;
}

View File

@ -3,6 +3,7 @@ import { Cluster } from "../../../main/cluster";
import { clusterStore } from "../../../common/cluster-store";
import { ItemObject, ItemStore } from "../../item.store";
import { autobind } from "../../utils";
import { computed, reaction } from "mobx";
export class ClusterItem implements ItemObject {
constructor(public cluster: Cluster) {}
@ -51,15 +52,21 @@ export class WorkspaceClusterStore extends ItemStore<ClusterItem> {
this.workspaceId = workspaceId;
}
@computed get clusters(): ClusterItem[] {
return clusterStore
.getByWorkspaceId(this.workspaceId)
.filter(cluster => cluster.enabled)
.map(cluster => new ClusterItem(cluster));
}
watch() {
return reaction(() => this.clusters, () => this.loadAll(), {
fireImmediately: true
});
}
loadAll() {
return this.loadItems(
() => (
clusterStore
.getByWorkspaceId(this.workspaceId)
.filter(cluster => cluster.enabled)
.map(cluster => new ClusterItem(cluster))
)
);
return this.loadItems(() => this.clusters);
}
async remove(clusterItem: ClusterItem) {

View File

@ -1,7 +1,7 @@
import "./workspace-overview.scss";
import React, { Component } from "react";
import { disposeOnUnmount, observer } from "mobx-react";
import { observer } from "mobx-react";
import { ItemListLayout } from "../item-object-list/item-list-layout";
import { ClusterItem, WorkspaceClusterStore } from "./workspace-cluster.store";
import { navigate } from "../../navigation";
@ -9,7 +9,7 @@ import { clusterViewURL } from "../cluster-manager/cluster-view.route";
import { WorkspaceClusterMenu } from "./workspace-cluster-menu";
import { kebabCase } from "lodash";
import { addClusterURL } from "../+add-cluster";
import { observable, reaction } from "mobx";
import { IReactionDisposer, observable, reaction } from "mobx";
import { workspaceStore } from "../../../common/workspace-store";
enum sortBy {
@ -23,15 +23,22 @@ enum sortBy {
export class WorkspaceOverview extends Component {
@observable private workspaceClusterStore?: WorkspaceClusterStore;
disposeWorkspaceWatch: IReactionDisposer;
disposeClustersWatch: IReactionDisposer;
componentDidMount() {
disposeOnUnmount(this, [
reaction(() => workspaceStore.currentWorkspaceId, workspaceId => {
this.workspaceClusterStore = new WorkspaceClusterStore(workspaceId);
this.workspaceClusterStore.loadAll().catch(error => console.log("workspaceClusterStore.loadAll", error));
}, {
fireImmediately: true,
})
]);
this.disposeWorkspaceWatch = reaction(() => workspaceStore.currentWorkspaceId, workspaceId => {
this.workspaceClusterStore = new WorkspaceClusterStore(workspaceId);
this.disposeClustersWatch?.();
this.disposeClustersWatch = this.workspaceClusterStore.watch();
}, {
fireImmediately: true,
});
}
componentWillUnmount() {
this.disposeWorkspaceWatch?.();
this.disposeClustersWatch?.();
}
showCluster = ({ clusterId }: ClusterItem) => {

View File

@ -1,7 +1,3 @@
/**
* @jest-environment jsdom
*/
import React from "react";
import "@testing-library/jest-dom/extend-expect";
import { fireEvent, render } from "@testing-library/react";

View File

@ -1,7 +1,3 @@
/**
* @jest-environment jsdom
*/
import React from "react";
import "@testing-library/jest-dom/extend-expect";
import { render } from "@testing-library/react";

View File

@ -1,7 +1,3 @@
/**
* @jest-environment jsdom
*/
import { podsStore } from "../../+workloads-pods/pods.store";
import { Pod } from "../../../api/endpoints";
import { dockStore } from "../dock.store";

View File

@ -26,7 +26,6 @@ interface SidebarItemProps {
* this item should be shown as active
*/
isActive?: boolean;
subMenus?: React.ReactNode | React.ComponentType<SidebarItemProps>[];
}
@observer
@ -53,11 +52,9 @@ export class SidebarItem extends React.Component<SidebarItemProps> {
}
@computed get isExpandable(): boolean {
if (this.compact) {
return false; // not available currently
}
if (this.compact) return false; // not available in compact-mode currently
return Boolean(this.props.subMenus || this.props.children);
return Boolean(this.props.children);
}
toggleExpand = () => {
@ -66,8 +63,22 @@ export class SidebarItem extends React.Component<SidebarItemProps> {
});
};
renderSubMenu() {
const { isExpandable, expanded, isActive } = this;
if (!isExpandable || !expanded) {
return;
}
return (
<ul className={cssNames("sub-menu", { active: isActive })}>
{this.props.children}
</ul>
);
}
render() {
const { isHidden, icon, text, children, url, className, subMenus } = this.props;
const { isHidden, icon, text, url, className } = this.props;
if (isHidden) return null;
@ -90,12 +101,7 @@ export class SidebarItem extends React.Component<SidebarItemProps> {
material={expanded ? "keyboard_arrow_up" : "keyboard_arrow_down"}
/>}
</NavLink>
{isExpandable && expanded && (
<ul className={cssNames("sub-menu", { active: isActive })}>
{subMenus}
{children}
</ul>
)}
{this.renderSubMenu()}
</div>
);
}

View File

@ -51,25 +51,20 @@ export class Sidebar extends React.Component<Props> {
}
return Object.entries(crdStore.groups).map(([group, crds]) => {
const crdGroupSubMenu: React.ReactNode = crds.map((crd) => {
return (
<SidebarItem
key={crd.getResourceApiBase()}
id={`crd-resource:${crd.getResourceApiBase()}`}
url={crd.getResourceUrl()}
text={crd.getResourceTitle()}
/>
);
});
const id = `crd-group:${group}`;
const crdGroupsPageUrl = crdURL({ query: { groups: group } });
return (
<SidebarItem
key={group}
text={group}
id={`crd-group:${group}`}
url={crdURL({ query: { groups: group } })}
subMenus={crdGroupSubMenu}
/>
<SidebarItem key={id} id={id} text={group} url={crdGroupsPageUrl}>
{crds.map((crd) => (
<SidebarItem
key={crd.getResourceApiBase()}
id={`crd-resource:${crd.getResourceApiBase()}`}
url={crd.getResourceUrl()}
text={crd.getResourceTitle()}
/>
))}
</SidebarItem>
);
});
}
@ -147,8 +142,9 @@ export class Sidebar extends React.Component<Props> {
isActive={isActive}
text={menuItem.title}
icon={<menuItem.components.Icon/>}
subMenus={this.renderTreeFromTabRoutes(tabRoutes)}
/>
>
{this.renderTreeFromTabRoutes(tabRoutes)}
</SidebarItem>
);
});
}
@ -175,88 +171,94 @@ export class Sidebar extends React.Component<Props> {
<div className={cssNames("sidebar-nav flex column box grow-fixed", { compact })}>
<SidebarItem
id="cluster"
text="Cluster"
isActive={isActiveRoute(clusterRoute)}
isHidden={!isAllowedResource("nodes")}
url={clusterURL()}
text="Cluster"
icon={<Icon svg="kube"/>}
/>
<SidebarItem
id="nodes"
text="Nodes"
isActive={isActiveRoute(nodesRoute)}
isHidden={!isAllowedResource("nodes")}
url={nodesURL()}
text="Nodes"
icon={<Icon svg="nodes"/>}
/>
<SidebarItem
id="workloads"
text="Workloads"
isActive={isActiveRoute(workloadsRoute)}
isHidden={Workloads.tabRoutes.length == 0}
url={workloadsURL({ query })}
subMenus={this.renderTreeFromTabRoutes(Workloads.tabRoutes)}
text="Workloads"
icon={<Icon svg="workloads"/>}
/>
>
{this.renderTreeFromTabRoutes(Workloads.tabRoutes)}
</SidebarItem>
<SidebarItem
id="config"
text="Configuration"
isActive={isActiveRoute(configRoute)}
isHidden={Config.tabRoutes.length == 0}
url={configURL({ query })}
subMenus={this.renderTreeFromTabRoutes(Config.tabRoutes)}
text="Configuration"
icon={<Icon material="list"/>}
/>
>
{this.renderTreeFromTabRoutes(Config.tabRoutes)}
</SidebarItem>
<SidebarItem
id="networks"
text="Network"
isActive={isActiveRoute(networkRoute)}
isHidden={Network.tabRoutes.length == 0}
url={networkURL({ query })}
subMenus={this.renderTreeFromTabRoutes(Network.tabRoutes)}
text="Network"
icon={<Icon material="device_hub"/>}
/>
>
{this.renderTreeFromTabRoutes(Network.tabRoutes)}
</SidebarItem>
<SidebarItem
id="storage"
text="Storage"
isActive={isActiveRoute(storageRoute)}
isHidden={Storage.tabRoutes.length == 0}
url={storageURL({ query })}
subMenus={this.renderTreeFromTabRoutes(Storage.tabRoutes)}
icon={<Icon svg="storage"/>}
text="Storage"
/>
>
{this.renderTreeFromTabRoutes(Storage.tabRoutes)}
</SidebarItem>
<SidebarItem
id="namespaces"
text="Namespaces"
isActive={isActiveRoute(namespacesRoute)}
isHidden={!isAllowedResource("namespaces")}
url={namespacesURL()}
icon={<Icon material="layers"/>}
text="Namespaces"
/>
<SidebarItem
id="events"
text="Events"
isActive={isActiveRoute(eventRoute)}
isHidden={!isAllowedResource("events")}
url={eventsURL({ query })}
icon={<Icon material="access_time"/>}
text="Events"
/>
<SidebarItem
id="apps"
text="Apps" // helm charts
isActive={isActiveRoute(appsRoute)}
url={appsURL({ query })}
subMenus={this.renderTreeFromTabRoutes(Apps.tabRoutes)}
icon={<Icon material="apps"/>}
text="Apps"
/>
>
{this.renderTreeFromTabRoutes(Apps.tabRoutes)}
</SidebarItem>
<SidebarItem
id="users"
text="Access Control"
isActive={isActiveRoute(usersManagementRoute)}
url={usersManagementURL({ query })}
subMenus={this.renderTreeFromTabRoutes(UserManagement.tabRoutes)}
icon={<Icon material="security"/>}
text="Access Control"
/>
>
{this.renderTreeFromTabRoutes(UserManagement.tabRoutes)}
</SidebarItem>
<SidebarItem
id="custom-resources"
text="Custom Resources"

View File

@ -2,7 +2,7 @@
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.2 (current version)
## 4.2.0-rc.3 (current version)
- Add lens:// protocol handling with a routing mechanism
- Add common app routes to the protocol renderer router from the documentation
@ -33,6 +33,10 @@ Here you can find description of changes we've built into each release. While we
- Fix: Closing workspace menu after clicking on iframe
- Fix: extension global pages are never able to be visible
- Fix: recreate proxy kubeconfig if it is deleted
- Fix: Proxy should listen only on loopback device
- Fix: Block global path traversal in router
- Fix: Set initial cursor position for the editor to beginning
- Fix: Highlight sidebar's active section
## 4.1.4