diff --git a/Makefile b/Makefile index ea7c84d018..658be09690 100644 --- a/Makefile +++ b/Makefile @@ -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) diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000000..b1a54ec9d0 --- /dev/null +++ b/SECURITY.md @@ -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. diff --git a/integration/__tests__/app.tests.ts b/integration/__tests__/app.tests.ts index af029a23e9..cf03bccf7c 100644 --- a/integration/__tests__/app.tests.ts +++ b/integration/__tests__/app.tests.ts @@ -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); } diff --git a/integration/__tests__/cluster-pages.tests.ts b/integration/__tests__/cluster-pages.tests.ts index 510110707e..ed2340f86f 100644 --- a/integration/__tests__/cluster-pages.tests.ts +++ b/integration/__tests__/cluster-pages.tests.ts @@ -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); } }); diff --git a/integration/__tests__/command-palette.tests.ts b/integration/__tests__/command-palette.tests.ts index 789806c445..08da064bd2 100644 --- a/integration/__tests__/command-palette.tests.ts +++ b/integration/__tests__/command-palette.tests.ts @@ -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); } diff --git a/integration/__tests__/workspace.tests.ts b/integration/__tests__/workspace.tests.ts index 4164151b0f..6ad56d5255 100644 --- a/integration/__tests__/workspace.tests.ts +++ b/integration/__tests__/workspace.tests.ts @@ -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); } }); diff --git a/integration/helpers/utils.ts b/integration/helpers/utils.ts index 1db9af1c4d..c7fd4e8ddb 100644 --- a/integration/helpers/utils.ts +++ b/integration/helpers/utils.ts @@ -8,6 +8,39 @@ const AppPaths: Partial> = { "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): (done: DoneCallback) => void { + return function (done: DoneCallback) { + fn() + .then(() => done()) + .catch(error => done.fail(error)); + }; +} + +export function beforeAllWrapped(fn: () => Promise): void { + beforeAll(wrapJestLifecycle(fn)); +} + +export function beforeEachWrapped(fn: () => Promise): void { + beforeEach(wrapJestLifecycle(fn)); +} + +export function afterAllWrapped(fn: () => Promise): void { + afterAll(wrapJestLifecycle(fn)); +} + +export function afterEachWrapped(fn: () => Promise): void { + afterEach(wrapJestLifecycle(fn)); +} + export function itIf(condition: boolean) { return condition ? it : it.skip; } diff --git a/package.json b/package.json index e346f8c11f..b6b1fd1ea5 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/scripts/test.sh b/scripts/test.sh deleted file mode 100755 index 19c1f71c47..0000000000 --- a/scripts/test.sh +++ /dev/null @@ -1 +0,0 @@ -jest --env=jsdom ${1:-src} diff --git a/src/common/__tests__/cluster-store.test.ts b/src/common/__tests__/cluster-store.test.ts index d1d2f76603..e9ee4ee486 100644 --- a/src/common/__tests__/cluster-store.test.ts +++ b/src/common/__tests__/cluster-store.test.ts @@ -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 () => { diff --git a/src/common/__tests__/search-store.test.ts b/src/common/__tests__/search-store.test.ts index 27d52971de..6193863192 100644 --- a/src/common/__tests__/search-store.test.ts +++ b/src/common/__tests__/search-store.test.ts @@ -1,7 +1,3 @@ -/** - * @jest-environment jsdom - */ - import { SearchStore } from "../search-store"; let searchStore: SearchStore = null; diff --git a/src/common/__tests__/user-store.test.ts b/src/common/__tests__/user-store.test.ts index b74941a790..1715a1a593 100644 --- a/src/common/__tests__/user-store.test.ts +++ b/src/common/__tests__/user-store.test.ts @@ -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(), } }; }); diff --git a/src/common/__tests__/workspace-store.test.ts b/src/common/__tests__/workspace-store.test.ts index 355eb8b2ce..ae9538ead3 100644 --- a/src/common/__tests__/workspace-store.test.ts +++ b/src/common/__tests__/workspace-store.test.ts @@ -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(), diff --git a/src/common/base-store.ts b/src/common/base-store.ts index f7ad946bfd..91d3ef8312 100644 --- a/src/common/base-store.ts +++ b/src/common/base-store.ts @@ -19,7 +19,7 @@ export interface BaseStoreParams extends ConfOptions { * Note: T should only contain base JSON serializable types. */ export abstract class BaseStore extends Singleton { - protected storeConfig: Config; + protected storeConfig?: Config; protected syncDisposers: Function[] = []; whenLoaded = when(() => this.isLoaded); @@ -36,7 +36,7 @@ export abstract class BaseStore 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 extends Singleton { } get path() { - return this.storeConfig.path; + return this.storeConfig?.path || ""; } protected async init() { @@ -82,10 +82,13 @@ export abstract class BaseStore 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() { diff --git a/src/common/cluster-store.ts b/src/common/cluster-store.ts index d1006f0a3d..4ac0ad9bdf 100644 --- a/src/common/cluster-store.ts +++ b/src/common/cluster-store.ts @@ -218,8 +218,12 @@ export class ClusterStore extends BaseStore { } @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 { 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[] { diff --git a/src/extensions/stores/cluster-store.ts b/src/extensions/stores/cluster-store.ts index b2aba41c06..c1a18c453b 100644 --- a/src/extensions/stores/cluster-store.ts +++ b/src/extensions/stores/cluster-store.ts @@ -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); } /** diff --git a/src/jest.setup.ts b/src/jest.setup.ts index ef6565c907..d8c6ce9161 100644 --- a/src/jest.setup.ts +++ b/src/jest.setup.ts @@ -4,3 +4,7 @@ fetchMock.enableMocks(); // Mock __non_webpack_require__ for tests globalThis.__non_webpack_require__ = jest.fn(); + +process.on("unhandledRejection", (err) => { + fail(err); +}); diff --git a/src/main/__test__/router.test.ts b/src/main/__test__/router.test.ts new file mode 100644 index 0000000000..8c2fa9c822 --- /dev/null +++ b/src/main/__test__/router.test.ts @@ -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); + }); +}); diff --git a/src/main/lens-proxy.ts b/src/main/lens-proxy.ts index 7e1aa98b7e..0bc3528a33 100644 --- a/src/main/lens-proxy.ts +++ b/src/main/lens-proxy.ts @@ -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; diff --git a/src/main/router.ts b/src/main/router.ts index bb49aacdab..6fa14e1444 100644 --- a/src/main/router.ts +++ b/src/main/router.ts @@ -40,10 +40,16 @@ export interface LensApiRequest

{ 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 { @@ -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); diff --git a/src/main/routes/helm-route.ts b/src/main/routes/helm-route.ts index 4d1cae8bc2..c3c0bdb353 100644 --- a/src/main/routes/helm-route.ts +++ b/src/main/routes/helm-route.ts @@ -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); } } diff --git a/src/main/tray.ts b/src/main/tray.ts index 3d6d2dd624..b9174977ae 100644 --- a/src/main/tray.ts +++ b/src/main/tray.ts @@ -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" }, diff --git a/src/renderer/api/__tests__/kube-api-parse.test.ts b/src/renderer/api/__tests__/kube-api-parse.test.ts index c2aec7fd58..bc4528ad4e 100644 --- a/src/renderer/api/__tests__/kube-api-parse.test.ts +++ b/src/renderer/api/__tests__/kube-api-parse.test.ts @@ -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; -} +/** + * [, ] + */ +type KubeApiParseTestData = [string, Required]; 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); + }); }); diff --git a/src/renderer/api/__tests__/kube-api.test.ts b/src/renderer/api/__tests__/kube-api.test.ts index 7481bd096a..9d3c41869d 100644 --- a/src/renderer/api/__tests__/kube-api.test.ts +++ b/src/renderer/api/__tests__/kube-api.test.ts @@ -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"); diff --git a/src/renderer/api/__tests__/pods.test.ts b/src/renderer/api/__tests__/pods.test.ts new file mode 100644 index 0000000000..99cf95bf7f --- /dev/null +++ b/src/renderer/api/__tests__/pods.test.ts @@ -0,0 +1,303 @@ +import { Pod } from "../endpoints"; + +interface GetDummyPodOptions { + running?: number; + dead?: number; + initRunning?: number; + initDead?: number; +} + +function getDummyPodDefaultOptions(): Required { + 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); + }); + }); +}); diff --git a/src/renderer/api/endpoints/pods.api.ts b/src/renderer/api/endpoints/pods.api.ts index 3176cae4d2..5bf319fbe8 100644 --- a/src/renderer/api/endpoints/pods.api.ts +++ b/src/renderer/api/endpoints/pods.api.ts @@ -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"]; } } diff --git a/src/renderer/components/+apps-releases/release.store.ts b/src/renderer/components/+apps-releases/release.store.ts index 559a90eb01..59e8d9a5bc 100644 --- a/src/renderer/components/+apps-releases/release.store.ts +++ b/src/renderer/components/+apps-releases/release.store.ts @@ -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 { @@ -67,7 +68,11 @@ export class ReleaseStore extends ItemStore { 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; } diff --git a/src/renderer/components/+landing-page/workspace-cluster.store.ts b/src/renderer/components/+landing-page/workspace-cluster.store.ts index 24834927b3..8fd2966b93 100644 --- a/src/renderer/components/+landing-page/workspace-cluster.store.ts +++ b/src/renderer/components/+landing-page/workspace-cluster.store.ts @@ -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 { 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) { diff --git a/src/renderer/components/+landing-page/workspace-overview.tsx b/src/renderer/components/+landing-page/workspace-overview.tsx index 3fcb3d6ed3..bb95f5d336 100644 --- a/src/renderer/components/+landing-page/workspace-overview.tsx +++ b/src/renderer/components/+landing-page/workspace-overview.tsx @@ -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) => { diff --git a/src/renderer/components/+workloads-pods/__tests__/pod-tolerations.test.tsx b/src/renderer/components/+workloads-pods/__tests__/pod-tolerations.test.tsx index dbde813e5a..8071254940 100644 --- a/src/renderer/components/+workloads-pods/__tests__/pod-tolerations.test.tsx +++ b/src/renderer/components/+workloads-pods/__tests__/pod-tolerations.test.tsx @@ -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"; diff --git a/src/renderer/components/dock/__test__/log-resource-selector.test.tsx b/src/renderer/components/dock/__test__/log-resource-selector.test.tsx index 22d97b7216..d373d0fbab 100644 --- a/src/renderer/components/dock/__test__/log-resource-selector.test.tsx +++ b/src/renderer/components/dock/__test__/log-resource-selector.test.tsx @@ -1,7 +1,3 @@ -/** - * @jest-environment jsdom - */ - import React from "react"; import "@testing-library/jest-dom/extend-expect"; import { render } from "@testing-library/react"; diff --git a/src/renderer/components/dock/__test__/log-tab.store.test.ts b/src/renderer/components/dock/__test__/log-tab.store.test.ts index 79b93af623..e99022070b 100644 --- a/src/renderer/components/dock/__test__/log-tab.store.test.ts +++ b/src/renderer/components/dock/__test__/log-tab.store.test.ts @@ -1,7 +1,3 @@ -/** - * @jest-environment jsdom - */ - import { podsStore } from "../../+workloads-pods/pods.store"; import { Pod } from "../../../api/endpoints"; import { dockStore } from "../dock.store"; diff --git a/src/renderer/components/layout/sidebar-item.tsx b/src/renderer/components/layout/sidebar-item.tsx index da36b43c17..64956842aa 100644 --- a/src/renderer/components/layout/sidebar-item.tsx +++ b/src/renderer/components/layout/sidebar-item.tsx @@ -26,7 +26,6 @@ interface SidebarItemProps { * this item should be shown as active */ isActive?: boolean; - subMenus?: React.ReactNode | React.ComponentType[]; } @observer @@ -53,11 +52,9 @@ export class SidebarItem extends React.Component { } @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 { }); }; + renderSubMenu() { + const { isExpandable, expanded, isActive } = this; + + if (!isExpandable || !expanded) { + return; + } + + return ( +

    + {this.props.children} +
+ ); + } + 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 { material={expanded ? "keyboard_arrow_up" : "keyboard_arrow_down"} />} - {isExpandable && expanded && ( -
    - {subMenus} - {children} -
- )} + {this.renderSubMenu()} ); } diff --git a/src/renderer/components/layout/sidebar.tsx b/src/renderer/components/layout/sidebar.tsx index 522e06674c..531c9db1a0 100644 --- a/src/renderer/components/layout/sidebar.tsx +++ b/src/renderer/components/layout/sidebar.tsx @@ -51,25 +51,20 @@ export class Sidebar extends React.Component { } return Object.entries(crdStore.groups).map(([group, crds]) => { - const crdGroupSubMenu: React.ReactNode = crds.map((crd) => { - return ( - - ); - }); + const id = `crd-group:${group}`; + const crdGroupsPageUrl = crdURL({ query: { groups: group } }); return ( - + + {crds.map((crd) => ( + + ))} + ); }); } @@ -147,8 +142,9 @@ export class Sidebar extends React.Component { isActive={isActive} text={menuItem.title} icon={} - subMenus={this.renderTreeFromTabRoutes(tabRoutes)} - /> + > + {this.renderTreeFromTabRoutes(tabRoutes)} + ); }); } @@ -175,88 +171,94 @@ export class Sidebar extends React.Component {
} /> } /> } - /> + > + {this.renderTreeFromTabRoutes(Workloads.tabRoutes)} + } - /> + > + {this.renderTreeFromTabRoutes(Config.tabRoutes)} + } - /> + > + {this.renderTreeFromTabRoutes(Network.tabRoutes)} + } - text="Storage" - /> + > + {this.renderTreeFromTabRoutes(Storage.tabRoutes)} + } - text="Namespaces" /> } - text="Events" /> } - text="Apps" - /> + > + {this.renderTreeFromTabRoutes(Apps.tabRoutes)} + } - text="Access Control" - /> + > + {this.renderTreeFromTabRoutes(UserManagement.tabRoutes)} +