diff --git a/.eslintrc.js b/.eslintrc.js index 3fda195286..9a1ef03b6f 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -11,6 +11,7 @@ module.exports = { "**/dist/**/*", "**/static/**/*", "**/site/**/*", + "**/__mocks__/**/*", ], settings: { react: { @@ -109,12 +110,15 @@ module.exports = { ], parserOptions: { ecmaVersion: 2018, + tsconfigRootDir: __dirname, + project: ["./tsconfig.json"], sourceType: "module", }, rules: { "no-constant-condition": ["error", { "checkLoops": false }], "header/header": [2, "./license-header"], "no-invalid-this": "off", + "@typescript-eslint/await-thenable": "error", "@typescript-eslint/no-invalid-this": ["error"], "@typescript-eslint/explicit-function-return-type": "off", "@typescript-eslint/no-explicit-any": "off", @@ -129,6 +133,7 @@ module.exports = { "named": "never", "asyncArrow": "always", }], + "require-await": "error", "unused-imports/no-unused-imports-ts": process.env.PROD === "true" ? "error" : "warn", "unused-imports/no-unused-vars-ts": [ "warn", { @@ -193,7 +198,9 @@ module.exports = { ], parserOptions: { ecmaVersion: 2018, + tsconfigRootDir: __dirname, sourceType: "module", + project: ["./tsconfig.json"], jsx: true, }, rules: { @@ -201,6 +208,7 @@ module.exports = { "header/header": [2, "./license-header"], "react/prop-types": "off", "no-invalid-this": "off", + "@typescript-eslint/await-thenable": "error", "@typescript-eslint/no-invalid-this": ["error"], "@typescript-eslint/explicit-function-return-type": "off", "@typescript-eslint/no-explicit-any": "off", @@ -220,6 +228,7 @@ module.exports = { "named": "never", "asyncArrow": "always", }], + "require-await": "error", "unused-imports/no-unused-imports-ts": process.env.PROD === "true" ? "error" : "warn", "unused-imports/no-unused-vars-ts": [ "warn", { diff --git a/__mocks__/monaco-editor.ts b/__mocks__/monaco-editor.ts new file mode 100644 index 0000000000..ceecaa9945 --- /dev/null +++ b/__mocks__/monaco-editor.ts @@ -0,0 +1,20 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +module.exports = { + languages: { + register: jest.fn(), + setMonarchTokensProvider: jest.fn(), + registerCompletionItemProvider: jest.fn(), + }, + editor: { + defineTheme: jest.fn(), + getModel: jest.fn(), + createModel: jest.fn(), + }, + Uri: { + file: jest.fn(), + }, +}; diff --git a/build/build_theme_vars.ts b/build/build_theme_vars.ts index 44376712a7..1cd51fb2e6 100644 --- a/build/build_theme_vars.ts +++ b/build/build_theme_vars.ts @@ -5,11 +5,11 @@ import fs from "fs-extra"; import path from "path"; -import defaultBaseLensTheme from "../src/renderer/themes/lens-dark.json"; +import defaultBaseLensTheme from "../src/renderer/internal-themes/lens-dark.json"; -const outputCssFile = path.resolve("src/renderer/themes/theme-vars.css"); +const outputCssFile = path.resolve("src/renderer/internal-themes/theme-vars.css"); -const banner = `/* +const banner = `/* Generated Lens theme CSS-variables, don't edit manually. To refresh file run $: yarn run ts-node build/${path.basename(__filename)} */`; @@ -28,4 +28,4 @@ ${themeCssVars.join("\n")} // Run console.info(`"Saving default Lens theme css-variables to "${outputCssFile}""`); fs.ensureFileSync(outputCssFile); -fs.writeFile(outputCssFile, content); +fs.writeFileSync(outputCssFile, content); diff --git a/build/build_tray_icon.ts b/build/build_tray_icon.ts index 0850011815..6714d2c6d2 100644 --- a/build/build_tray_icon.ts +++ b/build/build_tray_icon.ts @@ -38,7 +38,7 @@ export async function generateTrayIcon( await fs.writeFile(pngIconDestPath, pngIconBuffer); console.info(`[DONE]: Tray icon saved at "${pngIconDestPath}"`); } catch (err) { - console.error(`[ERROR]: ${err}`); + console.error(`[ERROR]: ${String(err)}`); } } @@ -49,7 +49,9 @@ const iconSizes: Record = { "3x": 48, }; -Object.entries(iconSizes).forEach(([dpiSuffix, pixelSize]) => { +for (const dpiSuffix in iconSizes) { + const pixelSize = iconSizes[dpiSuffix]; + generateTrayIcon({ dpiSuffix, pixelSize, shouldUseDarkColors: false }); generateTrayIcon({ dpiSuffix, pixelSize, shouldUseDarkColors: true }); -}); +} diff --git a/build/download_kubectl.ts b/build/download_kubectl.ts index 8e85ac0c98..51bcd4441e 100644 --- a/build/download_kubectl.ts +++ b/build/download_kubectl.ts @@ -32,7 +32,7 @@ class KubectlDownloader { method: "HEAD", uri: this.url, resolveWithFullResponse: true, - }).catch(console.error); + }); if (response.headers["etag"]) { return response.headers["etag"].replace(/"/g, ""); diff --git a/extensions/kube-object-event-status/package-lock.json b/extensions/kube-object-event-status/package-lock.json index 648af47afb..98b4826cff 100644 --- a/extensions/kube-object-event-status/package-lock.json +++ b/extensions/kube-object-event-status/package-lock.json @@ -1,6 +1,6 @@ { "name": "kube-object-event-status", - "version": "0.0.1", + "version": "5.3.0-latest.1642433271626.1642519794031", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/extensions/kube-object-event-status/package.json b/extensions/kube-object-event-status/package.json index e8dfe38379..235b629de5 100644 --- a/extensions/kube-object-event-status/package.json +++ b/extensions/kube-object-event-status/package.json @@ -1,6 +1,6 @@ { "name": "kube-object-event-status", - "version": "0.0.1", + "version": "5.3.0-latest.1642433271626.1642519794031", "description": "Adds kube object status from events", "renderer": "dist/renderer.js", "lens": { diff --git a/extensions/metrics-cluster-feature/package-lock.json b/extensions/metrics-cluster-feature/package-lock.json index b67c57706b..edd69a037e 100644 --- a/extensions/metrics-cluster-feature/package-lock.json +++ b/extensions/metrics-cluster-feature/package-lock.json @@ -1,6 +1,6 @@ { "name": "lens-metrics-cluster-feature", - "version": "0.0.1", + "version": "5.3.0-latest.1642433271626.1642519794031", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/extensions/metrics-cluster-feature/package.json b/extensions/metrics-cluster-feature/package.json index 8683b75737..4d12c123d7 100644 --- a/extensions/metrics-cluster-feature/package.json +++ b/extensions/metrics-cluster-feature/package.json @@ -1,6 +1,6 @@ { "name": "lens-metrics-cluster-feature", - "version": "0.0.1", + "version": "5.3.0-latest.1642433271626.1642519794031", "description": "Lens metrics cluster feature", "renderer": "dist/renderer.js", "lens": { diff --git a/extensions/metrics-cluster-feature/src/metrics-feature.ts b/extensions/metrics-cluster-feature/src/metrics-feature.ts index 8b1a498e9d..af76b79e7e 100644 --- a/extensions/metrics-cluster-feature/src/metrics-feature.ts +++ b/extensions/metrics-cluster-feature/src/metrics-feature.ts @@ -71,7 +71,7 @@ export class MetricsFeature { return this.stack.kubectlApplyFolder(this.resourceFolder, config, ["--prune"]); } - async upgrade(config: MetricsConfiguration): Promise { + upgrade(config: MetricsConfiguration): Promise { return this.install(config); } @@ -101,7 +101,7 @@ export class MetricsFeature { return status; } - async uninstall(config: MetricsConfiguration): Promise { + uninstall(config: MetricsConfiguration): Promise { return this.stack.kubectlDeleteFolder(this.resourceFolder, config); } } diff --git a/extensions/metrics-cluster-feature/src/metrics-settings.tsx b/extensions/metrics-cluster-feature/src/metrics-settings.tsx index f309ff13cc..de1f91d82d 100644 --- a/extensions/metrics-cluster-feature/src/metrics-settings.tsx +++ b/extensions/metrics-cluster-feature/src/metrics-settings.tsx @@ -156,17 +156,17 @@ export class MetricsSettings extends React.Component { } } - async togglePrometheus(enabled: boolean) { + togglePrometheus(enabled: boolean) { this.featureStates.prometheus = enabled; this.changed = true; } - async toggleKubeStateMetrics(enabled: boolean) { + toggleKubeStateMetrics(enabled: boolean) { this.featureStates.kubeStateMetrics = enabled; this.changed = true; } - async toggleNodeExporter(enabled: boolean) { + toggleNodeExporter(enabled: boolean) { this.featureStates.nodeExporter = enabled; this.changed = true; } diff --git a/extensions/node-menu/package-lock.json b/extensions/node-menu/package-lock.json index 3d8c201d93..1773f063c7 100644 --- a/extensions/node-menu/package-lock.json +++ b/extensions/node-menu/package-lock.json @@ -1,6 +1,6 @@ { "name": "lens-node-menu", - "version": "0.0.1", + "version": "5.3.0-latest.1642433271626.1642519794031", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/extensions/node-menu/package.json b/extensions/node-menu/package.json index fdaaee3ae1..e4d1b55dd6 100644 --- a/extensions/node-menu/package.json +++ b/extensions/node-menu/package.json @@ -1,6 +1,6 @@ { "name": "lens-node-menu", - "version": "0.0.1", + "version": "5.3.0-latest.1642433271626.1642519794031", "description": "Lens node menu", "renderer": "dist/renderer.js", "lens": { diff --git a/extensions/pod-menu/package-lock.json b/extensions/pod-menu/package-lock.json index e62f33d7cb..21547e8096 100644 --- a/extensions/pod-menu/package-lock.json +++ b/extensions/pod-menu/package-lock.json @@ -1,6 +1,6 @@ { "name": "lens-pod-menu", - "version": "0.0.1", + "version": "5.3.0-latest.1642433271626.1642519794031", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/extensions/pod-menu/package.json b/extensions/pod-menu/package.json index 959059e5a2..e368c095ea 100644 --- a/extensions/pod-menu/package.json +++ b/extensions/pod-menu/package.json @@ -1,6 +1,6 @@ { "name": "lens-pod-menu", - "version": "0.0.1", + "version": "5.3.0-latest.1642433271626.1642519794031", "description": "Lens pod menu", "renderer": "dist/renderer.js", "lens": { diff --git a/extensions/pod-menu/src/attach-menu.tsx b/extensions/pod-menu/src/attach-menu.tsx index de8035ed94..5e043c5757 100644 --- a/extensions/pod-menu/src/attach-menu.tsx +++ b/extensions/pod-menu/src/attach-menu.tsx @@ -55,7 +55,7 @@ export class PodAttachMenu extends React.Component { title: `Pod: ${pod.getName()} (namespace: ${pod.getNs()}) [Attached]`, }); - terminalStore.sendCommand(commandParts.join(" "), { + await terminalStore.sendCommand(commandParts.join(" "), { enter: true, tabId: shell.id, }); diff --git a/extensions/pod-menu/src/shell-menu.tsx b/extensions/pod-menu/src/shell-menu.tsx index 4e239d6fdf..8aa30fb126 100644 --- a/extensions/pod-menu/src/shell-menu.tsx +++ b/extensions/pod-menu/src/shell-menu.tsx @@ -63,7 +63,7 @@ export class PodShellMenu extends React.Component { title: `Pod: ${pod.getName()} (namespace: ${pod.getNs()})`, }); - terminalStore.sendCommand(commandParts.join(" "), { + await terminalStore.sendCommand(commandParts.join(" "), { enter: true, tabId: shell.id, }); diff --git a/package.json b/package.json index 2667dea25b..58103e2dd2 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "productName": "OpenLens", "description": "OpenLens - Open Source IDE for Kubernetes", "homepage": "https://github.com/lensapp/lens", - "version": "5.3.0", + "version": "5.3.4", "main": "static/build/main.js", "copyright": "© 2021 OpenLens Authors", "license": "MIT", @@ -62,7 +62,8 @@ }, "moduleNameMapper": { "\\.(css|scss)$": "/__mocks__/styleMock.ts", - "\\.(svg)$": "/__mocks__/imageMock.ts" + "\\.(svg)$": "/__mocks__/imageMock.ts", + "^monaco-editor": "/node_modules/monaco-editor" }, "modulePathIgnorePatterns": [ "/dist", @@ -195,8 +196,8 @@ "@hapi/call": "^8.0.1", "@hapi/subtext": "^7.0.3", "@kubernetes/client-node": "^0.16.1", - "@ogre-tools/injectable": "3.1.1", - "@ogre-tools/injectable-react": "3.1.1", + "@ogre-tools/injectable": "3.2.0", + "@ogre-tools/injectable-react": "3.2.0", "@sentry/electron": "^2.5.4", "@sentry/integrations": "^6.15.0", "@types/circular-dependency-plugin": "5.0.4", diff --git a/src/common/__tests__/base-store.test.ts b/src/common/__tests__/base-store.test.ts index f35ed7721f..df7b3aeb2c 100644 --- a/src/common/__tests__/base-store.test.ts +++ b/src/common/__tests__/base-store.test.ts @@ -10,7 +10,7 @@ import { readFileSync } from "fs"; import { getDisForUnitTesting } from "../../test-utils/get-dis-for-unit-testing"; import directoryForUserDataInjectable - from "../app-paths/directory-for-user-data/directory-for-user-data.injectable"; + from "../app-paths/directory-for-user-data.injectable"; jest.mock("electron", () => ({ ipcMain: { @@ -59,7 +59,7 @@ class TestStore extends BaseStore { super.onSync(data); } - async saveToFile(model: TestStoreModel) { + saveToFile(model: TestStoreModel) { return super.saveToFile(model); } @@ -85,7 +85,6 @@ describe("BaseStore", () => { await dis.runSetups(); store = undefined; - TestStore.resetInstance(); const mockOpts = { "some-user-data-directory": { @@ -95,12 +94,11 @@ describe("BaseStore", () => { mockFs(mockOpts); - store = TestStore.createInstance(); + store = new TestStore(); }); afterEach(() => { store.disableSync(); - TestStore.resetInstance(); mockFs.restore(); }); diff --git a/src/common/__tests__/catalog-category-registry.test.ts b/src/common/__tests__/catalog-category-registry.test.ts index 896845331d..094311c4cf 100644 --- a/src/common/__tests__/catalog-category-registry.test.ts +++ b/src/common/__tests__/catalog-category-registry.test.ts @@ -64,26 +64,26 @@ describe("CatalogCategoryRegistry", () => { registry.add(new TestCatalogCategory2()); expect(registry.items.length).toBe(2); - expect(registry.filteredItems.length).toBe(2); + expect(registry.filteredItems.get().length).toBe(2); const disposer = registry.addCatalogCategoryFilter(category => category.metadata.name === "Test Category"); expect(registry.items.length).toBe(2); - expect(registry.filteredItems.length).toBe(1); + expect(registry.filteredItems.get().length).toBe(1); const disposer2 = registry.addCatalogCategoryFilter(category => category.metadata.name === "foo"); expect(registry.items.length).toBe(2); - expect(registry.filteredItems.length).toBe(0); + expect(registry.filteredItems.get().length).toBe(0); disposer(); expect(registry.items.length).toBe(2); - expect(registry.filteredItems.length).toBe(0); + expect(registry.filteredItems.get().length).toBe(0); disposer2(); expect(registry.items.length).toBe(2); - expect(registry.filteredItems.length).toBe(2); + expect(registry.filteredItems.get().length).toBe(2); }); }); diff --git a/src/common/__tests__/cluster-store.test.ts b/src/common/__tests__/cluster-store.test.ts index f11bd2d593..e3a7a63d79 100644 --- a/src/common/__tests__/cluster-store.test.ts +++ b/src/common/__tests__/cluster-store.test.ts @@ -8,11 +8,11 @@ import mockFs from "mock-fs"; import path from "path"; import fse from "fs-extra"; import type { Cluster } from "../cluster/cluster"; -import { ClusterStore } from "../cluster-store/cluster-store"; +import type { ClusterStore } from "../cluster-store/store"; import { Console } from "console"; import { stdout, stderr } from "process"; -import getCustomKubeConfigDirectoryInjectable from "../app-paths/get-custom-kube-config-directory/get-custom-kube-config-directory.injectable"; -import clusterStoreInjectable from "../cluster-store/cluster-store.injectable"; +import getCustomKubeConfigDirectoryInjectable from "../app-paths/get-custom-kube-config-directory.injectable"; +import clusterStoreInjectable from "../cluster-store/store.injectable"; import type { ClusterModel } from "../cluster-types"; import type { DependencyInjectionContainer, @@ -23,7 +23,7 @@ import { getDisForUnitTesting } from "../../test-utils/get-dis-for-unit-testing" import { createClusterInjectionToken } from "../cluster/create-cluster-injection-token"; import directoryForUserDataInjectable - from "../app-paths/directory-for-user-data/directory-for-user-data.injectable"; + from "../app-paths/directory-for-user-data.injectable"; console = new Console(stdout, stderr); @@ -100,15 +100,11 @@ describe("cluster-store", () => { describe("empty config", () => { let getCustomKubeConfigDirectory: (directoryName: string) => string; - beforeEach(async () => { + beforeEach(() => { getCustomKubeConfigDirectory = mainDi.inject( getCustomKubeConfigDirectoryInjectable, ); - // TODO: Remove these by removing Singleton base-class from BaseStore - ClusterStore.getInstance(false)?.unregisterIpcListener(); - ClusterStore.resetInstance(); - const mockOpts = { "some-directory-for-user-data": { "lens-cluster-store.json": JSON.stringify({}), @@ -143,7 +139,7 @@ describe("cluster-store", () => { clusterStore.addCluster(cluster); }); - it("adds new cluster to store", async () => { + it("adds new cluster to store", () => { const storedCluster = clusterStore.getById("foo"); expect(storedCluster.id).toBe("foo"); @@ -197,8 +193,6 @@ describe("cluster-store", () => { describe("config with existing clusters", () => { beforeEach(() => { - ClusterStore.resetInstance(); - const mockOpts = { "temp-kube-config": kubeconfig, "some-directory-for-user-data": { @@ -251,7 +245,7 @@ describe("cluster-store", () => { expect(storedCluster.preferences.terminalCWD).toBe("/foo"); }); - it("allows getting all of the clusters", async () => { + it("allows getting all of the clusters", () => { const storedClusters = clusterStore.clustersList; expect(storedClusters.length).toBe(3); @@ -285,8 +279,6 @@ users: token: kubeconfig-user-q4lm4:xxxyyyy `; - ClusterStore.resetInstance(); - const mockOpts = { "invalid-kube-config": invalidKubeconfig, "valid-kube-config": kubeconfig, @@ -335,7 +327,6 @@ users: describe("pre 3.6.0-beta.1 config with an existing cluster", () => { beforeEach(() => { - ClusterStore.resetInstance(); const mockOpts = { "some-directory-for-user-data": { "lens-cluster-store.json": JSON.stringify({ @@ -368,13 +359,13 @@ users: mockFs.restore(); }); - it("migrates to modern format with kubeconfig in a file", async () => { + it("migrates to modern format with kubeconfig in a file", () => { const config = clusterStore.clustersList[0].kubeConfigPath; expect(fs.readFileSync(config, "utf8")).toBe(minimalValidKubeConfig); }); - it("migrates to modern format with icon not in file", async () => { + it("migrates to modern format with icon not in file", () => { const { icon } = clusterStore.clustersList[0].preferences; expect(icon.startsWith("data:;base64,")).toBe(true); diff --git a/src/common/__tests__/hotbar-store.test.ts b/src/common/__tests__/hotbar-store.test.ts deleted file mode 100644 index b73a63b308..0000000000 --- a/src/common/__tests__/hotbar-store.test.ts +++ /dev/null @@ -1,416 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import { anyObject } from "jest-mock-extended"; -import { merge } from "lodash"; -import mockFs from "mock-fs"; -import logger from "../../main/logger"; -import type { CatalogEntity, CatalogEntityData, CatalogEntityKindData } from "../catalog"; -import { HotbarStore } from "../hotbar-store"; -import { getDiForUnitTesting } from "../../main/getDiForUnitTesting"; -import directoryForUserDataInjectable - from "../app-paths/directory-for-user-data/directory-for-user-data.injectable"; - -jest.mock("../../main/catalog/catalog-entity-registry", () => ({ - catalogEntityRegistry: { - items: [ - { - metadata: { - uid: "1dfa26e2ebab15780a3547e9c7fa785c", - name: "mycluster", - source: "local", - }, - }, - { - metadata: { - uid: "55b42c3c7ba3b04193416cda405269a5", - name: "my_shiny_cluster", - source: "remote", - }, - }, - { - metadata: { - uid: "catalog-entity", - name: "Catalog", - source: "app", - }, - }, - ], - }, -})); - -function getMockCatalogEntity(data: Partial & CatalogEntityKindData): CatalogEntity { - return merge(data, { - getName: jest.fn(() => data.metadata?.name), - getId: jest.fn(() => data.metadata?.uid), - getSource: jest.fn(() => data.metadata?.source ?? "unknown"), - isEnabled: jest.fn(() => data.status?.enabled ?? true), - onContextMenuOpen: jest.fn(), - onSettingsOpen: jest.fn(), - metadata: {}, - spec: {}, - status: {}, - }) as CatalogEntity; -} - -const testCluster = getMockCatalogEntity({ - apiVersion: "v1", - kind: "Cluster", - status: { - phase: "Running", - }, - metadata: { - uid: "test", - name: "test", - labels: {}, - }, -}); - -const minikubeCluster = getMockCatalogEntity({ - apiVersion: "v1", - kind: "Cluster", - status: { - phase: "Running", - }, - metadata: { - uid: "minikube", - name: "minikube", - labels: {}, - }, -}); - -const awsCluster = getMockCatalogEntity({ - apiVersion: "v1", - kind: "Cluster", - status: { - phase: "Running", - }, - metadata: { - uid: "aws", - name: "aws", - labels: {}, - }, -}); - -describe("HotbarStore", () => { - beforeEach(async () => { - const di = getDiForUnitTesting({ doGeneralOverrides: true }); - - di.override(directoryForUserDataInjectable, () => "some-directory-for-user-data"); - - await di.runSetups(); - - mockFs({ - "some-directory-for-user-data": { - "lens-hotbar-store.json": JSON.stringify({}), - }, - }); - - HotbarStore.createInstance(); - }); - - afterEach(() => { - HotbarStore.resetInstance(); - mockFs.restore(); - }); - - describe("load", () => { - it("loads one hotbar by default", () => { - expect(HotbarStore.getInstance().hotbars.length).toEqual(1); - }); - }); - - describe("add", () => { - it("adds a hotbar", () => { - const hotbarStore = HotbarStore.getInstance(); - - hotbarStore.add({ name: "hottest" }); - expect(hotbarStore.hotbars.length).toEqual(2); - }); - }); - - describe("hotbar items", () => { - it("initially creates 12 empty cells", () => { - const hotbarStore = HotbarStore.getInstance(); - - expect(hotbarStore.getActive().items.length).toEqual(12); - }); - - it("initially adds catalog entity as first item", () => { - const hotbarStore = HotbarStore.getInstance(); - - expect(hotbarStore.getActive().items[0].entity.name).toEqual("Catalog"); - }); - - it("adds items", () => { - const hotbarStore = HotbarStore.getInstance(); - - hotbarStore.addToHotbar(testCluster); - const items = hotbarStore.getActive().items.filter(Boolean); - - expect(items.length).toEqual(2); - }); - - it("removes items", () => { - const hotbarStore = HotbarStore.getInstance(); - - hotbarStore.addToHotbar(testCluster); - hotbarStore.removeFromHotbar("test"); - hotbarStore.removeFromHotbar("catalog-entity"); - const items = hotbarStore.getActive().items.filter(Boolean); - - expect(items).toStrictEqual([]); - }); - - it("does nothing if removing with invalid uid", () => { - const hotbarStore = HotbarStore.getInstance(); - - hotbarStore.addToHotbar(testCluster); - hotbarStore.removeFromHotbar("invalid uid"); - const items = hotbarStore.getActive().items.filter(Boolean); - - expect(items.length).toEqual(2); - }); - - it("moves item to empty cell", () => { - const hotbarStore = HotbarStore.getInstance(); - - hotbarStore.addToHotbar(testCluster); - hotbarStore.addToHotbar(minikubeCluster); - hotbarStore.addToHotbar(awsCluster); - - expect(hotbarStore.getActive().items[6]).toBeNull(); - - hotbarStore.restackItems(1, 5); - - expect(hotbarStore.getActive().items[5]).toBeTruthy(); - expect(hotbarStore.getActive().items[5].entity.uid).toEqual("test"); - }); - - it("moves items down", () => { - const hotbarStore = HotbarStore.getInstance(); - - hotbarStore.addToHotbar(testCluster); - hotbarStore.addToHotbar(minikubeCluster); - hotbarStore.addToHotbar(awsCluster); - - // aws -> catalog - hotbarStore.restackItems(3, 0); - - const items = hotbarStore.getActive().items.map(item => item?.entity.uid || null); - - expect(items.slice(0, 4)).toEqual(["aws", "catalog-entity", "test", "minikube"]); - }); - - it("moves items up", () => { - const hotbarStore = HotbarStore.getInstance(); - - hotbarStore.addToHotbar(testCluster); - hotbarStore.addToHotbar(minikubeCluster); - hotbarStore.addToHotbar(awsCluster); - - // test -> aws - hotbarStore.restackItems(1, 3); - - const items = hotbarStore.getActive().items.map(item => item?.entity.uid || null); - - expect(items.slice(0, 4)).toEqual(["catalog-entity", "minikube", "aws", "test"]); - }); - - it("logs an error if cellIndex is out of bounds", () => { - const hotbarStore = HotbarStore.getInstance(); - - hotbarStore.add({ name: "hottest", id: "hottest" }); - hotbarStore.setActiveHotbar("hottest"); - - const { error } = logger; - const mocked = jest.fn(); - - logger.error = mocked; - - hotbarStore.addToHotbar(testCluster, -1); - expect(mocked).toBeCalledWith("[HOTBAR-STORE]: cannot pin entity to hotbar outside of index range", anyObject()); - - hotbarStore.addToHotbar(testCluster, 12); - expect(mocked).toBeCalledWith("[HOTBAR-STORE]: cannot pin entity to hotbar outside of index range", anyObject()); - - hotbarStore.addToHotbar(testCluster, 13); - expect(mocked).toBeCalledWith("[HOTBAR-STORE]: cannot pin entity to hotbar outside of index range", anyObject()); - - logger.error = error; - }); - - it("throws an error if getId is invalid or returns not a string", () => { - const hotbarStore = HotbarStore.getInstance(); - - expect(() => hotbarStore.addToHotbar({} as any)).toThrowError(TypeError); - expect(() => hotbarStore.addToHotbar({ getId: () => true } as any)).toThrowError(TypeError); - }); - - it("throws an error if getName is invalid or returns not a string", () => { - const hotbarStore = HotbarStore.getInstance(); - - expect(() => hotbarStore.addToHotbar({ getId: () => "" } as any)).toThrowError(TypeError); - expect(() => hotbarStore.addToHotbar({ getId: () => "", getName: () => 4 } as any)).toThrowError(TypeError); - }); - - it("does nothing when item moved to same cell", () => { - const hotbarStore = HotbarStore.getInstance(); - - hotbarStore.addToHotbar(testCluster); - hotbarStore.restackItems(1, 1); - - expect(hotbarStore.getActive().items[1].entity.uid).toEqual("test"); - }); - - it("new items takes first empty cell", () => { - const hotbarStore = HotbarStore.getInstance(); - - hotbarStore.addToHotbar(testCluster); - hotbarStore.addToHotbar(awsCluster); - hotbarStore.restackItems(0, 3); - hotbarStore.addToHotbar(minikubeCluster); - - expect(hotbarStore.getActive().items[0].entity.uid).toEqual("minikube"); - }); - - it("throws if invalid arguments provided", () => { - // Prevent writing to stderr during this render. - const { error, warn } = console; - - console.error = jest.fn(); - console.warn = jest.fn(); - - const hotbarStore = HotbarStore.getInstance(); - - hotbarStore.addToHotbar(testCluster); - - expect(() => hotbarStore.restackItems(-5, 0)).toThrow(); - expect(() => hotbarStore.restackItems(2, -1)).toThrow(); - expect(() => hotbarStore.restackItems(14, 1)).toThrow(); - expect(() => hotbarStore.restackItems(11, 112)).toThrow(); - - // Restore writing to stderr. - console.error = error; - console.warn = warn; - }); - - it("checks if entity already pinned to hotbar", () => { - const hotbarStore = HotbarStore.getInstance(); - - hotbarStore.addToHotbar(testCluster); - - expect(hotbarStore.isAddedToActive(testCluster)).toBeTruthy(); - expect(hotbarStore.isAddedToActive(awsCluster)).toBeFalsy(); - }); - }); - - describe("pre beta-5 migrations", () => { - beforeEach(() => { - HotbarStore.resetInstance(); - const mockOpts = { - "some-directory-for-user-data": { - "lens-hotbar-store.json": JSON.stringify({ - __internal__: { - migrations: { - version: "5.0.0-beta.3", - }, - }, - "hotbars": [ - { - "id": "3caac17f-aec2-4723-9694-ad204465d935", - "name": "myhotbar", - "items": [ - { - "entity": { - "uid": "1dfa26e2ebab15780a3547e9c7fa785c", - }, - }, - { - "entity": { - "uid": "55b42c3c7ba3b04193416cda405269a5", - }, - }, - { - "entity": { - "uid": "176fd331968660832f62283219d7eb6e", - }, - }, - { - "entity": { - "uid": "61c4fb45528840ebad1badc25da41d14", - "name": "user1-context", - "source": "local", - }, - }, - { - "entity": { - "uid": "27d6f99fe9e7548a6e306760bfe19969", - "name": "foo2", - "source": "local", - }, - }, - null, - { - "entity": { - "uid": "c0b20040646849bb4dcf773e43a0bf27", - "name": "multinode-demo", - "source": "local", - }, - }, - null, - null, - null, - null, - null, - ], - }, - ], - }), - }, - }; - - mockFs(mockOpts); - - HotbarStore.createInstance(); - }); - - afterEach(() => { - mockFs.restore(); - }); - - it("allows to retrieve a hotbar", () => { - const hotbar = HotbarStore.getInstance().getById("3caac17f-aec2-4723-9694-ad204465d935"); - - expect(hotbar.id).toBe("3caac17f-aec2-4723-9694-ad204465d935"); - }); - - it("clears cells without entity", () => { - const items = HotbarStore.getInstance().hotbars[0].items; - - expect(items[2]).toBeNull(); - }); - - it("adds extra data to cells with according entity", () => { - const items = HotbarStore.getInstance().hotbars[0].items; - - expect(items[0]).toEqual({ - entity: { - name: "mycluster", - source: "local", - uid: "1dfa26e2ebab15780a3547e9c7fa785c", - }, - }); - - expect(items[1]).toEqual({ - entity: { - name: "my_shiny_cluster", - source: "remote", - uid: "55b42c3c7ba3b04193416cda405269a5", - }, - }); - }); - }); -}); diff --git a/src/common/__tests__/kube-helpers.test.ts b/src/common/__tests__/kube-helpers.test.ts index c55d9a3442..073d3ddc47 100644 --- a/src/common/__tests__/kube-helpers.test.ts +++ b/src/common/__tests__/kube-helpers.test.ts @@ -151,13 +151,13 @@ describe("kube helpers", () => { }; }); - it("single context is ok", async () => { + it("single context is ok", () => { const { config } = loadConfigFromString(JSON.stringify(mockKubeConfig)); expect(config.getCurrentContext()).toBe("minikube"); }); - it("multiple context is ok", async () => { + it("multiple context is ok", () => { mockKubeConfig.contexts.push({ context: { cluster: "cluster-2", user: "cluster-2" }, name: "cluster-2" }); const { config } = loadConfigFromString(JSON.stringify(mockKubeConfig)); @@ -192,7 +192,7 @@ describe("kube helpers", () => { }; }); - it("empty name in context causes it to be removed", async () => { + it("empty name in context causes it to be removed", () => { mockKubeConfig.contexts.push({ context: { cluster: "cluster-2", user: "cluster-2" }, name: "" }); expect(mockKubeConfig.contexts.length).toBe(2); const { config } = loadConfigFromString(JSON.stringify(mockKubeConfig)); @@ -201,7 +201,7 @@ describe("kube helpers", () => { expect(config.contexts.length).toBe(1); }); - it("empty cluster in context causes it to be removed", async () => { + it("empty cluster in context causes it to be removed", () => { mockKubeConfig.contexts.push({ context: { cluster: "", user: "cluster-2" }, name: "cluster-2" }); expect(mockKubeConfig.contexts.length).toBe(2); const { config } = loadConfigFromString(JSON.stringify(mockKubeConfig)); @@ -210,7 +210,7 @@ describe("kube helpers", () => { expect(config.contexts.length).toBe(1); }); - it("empty user in context causes it to be removed", async () => { + it("empty user in context causes it to be removed", () => { mockKubeConfig.contexts.push({ context: { cluster: "cluster-2", user: "" }, name: "cluster-2" }); expect(mockKubeConfig.contexts.length).toBe(2); const { config } = loadConfigFromString(JSON.stringify(mockKubeConfig)); @@ -219,7 +219,7 @@ describe("kube helpers", () => { expect(config.contexts.length).toBe(1); }); - it("invalid context in between valid contexts is removed", async () => { + it("invalid context in between valid contexts is removed", () => { mockKubeConfig.contexts.push({ context: { cluster: "cluster-2", user: "" }, name: "cluster-2" }); mockKubeConfig.contexts.push({ context: { cluster: "cluster-3", user: "cluster-3" }, name: "cluster-3" }); expect(mockKubeConfig.contexts.length).toBe(3); diff --git a/src/common/__tests__/log-search-store.test.ts b/src/common/__tests__/log-search-store.test.ts new file mode 100644 index 0000000000..8b426b5b80 --- /dev/null +++ b/src/common/__tests__/log-search-store.test.ts @@ -0,0 +1,82 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { LogSearchStore } from "../../renderer/components/dock/log-search/store"; + +const logs = [ + "1:M 30 Oct 2020 16:17:41.553 # Connection with replica 172.17.0.12:6379 lost", + "1:M 30 Oct 2020 16:17:41.623 * Replica 172.17.0.12:6379 asks for synchronization", + "1:M 30 Oct 2020 16:17:41.623 * Starting Partial resynchronization request from 172.17.0.12:6379 accepted. Sending 0 bytes of backlog starting from offset 14407.", +]; + +describe("search store tests", () => { + let logSearchStore: LogSearchStore; + + beforeEach(() => { + logSearchStore = new LogSearchStore(); + }); + + it("does nothing with empty search query", () => { + logSearchStore.onSearch([], ""); + expect(logSearchStore.occurrences).toEqual([]); + }); + + it("doesn't break if no text provided", () => { + logSearchStore.onSearch(null, "replica"); + expect(logSearchStore.occurrences).toEqual([]); + + logSearchStore.onSearch([], "replica"); + expect(logSearchStore.occurrences).toEqual([]); + }); + + it("find 3 occurrences across 3 lines", () => { + logSearchStore.onSearch(logs, "172"); + expect(logSearchStore.occurrences).toEqual([0, 1, 2]); + }); + + it("find occurrences within 1 line (case-insensitive)", () => { + logSearchStore.onSearch(logs, "Starting"); + expect(logSearchStore.occurrences).toEqual([2, 2]); + }); + + it("sets overlay index equal to first occurrence", () => { + logSearchStore.onSearch(logs, "Replica"); + expect(logSearchStore.activeOverlayIndex).toBe(0); + }); + + it("set overlay index to next occurrence", () => { + logSearchStore.onSearch(logs, "172"); + logSearchStore.setNextOverlayActive(); + expect(logSearchStore.activeOverlayIndex).toBe(1); + }); + + it("sets overlay to last occurrence", () => { + logSearchStore.onSearch(logs, "172"); + logSearchStore.setPrevOverlayActive(); + expect(logSearchStore.activeOverlayIndex).toBe(2); + }); + + it("gets line index where overlay is located", () => { + logSearchStore.onSearch(logs, "synchronization"); + expect(logSearchStore.activeOverlayLine).toBe(1); + }); + + it("escapes string for using in regex", () => { + const regex = LogSearchStore.escapeRegex("some.interesting-query\\#?()[]"); + + expect(regex).toBe("some\\.interesting\\-query\\\\\\#\\?\\(\\)\\[\\]"); + }); + + it("gets active find number", () => { + logSearchStore.onSearch(logs, "172"); + logSearchStore.setNextOverlayActive(); + expect(logSearchStore.activeFind).toBe(2); + }); + + it("gets total finds number", () => { + logSearchStore.onSearch(logs, "Starting"); + expect(logSearchStore.totalFinds).toBe(2); + }); +}); diff --git a/src/common/__tests__/user-store.test.ts b/src/common/__tests__/user-store.test.ts index 9201b25bd7..1f36d81a1c 100644 --- a/src/common/__tests__/user-store.test.ts +++ b/src/common/__tests__/user-store.test.ts @@ -4,6 +4,17 @@ */ import mockFs from "mock-fs"; +import { Console } from "console"; +import { SemVer } from "semver"; +import electron from "electron"; +import { stdout, stderr } from "process"; +import { getDisForUnitTesting } from "../../test-utils/get-dis-for-unit-testing"; +import userPreferencesStoreInjectable from "../user-preferences/store.injectable"; +import type { DependencyInjectionContainer } from "@ogre-tools/injectable"; +import directoryForUserDataInjectable from "../app-paths/directory-for-user-data.injectable"; +import type { ClusterStoreModel } from "../cluster-store/store"; +import { defaultTheme } from "../vars"; +import type { UserPreferencesStore } from "../user-preferences"; jest.mock("electron", () => ({ app: { @@ -21,22 +32,10 @@ jest.mock("electron", () => ({ }, })); -import { UserStore } from "../user-store"; -import { Console } from "console"; -import { SemVer } from "semver"; -import electron from "electron"; -import { stdout, stderr } from "process"; -import { getDisForUnitTesting } from "../../test-utils/get-dis-for-unit-testing"; -import userStoreInjectable from "../user-store/user-store.injectable"; -import type { DependencyInjectionContainer } from "@ogre-tools/injectable"; -import directoryForUserDataInjectable from "../app-paths/directory-for-user-data/directory-for-user-data.injectable"; -import type { ClusterStoreModel } from "../cluster-store/cluster-store"; -import { defaultTheme } from "../vars"; - console = new Console(stdout, stderr); describe("user store tests", () => { - let userStore: UserStore; + let userStore: UserPreferencesStore; let mainDi: DependencyInjectionContainer; beforeEach(async () => { @@ -59,12 +58,11 @@ describe("user store tests", () => { beforeEach(() => { mockFs({ "some-directory-for-user-data": { "config.json": "{}", "kube_config": "{}" }}); - userStore = mainDi.inject(userStoreInjectable); + userStore = mainDi.inject(userPreferencesStoreInjectable); }); afterEach(() => { mockFs.restore(); - UserStore.resetInstance(); }); it("allows setting and retrieving lastSeenAppVersion", () => { @@ -82,7 +80,7 @@ describe("user store tests", () => { expect(userStore.colorTheme).toBe("light"); }); - it("correctly resets theme to default value", async () => { + it("correctly resets theme to default value", () => { userStore.colorTheme = "some other theme"; userStore.resetTheme(); expect(userStore.colorTheme).toBe(defaultTheme); @@ -126,11 +124,10 @@ describe("user store tests", () => { }, }); - userStore = mainDi.inject(userStoreInjectable); + userStore = mainDi.inject(userPreferencesStoreInjectable); }); afterEach(() => { - UserStore.resetInstance(); mockFs.restore(); }); diff --git a/src/common/app-paths/directory-for-binaries/directory-for-binaries.injectable.ts b/src/common/app-paths/directory-for-binaries.injectable.ts similarity index 81% rename from src/common/app-paths/directory-for-binaries/directory-for-binaries.injectable.ts rename to src/common/app-paths/directory-for-binaries.injectable.ts index 1857942fa8..b8c8f8a0d5 100644 --- a/src/common/app-paths/directory-for-binaries/directory-for-binaries.injectable.ts +++ b/src/common/app-paths/directory-for-binaries.injectable.ts @@ -4,7 +4,7 @@ */ import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; import path from "path"; -import directoryForUserDataInjectable from "../directory-for-user-data/directory-for-user-data.injectable"; +import directoryForUserDataInjectable from "./directory-for-user-data.injectable"; const directoryForBinariesInjectable = getInjectable({ instantiate: (di) => diff --git a/src/common/app-paths/directory-for-downloads/directory-for-downloads.injectable.ts b/src/common/app-paths/directory-for-downloads.injectable.ts similarity index 86% rename from src/common/app-paths/directory-for-downloads/directory-for-downloads.injectable.ts rename to src/common/app-paths/directory-for-downloads.injectable.ts index 2b8cca9759..e2b9cf785b 100644 --- a/src/common/app-paths/directory-for-downloads/directory-for-downloads.injectable.ts +++ b/src/common/app-paths/directory-for-downloads.injectable.ts @@ -3,7 +3,7 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; -import { appPathsInjectionToken } from "../app-path-injection-token"; +import { appPathsInjectionToken } from "./app-path-injection-token"; const directoryForDownloadsInjectable = getInjectable({ instantiate: (di) => di.inject(appPathsInjectionToken).downloads, diff --git a/src/common/app-paths/directory-for-exes/directory-for-exes.injectable.ts b/src/common/app-paths/directory-for-exes.injectable.ts similarity index 85% rename from src/common/app-paths/directory-for-exes/directory-for-exes.injectable.ts rename to src/common/app-paths/directory-for-exes.injectable.ts index f1bd480d2d..3b5b2bd9b7 100644 --- a/src/common/app-paths/directory-for-exes/directory-for-exes.injectable.ts +++ b/src/common/app-paths/directory-for-exes.injectable.ts @@ -3,7 +3,7 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; -import { appPathsInjectionToken } from "../app-path-injection-token"; +import { appPathsInjectionToken } from "./app-path-injection-token"; const directoryForExesInjectable = getInjectable({ instantiate: (di) => di.inject(appPathsInjectionToken).exe, diff --git a/src/common/app-paths/directory-for-kube-configs/directory-for-kube-configs.injectable.ts b/src/common/app-paths/directory-for-kube-configs.injectable.ts similarity index 82% rename from src/common/app-paths/directory-for-kube-configs/directory-for-kube-configs.injectable.ts rename to src/common/app-paths/directory-for-kube-configs.injectable.ts index 7e4865197d..ba849ac551 100644 --- a/src/common/app-paths/directory-for-kube-configs/directory-for-kube-configs.injectable.ts +++ b/src/common/app-paths/directory-for-kube-configs.injectable.ts @@ -3,7 +3,7 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; -import directoryForUserDataInjectable from "../directory-for-user-data/directory-for-user-data.injectable"; +import directoryForUserDataInjectable from "./directory-for-user-data.injectable"; import path from "path"; const directoryForKubeConfigsInjectable = getInjectable({ diff --git a/src/common/app-paths/directory-for-temp/directory-for-temp.injectable.ts b/src/common/app-paths/directory-for-temp.injectable.ts similarity index 85% rename from src/common/app-paths/directory-for-temp/directory-for-temp.injectable.ts rename to src/common/app-paths/directory-for-temp.injectable.ts index aeb4b3af67..328b44c0f1 100644 --- a/src/common/app-paths/directory-for-temp/directory-for-temp.injectable.ts +++ b/src/common/app-paths/directory-for-temp.injectable.ts @@ -3,7 +3,7 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; -import { appPathsInjectionToken } from "../app-path-injection-token"; +import { appPathsInjectionToken } from "./app-path-injection-token"; const directoryForTempInjectable = getInjectable({ instantiate: (di) => di.inject(appPathsInjectionToken).temp, diff --git a/src/common/app-paths/directory-for-user-data/directory-for-user-data.injectable.ts b/src/common/app-paths/directory-for-user-data.injectable.ts similarity index 86% rename from src/common/app-paths/directory-for-user-data/directory-for-user-data.injectable.ts rename to src/common/app-paths/directory-for-user-data.injectable.ts index 38d1dbfb7c..f68f8b65bf 100644 --- a/src/common/app-paths/directory-for-user-data/directory-for-user-data.injectable.ts +++ b/src/common/app-paths/directory-for-user-data.injectable.ts @@ -3,7 +3,7 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; -import { appPathsInjectionToken } from "../app-path-injection-token"; +import { appPathsInjectionToken } from "./app-path-injection-token"; const directoryForUserDataInjectable = getInjectable({ instantiate: (di) => di.inject(appPathsInjectionToken).userData, diff --git a/src/common/app-paths/get-custom-kube-config-directory/get-custom-kube-config-directory.injectable.ts b/src/common/app-paths/get-custom-kube-config-directory.injectable.ts similarity index 83% rename from src/common/app-paths/get-custom-kube-config-directory/get-custom-kube-config-directory.injectable.ts rename to src/common/app-paths/get-custom-kube-config-directory.injectable.ts index 15e3d03051..96cb8cac38 100644 --- a/src/common/app-paths/get-custom-kube-config-directory/get-custom-kube-config-directory.injectable.ts +++ b/src/common/app-paths/get-custom-kube-config-directory.injectable.ts @@ -4,12 +4,12 @@ */ import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; import path from "path"; -import directoryForKubeConfigsInjectable from "../directory-for-kube-configs/directory-for-kube-configs.injectable"; +import directoryForKubeConfigsInjectable from "./directory-for-kube-configs.injectable"; const getCustomKubeConfigDirectoryInjectable = getInjectable({ instantiate: (di) => (directoryName: string) => { const directoryForKubeConfigs = di.inject(directoryForKubeConfigsInjectable); - + return path.resolve( directoryForKubeConfigs, directoryName, diff --git a/src/common/base-store.ts b/src/common/base-store.ts index 856860614a..9f56c60cae 100644 --- a/src/common/base-store.ts +++ b/src/common/base-store.ts @@ -8,15 +8,14 @@ import Config from "conf"; import type { Options as ConfOptions } from "conf/dist/source/types"; import { ipcMain, ipcRenderer } from "electron"; import { IEqualsComparer, makeObservable, reaction, runInAction } from "mobx"; -import { getAppVersion, Singleton, toJS, Disposer } from "./utils"; +import { getAppVersion, toJS, Disposer } from "./utils"; import logger from "../main/logger"; import { broadcastMessage, ipcMainOn, ipcRendererOn } from "./ipc"; import isEqual from "lodash/isEqual"; import { isTestEnv } from "./vars"; import { kebabCase } from "lodash"; import { getLegacyGlobalDiForExtensionApi } from "../extensions/as-legacy-globals-for-extension-api/legacy-global-di-for-extension-api"; -import directoryForUserDataInjectable - from "./app-paths/directory-for-user-data/directory-for-user-data.injectable"; +import directoryForUserDataInjectable from "./app-paths/directory-for-user-data.injectable"; export interface BaseStoreParams extends ConfOptions { syncOptions?: { @@ -28,14 +27,13 @@ export interface BaseStoreParams extends ConfOptions { /** * Note: T should only contain base JSON serializable types. */ -export abstract class BaseStore extends Singleton { +export abstract class BaseStore { protected storeConfig?: Config; protected syncDisposers: Disposer[] = []; readonly displayName: string = this.constructor.name; protected constructor(protected params: BaseStoreParams) { - super(); makeObservable(this); if (ipcRenderer) { diff --git a/src/common/catalog-entities/__tests__/kubernetes-cluster.test.ts b/src/common/catalog-entities/__tests__/kubernetes-cluster.test.ts index bbddddcca7..54bbf13d93 100644 --- a/src/common/catalog-entities/__tests__/kubernetes-cluster.test.ts +++ b/src/common/catalog-entities/__tests__/kubernetes-cluster.test.ts @@ -2,9 +2,15 @@ * Copyright (c) OpenLens Authors. All rights reserved. * Licensed under MIT License. See LICENSE in root directory for more information. */ -import { kubernetesClusterCategory } from "../kubernetes-cluster"; +import { KubernetesClusterCategory } from "../kubernetes-cluster"; describe("kubernetesClusterCategory", () => { + let kubernetesClusterCategory: KubernetesClusterCategory; + + beforeEach(() => { + kubernetesClusterCategory = new KubernetesClusterCategory(); + }); + describe("filteredItems", () => { const item1 = { icon: "Icon", diff --git a/src/common/catalog-entities/general.ts b/src/common/catalog-entities/general.ts index 9387528527..19227e3eba 100644 --- a/src/common/catalog-entities/general.ts +++ b/src/common/catalog-entities/general.ts @@ -5,7 +5,6 @@ import { navigate } from "../../renderer/navigation"; import { CatalogCategory, CatalogEntity, CatalogEntityMetadata, CatalogEntitySpec, CatalogEntityStatus } from "../catalog"; -import { catalogCategoryRegistry } from "../catalog/catalog-category-registry"; interface GeneralEntitySpec extends CatalogEntitySpec { path: string; @@ -19,21 +18,9 @@ export class GeneralEntity extends CatalogEntity { if (app) { - await ClusterStore.getInstance().getById(this.metadata.uid)?.activate(); + // TODO refactor + ipcMain.emit(clusterActivateHandler, this.metadata.uid, false); } else { await requestMain(clusterActivateHandler, this.metadata.uid, false); } @@ -75,25 +74,17 @@ export class KubernetesCluster extends CatalogEntity { if (app) { - ClusterStore.getInstance().getById(this.metadata.uid)?.disconnect(); + ipcMain.emit(clusterDisconnectHandler, this.metadata.uid, false); } else { await requestMain(clusterDisconnectHandler, this.metadata.uid, false); } } - async onRun(context: CatalogEntityActionContext) { + onRun(context: CatalogEntityActionContext) { context.navigate(`/cluster/${this.metadata.uid}`); } - onDetailsOpen(): void { - // - } - - onSettingsOpen(): void { - // - } - - async onContextMenuOpen(context: CatalogEntityContextMenuContext) { + onContextMenuOpen(context: CatalogEntityContextMenuContext) { if (!this.metadata.source || this.metadata.source === "local") { context.menuItems.push({ title: "Settings", @@ -122,14 +113,10 @@ export class KubernetesCluster extends CatalogEntity(this) - ?.emit("contextMenuOpen", this, context); } } -class KubernetesClusterCategory extends CatalogCategory { +export class KubernetesClusterCategory extends CatalogCategory { public readonly apiVersion = "catalog.k8slens.dev/v1alpha1"; public readonly kind = "CatalogCategory"; public metadata = { @@ -149,7 +136,3 @@ class KubernetesClusterCategory extends CatalogCategory { }, }; } - -export const kubernetesClusterCategory = new KubernetesClusterCategory(); - -catalogCategoryRegistry.add(kubernetesClusterCategory); diff --git a/src/common/catalog-entities/web-link.ts b/src/common/catalog-entities/web-link.ts index be59135ae1..eeaf65b683 100644 --- a/src/common/catalog-entities/web-link.ts +++ b/src/common/catalog-entities/web-link.ts @@ -3,10 +3,7 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import { CatalogCategory, CatalogEntity, CatalogEntityContextMenuContext, CatalogEntityMetadata, CatalogEntityStatus } from "../catalog"; -import { catalogCategoryRegistry } from "../catalog/catalog-category-registry"; -import { productName } from "../vars"; -import { WeblinkStore } from "../weblink-store"; +import { CatalogCategory, CatalogEntity, CatalogEntityMetadata, CatalogEntityStatus } from "../catalog"; export type WebLinkStatusPhase = "available" | "unavailable"; @@ -14,9 +11,9 @@ export interface WebLinkStatus extends CatalogEntityStatus { phase: WebLinkStatusPhase; } -export type WebLinkSpec = { +export interface WebLinkSpec { url: string; -}; +} export class WebLink extends CatalogEntity { public static readonly apiVersion = "entity.k8slens.dev/v1alpha1"; @@ -25,30 +22,9 @@ export class WebLink extends CatalogEntity WeblinkStore.getInstance().removeById(this.metadata.uid), - confirm: { - message: `Remove Web Link "${this.metadata.name}" from ${productName}?`, - }, - }); - } - - catalogCategoryRegistry - .getCategoryForEntity(this) - ?.emit("contextMenuOpen", this, context); - } } export class WebLinkCategory extends CatalogCategory { @@ -71,5 +47,3 @@ export class WebLinkCategory extends CatalogCategory { }, }; } - -catalogCategoryRegistry.add(new WebLinkCategory()); diff --git a/src/common/catalog/catalog-category-registry.ts b/src/common/catalog/catalog-category-registry.ts index ed0bddd4a8..0a186296f6 100644 --- a/src/common/catalog/catalog-category-registry.ts +++ b/src/common/catalog/catalog-category-registry.ts @@ -5,7 +5,7 @@ import { action, computed, observable, makeObservable } from "mobx"; import { Disposer, ExtendedMap, iter } from "../utils"; -import { CatalogCategory, CatalogEntityData, CatalogEntityKindData } from "./catalog-entity"; +import { CatalogCategory, CatalogEntity, CatalogEntityData, CatalogEntityKindData } from "./catalog-entity"; import { once } from "lodash"; export type CategoryFilter = (category: CatalogCategory) => any; @@ -37,22 +37,19 @@ export class CatalogCategoryRegistry { return Array.from(this.categories); } - @computed get filteredItems() { - return Array.from( - iter.reduce( - this.filters, - iter.filter, - this.items.values(), - ), - ); + readonly filteredItems = computed(() => Array.from( + iter.reduce( + this.filters, + iter.filter, + this.categories.values(), + ), + )); + + getForGroupKind(group: string, kind: string): CatalogCategory | undefined { + return this.groupKinds.get(group)?.get(kind); } - - getForGroupKind(group: string, kind: string): T | undefined { - return this.groupKinds.get(group)?.get(kind) as T; - } - - getEntityForData(data: CatalogEntityData & CatalogEntityKindData) { + getEntityForData = (data: CatalogEntityData & CatalogEntityKindData): CatalogEntity | null => { const category = this.getCategoryForEntity(data); if (!category) { @@ -69,18 +66,23 @@ export class CatalogCategoryRegistry { } return new specVersion.entityClass(data); - } + }; - getCategoryForEntity(data: CatalogEntityData & CatalogEntityKindData): T | undefined { - const splitApiVersion = data.apiVersion.split("/"); - const group = splitApiVersion[0]; + getCategoryForEntity = ({ kind, apiVersion }: CatalogEntityData & CatalogEntityKindData): T => { + const group = apiVersion.split("/")[0]; + const category = this.getForGroupKind(group, kind); - return this.getForGroupKind(group, data.kind); - } + if (!category) { + // Throw here because it is very important that this is always true + throw new Error(`Unable to find a category for group=${group} kind=${kind}`); + } - getByName(name: string) { + return category as T; + }; + + getByName = (name: string) => { return this.items.find(category => category.metadata?.name == name); - } + }; /** * Add a new filter to the set of category filters @@ -93,5 +95,3 @@ export class CatalogCategoryRegistry { return once(() => void this.filters.delete(fn)); } } - -export const catalogCategoryRegistry = new CatalogCategoryRegistry(); diff --git a/src/common/catalog/catalog-entity.ts b/src/common/catalog/catalog-entity.ts index 46ce8228ff..e696391159 100644 --- a/src/common/catalog/catalog-entity.ts +++ b/src/common/catalog/catalog-entity.ts @@ -351,7 +351,7 @@ export abstract class CatalogEntity< return this.status.enabled ?? true; } - public abstract onRun?(context: CatalogEntityActionContext): void | Promise; - public abstract onContextMenuOpen(context: CatalogEntityContextMenuContext): void | Promise; - public abstract onSettingsOpen(context: CatalogEntitySettingsContext): void | Promise; + public onRun?(context: CatalogEntityActionContext): void; + public onContextMenuOpen?(context: CatalogEntityContextMenuContext): void; + public onSettingsOpen?(context: CatalogEntitySettingsContext): void; } diff --git a/src/common/cluster-store/get-cluster-by-id.injectable.ts b/src/common/cluster-store/get-cluster-by-id.injectable.ts new file mode 100644 index 0000000000..e1131dd70a --- /dev/null +++ b/src/common/cluster-store/get-cluster-by-id.injectable.ts @@ -0,0 +1,13 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import clusterStoreInjectable from "./store.injectable"; + +const getClusterByIdInjectable = getInjectable({ + instantiate: (di) => di.inject(clusterStoreInjectable).getById, + lifecycle: lifecycleEnum.singleton, +}); + +export default getClusterByIdInjectable; diff --git a/src/common/cluster-store/hosted-cluster/hosted-cluster.injectable.ts b/src/common/cluster-store/hosted-cluster/hosted-cluster.injectable.ts index 6a11c80a0e..e5d26ead55 100644 --- a/src/common/cluster-store/hosted-cluster/hosted-cluster.injectable.ts +++ b/src/common/cluster-store/hosted-cluster/hosted-cluster.injectable.ts @@ -4,15 +4,10 @@ */ import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; import { getHostedClusterId } from "../../utils"; -import clusterStoreInjectable from "../cluster-store.injectable"; +import clusterStoreInjectable from "../store.injectable"; const hostedClusterInjectable = getInjectable({ - instantiate: (di) => { - const hostedClusterId = getHostedClusterId(); - - return di.inject(clusterStoreInjectable).getById(hostedClusterId); - }, - + instantiate: (di) => di.inject(clusterStoreInjectable).getById(getHostedClusterId()), lifecycle: lifecycleEnum.singleton, }); diff --git a/src/common/cluster-store/migrations-injection-token.ts b/src/common/cluster-store/migrations-injection-token.ts new file mode 100644 index 0000000000..b5335c0275 --- /dev/null +++ b/src/common/cluster-store/migrations-injection-token.ts @@ -0,0 +1,9 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectionToken } from "@ogre-tools/injectable"; +import type { Migrations } from "conf/dist/source/types"; +import type { ClusterStoreModel } from "./store"; + +export const clusterStoreMigrationsInjectionToken = getInjectionToken | undefined>(); diff --git a/src/common/cluster-store/cluster-store.injectable.ts b/src/common/cluster-store/store.injectable.ts similarity index 59% rename from src/common/cluster-store/cluster-store.injectable.ts rename to src/common/cluster-store/store.injectable.ts index e0b4563ea8..5f7d19e859 100644 --- a/src/common/cluster-store/cluster-store.injectable.ts +++ b/src/common/cluster-store/store.injectable.ts @@ -3,14 +3,15 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; -import { ClusterStore } from "./cluster-store"; +import { ClusterStore } from "./store"; import { createClusterInjectionToken } from "../cluster/create-cluster-injection-token"; +import { clusterStoreMigrationsInjectionToken } from "./migrations-injection-token"; const clusterStoreInjectable = getInjectable({ - instantiate: (di) => - ClusterStore.createInstance({ - createCluster: di.inject(createClusterInjectionToken), - }), + instantiate: (di) => new ClusterStore({ + createCluster: di.inject(createClusterInjectionToken), + migrations: di.inject(clusterStoreMigrationsInjectionToken), + }), lifecycle: lifecycleEnum.singleton, }); diff --git a/src/common/cluster-store/cluster-store.ts b/src/common/cluster-store/store.ts similarity index 93% rename from src/common/cluster-store/cluster-store.ts rename to src/common/cluster-store/store.ts index 41617e11a8..ec06fc1263 100644 --- a/src/common/cluster-store/cluster-store.ts +++ b/src/common/cluster-store/store.ts @@ -2,18 +2,18 @@ * Copyright (c) OpenLens Authors. All rights reserved. * Licensed under MIT License. See LICENSE in root directory for more information. */ - + import { ipcMain, ipcRenderer, webFrame } from "electron"; import { action, comparer, computed, makeObservable, observable, reaction } from "mobx"; import { BaseStore } from "../base-store"; import { Cluster } from "../cluster/cluster"; -import migrations from "../../migrations/cluster-store"; import logger from "../../main/logger"; import { appEventBus } from "../app-event-bus/event-bus"; import { ipcMainHandle, requestMain } from "../ipc"; import { disposer, toJS } from "../utils"; import type { ClusterModel, ClusterId, ClusterState } from "../cluster-types"; +import type { Migrations } from "conf/dist/source/types"; export interface ClusterStoreModel { clusters?: ClusterModel[]; @@ -22,7 +22,8 @@ export interface ClusterStoreModel { const initialStates = "cluster:states"; interface Dependencies { - createCluster: (model: ClusterModel) => Cluster + createCluster: (model: ClusterModel) => Cluster; + migrations: Migrations; } export class ClusterStore extends BaseStore { @@ -38,7 +39,7 @@ export class ClusterStore extends BaseStore { syncOptions: { equals: comparer.structural, }, - migrations, + migrations: dependencies.migrations, }); makeObservable(this); @@ -103,9 +104,9 @@ export class ClusterStore extends BaseStore { return this.clusters.size > 0; } - getById(id: ClusterId): Cluster | null { + getById = (id: ClusterId): Cluster | null => { return this.clusters.get(id) ?? null; - } + }; addCluster(clusterOrModel: ClusterModel | Cluster): Cluster { appEventBus.emit({ name: "cluster", action: "add" }); diff --git a/src/common/cluster/cluster.ts b/src/common/cluster/cluster.ts index 911c2dd8fa..c51718c59e 100644 --- a/src/common/cluster/cluster.ts +++ b/src/common/cluster/cluster.ts @@ -13,19 +13,20 @@ import type { KubeconfigManager } from "../../main/kubeconfig-manager/kubeconfig import { loadConfigFromFile, loadConfigFromFileSync, validateKubeConfig } from "../kube-helpers"; import { apiResourceRecord, apiResources, KubeApiResource, KubeResource } from "../rbac"; import logger from "../../main/logger"; -import { VersionDetector } from "../../main/cluster-detectors/version-detector"; -import { DetectorRegistry } from "../../main/cluster-detectors/detector-registry"; import plimit from "p-limit"; import type { ClusterState, ClusterRefreshOptions, ClusterMetricsResourceType, ClusterId, ClusterMetadata, ClusterModel, ClusterPreferences, ClusterPrometheusPreferences, UpdateClusterModel, KubeAuthUpdate } from "../cluster-types"; import { ClusterMetadataKey, initialNodeShellImage, ClusterStatus } from "../cluster-types"; import { disposer, toJS } from "../utils"; import type { Response } from "request"; +import type { ClusterDetectionResult } from "../../main/cluster-detectors/base-cluster-detector"; -interface Dependencies { - directoryForKubeConfigs: string, - createKubeconfigManager: (cluster: Cluster) => KubeconfigManager, - createContextHandler: (cluster: Cluster) => ContextHandler, - createKubectl: (clusterVersion: string) => Kubectl +export interface ClusterDependencies { + directoryForKubeConfigs: string; + createKubeconfigManager: (cluster: Cluster) => KubeconfigManager; + createContextHandler: (cluster: Cluster) => ContextHandler; + createKubectl: (clusterVersion: string) => Kubectl; + detectMetadataForCluster: (cluster: Cluster) => Promise; + detectVersion: (cluster: Cluster) => Promise; } /** @@ -212,7 +213,11 @@ export class Cluster implements ClusterModel, ClusterState { return this.preferences.defaultNamespace; } - constructor(private dependencies: Dependencies, model: ClusterModel) { + static create(...args: ConstructorParameters) { + return new Cluster(...args); + } + + constructor(private readonly dependencies: ClusterDependencies, model: ClusterModel) { makeObservable(this); this.id = model.id; this.updateModel(model); @@ -414,7 +419,7 @@ export class Cluster implements ClusterModel, ClusterState { @action async refreshMetadata() { logger.info(`[CLUSTER]: refreshMetadata`, this.getMeta()); - const metadata = await DetectorRegistry.getInstance().detectForCluster(this); + const metadata = await this.dependencies.detectMetadataForCluster(this); const existingMetadata = this.metadata; this.metadata = Object.assign(existingMetadata, metadata); @@ -461,16 +466,15 @@ export class Cluster implements ClusterModel, ClusterState { /** * @internal */ - async getProxyKubeconfigPath(): Promise { + getProxyKubeconfigPath(): Promise { return this.proxyKubeconfigManager.getPath(); } protected async getConnectionStatus(): Promise { try { - const versionDetector = new VersionDetector(this); - const versionData = await versionDetector.detect(); + const { value } = await this.dependencies.detectVersion(this); - this.metadata.version = versionData.value; + this.metadata.version = value; return ClusterStatus.AccessGranted; } catch (error) { @@ -531,7 +535,7 @@ export class Cluster implements ClusterModel, ClusterState { /** * @internal */ - async isClusterAdmin(): Promise { + isClusterAdmin(): Promise { return this.canI({ namespace: "kube-system", resource: "*", @@ -542,7 +546,7 @@ export class Cluster implements ClusterModel, ClusterState { /** * @internal */ - async canUseWatchApi(customizeResource: V1ResourceAttributes = {}): Promise { + canUseWatchApi(customizeResource: V1ResourceAttributes = {}): Promise { return this.canI({ verb: "watch", resource: "*", @@ -697,9 +701,9 @@ export class Cluster implements ClusterModel, ClusterState { return true; // allowed by default for other resources } - isMetricHidden(resource: ClusterMetricsResourceType): boolean { + isMetricHidden = (resource: ClusterMetricsResourceType): boolean => { return Boolean(this.preferences.hiddenMetrics?.includes(resource)); - } + }; get nodeShellImage(): string { return this.preferences?.nodeShellImage || initialNodeShellImage; diff --git a/src/common/cluster/create-cluster-injection-token.ts b/src/common/cluster/create-cluster-injection-token.ts index 5f0570d793..8eb9b55d12 100644 --- a/src/common/cluster/create-cluster-injection-token.ts +++ b/src/common/cluster/create-cluster-injection-token.ts @@ -6,5 +6,4 @@ import { getInjectionToken } from "@ogre-tools/injectable"; import type { ClusterModel } from "../cluster-types"; import type { Cluster } from "./cluster"; -export const createClusterInjectionToken = - getInjectionToken<(model: ClusterModel) => Cluster>(); +export const createClusterInjectionToken = getInjectionToken<(model: ClusterModel) => Cluster>(); diff --git a/src/common/directory-for-lens-local-storage/directory-for-lens-local-storage.injectable.ts b/src/common/directory-for-lens-local-storage/directory-for-lens-local-storage.injectable.ts index 06eddb8d07..3efdbba9c4 100644 --- a/src/common/directory-for-lens-local-storage/directory-for-lens-local-storage.injectable.ts +++ b/src/common/directory-for-lens-local-storage/directory-for-lens-local-storage.injectable.ts @@ -4,7 +4,7 @@ */ import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; import path from "path"; -import directoryForUserDataInjectable from "../app-paths/directory-for-user-data/directory-for-user-data.injectable"; +import directoryForUserDataInjectable from "../app-paths/directory-for-user-data.injectable"; const directoryForLensLocalStorageInjectable = getInjectable({ instantiate: (di) => diff --git a/src/common/hotbar-store.injectable.ts b/src/common/fs/read-dir.injectable.ts similarity index 59% rename from src/common/hotbar-store.injectable.ts rename to src/common/fs/read-dir.injectable.ts index 3a20ccc4ce..501ecc1cdd 100644 --- a/src/common/hotbar-store.injectable.ts +++ b/src/common/fs/read-dir.injectable.ts @@ -3,11 +3,11 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; -import { HotbarStore } from "./hotbar-store"; +import fsInjectable from "./fs.injectable"; -const hotbarManagerInjectable = getInjectable({ - instantiate: () => HotbarStore.getInstance(), +const readDirInjectable = getInjectable({ + instantiate: (di) => di.inject(fsInjectable).readdir, lifecycle: lifecycleEnum.singleton, }); -export default hotbarManagerInjectable; +export default readDirInjectable; diff --git a/src/renderer/components/kube-object-menu/dependencies/api-manager.injectable.ts b/src/common/fs/read-file.injectable.ts similarity index 58% rename from src/renderer/components/kube-object-menu/dependencies/api-manager.injectable.ts rename to src/common/fs/read-file.injectable.ts index 4580b13824..5e2871a03d 100644 --- a/src/renderer/components/kube-object-menu/dependencies/api-manager.injectable.ts +++ b/src/common/fs/read-file.injectable.ts @@ -2,12 +2,12 @@ * Copyright (c) OpenLens Authors. All rights reserved. * Licensed under MIT License. See LICENSE in root directory for more information. */ -import { apiManager } from "../../../../common/k8s-api/api-manager"; import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import fsInjectable from "./fs.injectable"; -const apiManagerInjectable = getInjectable({ - instantiate: () => apiManager, +const readFileInjectable = getInjectable({ + instantiate: (di) => di.inject(fsInjectable).readFile, lifecycle: lifecycleEnum.singleton, }); -export default apiManagerInjectable; +export default readFileInjectable; diff --git a/src/common/fs/read-json-file.injectable.ts b/src/common/fs/read-json-file.injectable.ts new file mode 100644 index 0000000000..b944decadc --- /dev/null +++ b/src/common/fs/read-json-file.injectable.ts @@ -0,0 +1,13 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import fsInjectable from "./fs.injectable"; + +const readJsonFileInjectable = getInjectable({ + instantiate: (di) => di.inject(fsInjectable).readJson, + lifecycle: lifecycleEnum.singleton, +}); + +export default readJsonFileInjectable; diff --git a/src/common/user-store/user-store.injectable.ts b/src/common/fs/watch-file-path.injectable.ts similarity index 60% rename from src/common/user-store/user-store.injectable.ts rename to src/common/fs/watch-file-path.injectable.ts index 09a411d405..50bed127c7 100644 --- a/src/common/user-store/user-store.injectable.ts +++ b/src/common/fs/watch-file-path.injectable.ts @@ -3,12 +3,11 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; -import { UserStore } from "./user-store"; - -const userStoreInjectable = getInjectable({ - instantiate: () => UserStore.createInstance(), +import { watch } from "chokidar"; +const watchFilePathInjectable = getInjectable({ + instantiate: () => watch, lifecycle: lifecycleEnum.singleton, }); -export default userStoreInjectable; +export default watchFilePathInjectable; diff --git a/src/common/fs/write-json-file.injectable.ts b/src/common/fs/write-json-file.injectable.ts new file mode 100644 index 0000000000..04fa68d392 --- /dev/null +++ b/src/common/fs/write-json-file.injectable.ts @@ -0,0 +1,45 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import type { EnsureOptions, WriteOptions } from "fs-extra"; +import path from "path"; +import type { JsonValue } from "type-fest"; +import { bind } from "../utils"; +import fsInjectable from "./fs.injectable"; + +interface Dependencies { + writeJson: (file: string, object: any, options?: WriteOptions | BufferEncoding | string) => Promise; + ensureDir: (dir: string, options?: EnsureOptions | number) => Promise; +} + +async function writeJsonFile({ writeJson, ensureDir }: Dependencies, filePath: string, content: JsonValue, options?: WriteOptions | BufferEncoding) { + await ensureDir(path.dirname(filePath), { mode: 0o755 }); + + const resolvedOptions = typeof options === "string" + ? { + encoding: options, + } + : options; + + await writeJson(filePath, content, { + encoding: "utf-8", + spaces: 2, + ...resolvedOptions, + }); +} + +const writeJsonFileInjectable = getInjectable({ + instantiate: (di) => { + const { writeJson, ensureDir } = di.inject(fsInjectable); + + return bind(writeJsonFile, null, { + writeJson, + ensureDir, + }); + }, + lifecycle: lifecycleEnum.singleton, +}); + +export default writeJsonFileInjectable; diff --git a/src/common/getDiForUnitTesting.ts b/src/common/getDiForUnitTesting.ts new file mode 100644 index 0000000000..73ea83b1f2 --- /dev/null +++ b/src/common/getDiForUnitTesting.ts @@ -0,0 +1,39 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import glob from "glob"; +import { memoize } from "lodash/fp"; + +import { + createContainer, + ConfigurableDependencyInjectionContainer, +} from "@ogre-tools/injectable"; + +export const getDiForUnitTesting = () => { + const di: ConfigurableDependencyInjectionContainer = createContainer(); + + getInjectableFilePaths() + .map(key => { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const injectable = require(key).default; + + return { + id: key, + ...injectable, + aliases: [injectable, ...(injectable.aliases || [])], + }; + }) + + .forEach(injectable => di.register(injectable)); + + di.preventSideEffects(); + + return di; +}; + +const getInjectableFilePaths = memoize(() => [ + ...glob.sync("./**/*.injectable.{ts,tsx}", { cwd: __dirname }), + ...glob.sync("../extensions/**/*.injectable.{ts,tsx}", { cwd: __dirname }), +]); diff --git a/src/common/hotbar-store/__tests__/hotbar.test.ts b/src/common/hotbar-store/__tests__/hotbar.test.ts new file mode 100644 index 0000000000..37097847ef --- /dev/null +++ b/src/common/hotbar-store/__tests__/hotbar.test.ts @@ -0,0 +1,207 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { anyObject } from "jest-mock-extended"; +import { merge } from "lodash"; +import type { CatalogEntityData, CatalogEntityKindData, CatalogEntity } from "../../catalog"; +import type { LensLogger } from "../../logger"; +import { Hotbar } from "../hotbar"; +import { getEmptyHotbar } from "../hotbar-types"; + +function getMockCatalogEntity(data: Partial & CatalogEntityKindData): CatalogEntity { + return merge(data, { + getName: jest.fn(() => data.metadata?.name), + getId: jest.fn(() => data.metadata?.uid), + getSource: jest.fn(() => data.metadata?.source ?? "unknown"), + isEnabled: jest.fn(() => data.status?.enabled ?? true), + onContextMenuOpen: jest.fn(), + onSettingsOpen: jest.fn(), + metadata: {}, + spec: {}, + status: {}, + }) as CatalogEntity; +} + +const testCluster = getMockCatalogEntity({ + apiVersion: "v1", + kind: "Cluster", + status: { + phase: "Running", + }, + metadata: { + uid: "test", + name: "test", + labels: {}, + }, +}); + +const minikubeCluster = getMockCatalogEntity({ + apiVersion: "v1", + kind: "Cluster", + status: { + phase: "Running", + }, + metadata: { + uid: "minikube", + name: "minikube", + labels: {}, + }, +}); + +const awsCluster = getMockCatalogEntity({ + apiVersion: "v1", + kind: "Cluster", + status: { + phase: "Running", + }, + metadata: { + uid: "aws", + name: "aws", + labels: {}, + }, +}); + + +describe("Hotbar", () => { + let hotbar: Hotbar; + let logger: LensLogger; + + beforeEach(() => { + logger = { + debug: jest.fn(), + error: jest.fn(), + info: jest.fn(), + silly: jest.fn(), + warn: jest.fn(), + }; + hotbar = new Hotbar(getEmptyHotbar("Default"), { logger }); + }); + + it("adds items", () => { + hotbar.addItem(testCluster); + const items = hotbar.items.filter(Boolean); + + expect(items.length).toEqual(2); + }); + + it("removes items", () => { + hotbar.addItem(testCluster); + hotbar.removeItemById("test"); + hotbar.removeItemById("catalog-entity"); + const items = hotbar.items.filter(Boolean); + + expect(items).toStrictEqual([]); + }); + + it("does nothing if removing with invalid uid", () => { + hotbar.addItem(testCluster); + hotbar.removeItemById("invalid uid"); + const items = hotbar.items.filter(Boolean); + + expect(items.length).toEqual(2); + }); + + it("moves item to empty cell", () => { + hotbar.addItem(testCluster); + hotbar.addItem(minikubeCluster); + hotbar.addItem(awsCluster); + + expect(hotbar.items[6]).toBeNull(); + + hotbar.restackItems(1, 5); + + expect(hotbar.items[5]).toBeTruthy(); + expect(hotbar.items[5].entity.uid).toEqual("test"); + }); + + it("moves items down", () => { + hotbar.addItem(testCluster); + hotbar.addItem(minikubeCluster); + hotbar.addItem(awsCluster); + + // aws -> catalog + hotbar.restackItems(3, 0); + + const items = hotbar.items.map(item => item?.entity.uid || null); + + expect(items.slice(0, 4)).toEqual(["aws", "catalog-entity", "test", "minikube"]); + }); + + it("moves items up", () => { + hotbar.addItem(testCluster); + hotbar.addItem(minikubeCluster); + hotbar.addItem(awsCluster); + + // test -> aws + hotbar.restackItems(1, 3); + + const items = hotbar.items.map(item => item?.entity.uid || null); + + expect(items.slice(0, 4)).toEqual(["catalog-entity", "minikube", "aws", "test"]); + }); + + it("logs an error if cellIndex is out of bounds", () => { + hotbar.addItem(testCluster, -1); + expect(logger.error).toBeCalledWith("[HOTBAR-STORE]: cannot pin entity to hotbar outside of index range", anyObject()); + + hotbar.addItem(testCluster, 12); + expect(logger.error).toBeCalledWith("[HOTBAR-STORE]: cannot pin entity to hotbar outside of index range", anyObject()); + + hotbar.addItem(testCluster, 13); + expect(logger.error).toBeCalledWith("[HOTBAR-STORE]: cannot pin entity to hotbar outside of index range", anyObject()); + }); + + it("throws an error if getId is invalid or returns not a string", () => { + expect(() => hotbar.addItem({} as any)).toThrowError(TypeError); + expect(() => hotbar.addItem({ getId: () => true } as any)).toThrowError(TypeError); + }); + + it("throws an error if getName is invalid or returns not a string", () => { + expect(() => hotbar.addItem({ getId: () => "" } as any)).toThrowError(TypeError); + expect(() => hotbar.addItem({ getId: () => "", getName: () => 4 } as any)).toThrowError(TypeError); + }); + + it("does nothing when item moved to same cell", () => { + hotbar.addItem(testCluster); + hotbar.restackItems(1, 1); + + expect(hotbar.items[1].entity.uid).toEqual("test"); + }); + + it("new items takes first empty cell", () => { + hotbar.addItem(testCluster); + hotbar.addItem(awsCluster); + hotbar.restackItems(0, 3); + hotbar.addItem(minikubeCluster); + + expect(hotbar.items[0].entity.uid).toEqual("minikube"); + }); + + it("throws if invalid arguments provided", () => { + // Prevent writing to stderr during this render. + const { error, warn } = console; + + console.error = jest.fn(); + console.warn = jest.fn(); + + hotbar.addItem(testCluster); + + expect(() => hotbar.restackItems(-5, 0)).toThrow(); + expect(() => hotbar.restackItems(2, -1)).toThrow(); + expect(() => hotbar.restackItems(14, 1)).toThrow(); + expect(() => hotbar.restackItems(11, 112)).toThrow(); + + // Restore writing to stderr. + console.error = error; + console.warn = warn; + }); + + it("checks if entity already pinned to hotbar", () => { + hotbar.addItem(testCluster); + + expect(hotbar.hasItem(testCluster)).toBeTruthy(); + expect(hotbar.hasItem(awsCluster)).toBeFalsy(); + }); +}); diff --git a/src/common/hotbar-store/__tests__/store.test.ts b/src/common/hotbar-store/__tests__/store.test.ts new file mode 100644 index 0000000000..f18c6b9eaa --- /dev/null +++ b/src/common/hotbar-store/__tests__/store.test.ts @@ -0,0 +1,166 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { DependencyInjectionContainer } from "@ogre-tools/injectable"; +import mockFs from "mock-fs"; +import { getDisForUnitTesting } from "../../../test-utils/get-dis-for-unit-testing"; +import directoryForUserDataInjectable from "../../app-paths/directory-for-user-data.injectable"; +import type { HotbarStore } from "../store"; + +describe("HotbarStore", () => { + let hotbarStore: HotbarStore; + let mainDi: DependencyInjectionContainer; + + beforeEach(async () => { + const dis = getDisForUnitTesting({ doGeneralOverrides: true }); + + mockFs(); + + mainDi = dis.mainDi; + + mainDi.override(directoryForUserDataInjectable, () => "some-directory-for-user-data"); + + await dis.runSetups(); + }); + + beforeEach(() => { + mockFs({ + "some-directory-for-user-data": { + "lens-hotbar-store.json": JSON.stringify({}), + }, + }); + }); + + afterEach(() => { + mockFs.restore(); + }); + + describe("load", () => { + it("loads one hotbar by default", () => { + expect(hotbarStore.hotbars.length).toEqual(1); + }); + }); + + describe("add", () => { + it("adds a hotbar", () => { + hotbarStore.add({ name: "hottest" }); + expect(hotbarStore.hotbars.length).toEqual(2); + }); + }); + + describe("hotbar items", () => { + it("initially creates 12 empty cells", () => { + expect(hotbarStore.getActive().items.length).toEqual(12); + }); + + it("initially adds catalog entity as first item", () => { + expect(hotbarStore.getActive().items[0].entity.name).toEqual("Catalog"); + }); + }); + + describe("pre beta-5 migrations", () => { + beforeEach(() => { + const mockOpts = { + "some-directory-for-user-data": { + "lens-hotbar-store.json": JSON.stringify({ + __internal__: { + migrations: { + version: "5.0.0-beta.3", + }, + }, + "hotbars": [ + { + "id": "3caac17f-aec2-4723-9694-ad204465d935", + "name": "myhotbar", + "items": [ + { + "entity": { + "uid": "1dfa26e2ebab15780a3547e9c7fa785c", + }, + }, + { + "entity": { + "uid": "55b42c3c7ba3b04193416cda405269a5", + }, + }, + { + "entity": { + "uid": "176fd331968660832f62283219d7eb6e", + }, + }, + { + "entity": { + "uid": "61c4fb45528840ebad1badc25da41d14", + "name": "user1-context", + "source": "local", + }, + }, + { + "entity": { + "uid": "27d6f99fe9e7548a6e306760bfe19969", + "name": "foo2", + "source": "local", + }, + }, + null, + { + "entity": { + "uid": "c0b20040646849bb4dcf773e43a0bf27", + "name": "multinode-demo", + "source": "local", + }, + }, + null, + null, + null, + null, + null, + ], + }, + ], + }), + }, + }; + + mockFs(mockOpts); + }); + + afterEach(() => { + mockFs.restore(); + }); + + it("allows to retrieve a hotbar", () => { + const hotbar = hotbarStore.getById("3caac17f-aec2-4723-9694-ad204465d935"); + + expect(hotbar.id).toBe("3caac17f-aec2-4723-9694-ad204465d935"); + }); + + it("clears cells without entity", () => { + const items = hotbarStore.hotbars[0].items; + + expect(items[2]).toBeNull(); + }); + + it("adds extra data to cells with according entity", () => { + const items = hotbarStore.hotbars[0].items; + + expect(items[0]).toEqual({ + entity: { + name: "mycluster", + source: "local", + uid: "1dfa26e2ebab15780a3547e9c7fa785c", + }, + }); + + expect(items[1]).toEqual({ + entity: { + name: "my_shiny_cluster", + source: "remote", + uid: "55b42c3c7ba3b04193416cda405269a5", + }, + }); + }); + }); +}); diff --git a/src/common/hotbar-store/active-hotbar.injectable.ts b/src/common/hotbar-store/active-hotbar.injectable.ts new file mode 100644 index 0000000000..0569efd8c3 --- /dev/null +++ b/src/common/hotbar-store/active-hotbar.injectable.ts @@ -0,0 +1,18 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { computed } from "mobx"; +import hotbarStoreInjectable from "./store.injectable"; + +const activeHotbarInjectable = getInjectable({ + instantiate: (di) => { + const hotbarStore = di.inject(hotbarStoreInjectable); + + return computed(() => hotbarStore.getActive()); + }, + lifecycle: lifecycleEnum.singleton, +}); + +export default activeHotbarInjectable; diff --git a/src/common/hotbar-store/add-to-active-hotbar.injectable.ts b/src/common/hotbar-store/add-to-active-hotbar.injectable.ts new file mode 100644 index 0000000000..407101e11d --- /dev/null +++ b/src/common/hotbar-store/add-to-active-hotbar.injectable.ts @@ -0,0 +1,27 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import type { IComputedValue } from "mobx"; +import type { CatalogEntity } from "../catalog"; +import { bind } from "../utils"; +import activeHotbarInjectable from "./active-hotbar.injectable"; +import type { Hotbar } from "./hotbar"; + +interface Dependencies { + hotbar: IComputedValue; +} + +function addToActiveHotbar({ hotbar }: Dependencies, entity: CatalogEntity, cellIndex?: number) { + return hotbar.get().addItem(entity, cellIndex); +} + +const addToActiveHotbarInjectable = getInjectable({ + instantiate: (di) => bind(addToActiveHotbar, null, { + hotbar: di.inject(activeHotbarInjectable), + }), + lifecycle: lifecycleEnum.singleton, +}); + +export default addToActiveHotbarInjectable; diff --git a/src/common/hotbar-store/create-new-hotbar.injectable.ts b/src/common/hotbar-store/create-new-hotbar.injectable.ts new file mode 100644 index 0000000000..a62460de60 --- /dev/null +++ b/src/common/hotbar-store/create-new-hotbar.injectable.ts @@ -0,0 +1,13 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import hotbarStoreInjectable from "./store.injectable"; + +const createNewHotbarInjectable = getInjectable({ + instantiate: (di) => di.inject(hotbarStoreInjectable).add, + lifecycle: lifecycleEnum.singleton, +}); + +export default createNewHotbarInjectable; diff --git a/src/common/hotbar-store/get-hotbar-by-name.injectable.ts b/src/common/hotbar-store/get-hotbar-by-name.injectable.ts new file mode 100644 index 0000000000..83b3169e59 --- /dev/null +++ b/src/common/hotbar-store/get-hotbar-by-name.injectable.ts @@ -0,0 +1,13 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import hotbarStoreInjectable from "./store.injectable"; + +const getHotbarByNameInjectable = getInjectable({ + instantiate: (di) => di.inject(hotbarStoreInjectable).getByName, + lifecycle: lifecycleEnum.singleton, +}); + +export default getHotbarByNameInjectable; diff --git a/src/common/hotbar-types.ts b/src/common/hotbar-store/hotbar-types.ts similarity index 88% rename from src/common/hotbar-types.ts rename to src/common/hotbar-store/hotbar-types.ts index 0512c58ee5..5a2d81b4f1 100644 --- a/src/common/hotbar-types.ts +++ b/src/common/hotbar-store/hotbar-types.ts @@ -4,7 +4,7 @@ */ import * as uuid from "uuid"; -import { tuple, Tuple } from "./utils"; +import { tuple, Tuple } from "../utils"; export interface HotbarItem { entity: { @@ -17,8 +17,6 @@ export interface HotbarItem { } } -export type Hotbar = Required; - export interface CreateHotbarData { id?: string; name: string; @@ -31,7 +29,7 @@ export interface CreateHotbarOptions { export const defaultHotbarCells = 12; // Number is chosen to easy hit any item with keyboard -export function getEmptyHotbar(name: string, id: string = uuid.v4()): Hotbar { +export function getEmptyHotbar(name: string, id: string = uuid.v4()): Required { return { id, items: tuple.filled(defaultHotbarCells, null), diff --git a/src/common/hotbar-store/hotbar.ts b/src/common/hotbar-store/hotbar.ts new file mode 100644 index 0000000000..2511753349 --- /dev/null +++ b/src/common/hotbar-store/hotbar.ts @@ -0,0 +1,121 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { action } from "mobx"; +import type { CatalogEntity } from "../catalog"; +import { broadcastMessage, HotbarTooManyItems } from "../ipc"; +import type { LensLogger } from "../logger"; +import type { Tuple } from "../utils"; +import type { HotbarItem, defaultHotbarCells, CreateHotbarData } from "./hotbar-types"; + +export interface HotbarDependencies { + readonly logger: LensLogger; +} + +export class Hotbar { + public readonly id: string; + public name: string; + public readonly items: Tuple; + + constructor(data: Required, protected readonly dependencies: HotbarDependencies) { + this.id = data.id; + this.name = data.name; + this.items = data.items; + } + + setName = (name: string) => { + this.name = name; + }; + + addItem = action((item: CatalogEntity, cellIndex?: number) => { + const uid = item.getId(); + const name = item.getName(); + + if (typeof uid !== "string") { + throw new TypeError("item's id must be a string"); + } + + if (typeof name !== "string") { + throw new TypeError("item's name must be a string"); + } + + const newItem = { entity: { + uid, + name, + source: item.metadata.source, + }}; + + + if (this.hasItem(item)) { + return; + } + + if (cellIndex === undefined) { + // Add item to empty cell + const emptyCellIndex = this.items.indexOf(null); + + if (emptyCellIndex != -1) { + this.items[emptyCellIndex] = newItem; + } else { + broadcastMessage(HotbarTooManyItems); + } + } else if (0 <= cellIndex && cellIndex < this.items.length) { + this.items[cellIndex] = newItem; + } else { + this.dependencies.logger.error(`[HOTBAR-${this.id}]: cannot pin entity to hotbar outside of index range`, { entityId: uid, cellIndex }); + } + }); + + private findClosestEmptyIndex(from: number, direction = 1) { + let index = from; + + while(this.items[index] != null) { + index += direction; + } + + return index; + } + + /** + * Checks if entity already pinned to hotbar + * @returns boolean + */ + hasItem = (entity: CatalogEntity): boolean => { + return this.items.findIndex(item => item?.entity.uid === entity.metadata.uid) >= 0; + }; + + removeItemById = action((uid: string): void => { + const index = this.items.findIndex(item => item?.entity.uid === uid); + + if (index < 0) { + return; + } + + this.items[index] = null; + }); + + restackItems = action((from: number, to: number): void => { + const { items } = this; + const source = items[from]; + const moveDown = from < to; + + if (from < 0 || to < 0 || from >= items.length || to >= items.length || isNaN(from) || isNaN(to)) { + throw new Error("Invalid 'from' or 'to' arguments"); + } + + if (from == to) { + return; + } + + items.splice(from, 1, null); + + if (items[to] == null) { + items.splice(to, 1, source); + } else { + // Move cells up or down to closes empty cell + items.splice(this.findClosestEmptyIndex(to, moveDown ? -1 : 1), 1); + items.splice(to, 0, source); + } + }); +} diff --git a/src/common/hotbar-store/is-added-to-active-hotbar.injectable.ts b/src/common/hotbar-store/is-added-to-active-hotbar.injectable.ts new file mode 100644 index 0000000000..c7b5e57b38 --- /dev/null +++ b/src/common/hotbar-store/is-added-to-active-hotbar.injectable.ts @@ -0,0 +1,27 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import type { IComputedValue } from "mobx"; +import type { CatalogEntity } from "../catalog"; +import { bind } from "../utils"; +import activeHotbarInjectable from "./active-hotbar.injectable"; +import type { Hotbar } from "./hotbar"; + +interface Dependencies { + hotbar: IComputedValue; +} + +function isAddedToActiveHotbar({ hotbar }: Dependencies, entity: CatalogEntity) { + return hotbar.get().hasItem(entity); +} + +const isItemInActiveHotbarInjectable = getInjectable({ + instantiate: (di) => bind(isAddedToActiveHotbar, null, { + hotbar: di.inject(activeHotbarInjectable), + }), + lifecycle: lifecycleEnum.singleton, +}); + +export default isItemInActiveHotbarInjectable; diff --git a/src/common/hotbar-store/migrations-injectable-token.ts b/src/common/hotbar-store/migrations-injectable-token.ts new file mode 100644 index 0000000000..73a6ee50b6 --- /dev/null +++ b/src/common/hotbar-store/migrations-injectable-token.ts @@ -0,0 +1,9 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectionToken } from "@ogre-tools/injectable"; +import type { Migrations } from "conf/dist/source/types"; +import type { HotbarStoreModel } from "./store"; + +export const hotbarStoreMigrationsInjectionToken = getInjectionToken | undefined>(); diff --git a/src/common/hotbar-store/remove-all-hotbar-items.injectable.ts b/src/common/hotbar-store/remove-all-hotbar-items.injectable.ts new file mode 100644 index 0000000000..af188d8839 --- /dev/null +++ b/src/common/hotbar-store/remove-all-hotbar-items.injectable.ts @@ -0,0 +1,13 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import hotbarStoreInjectable from "./store.injectable"; + +const removeAllHotbarItemsInjectable = getInjectable({ + instantiate: (di) => di.inject(hotbarStoreInjectable).removeAllHotbarItems, + lifecycle: lifecycleEnum.singleton, +}); + +export default removeAllHotbarItemsInjectable; diff --git a/src/common/hotbar-store/remove-from-active-hotbar.injectable.ts b/src/common/hotbar-store/remove-from-active-hotbar.injectable.ts new file mode 100644 index 0000000000..65df1a266d --- /dev/null +++ b/src/common/hotbar-store/remove-from-active-hotbar.injectable.ts @@ -0,0 +1,26 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import type { IComputedValue } from "mobx"; +import { bind } from "../utils"; +import activeHotbarInjectable from "./active-hotbar.injectable"; +import type { Hotbar } from "./hotbar"; + +interface Dependencies { + hotbar: IComputedValue; +} + +function removeByIdFromActiveHotbar({ hotbar }: Dependencies, id: string) { + return hotbar.get().removeItemById(id); +} + +const removeByIdFromActiveHotbarInjectable = getInjectable({ + instantiate: (di) => bind(removeByIdFromActiveHotbar, null, { + hotbar: di.inject(activeHotbarInjectable), + }), + lifecycle: lifecycleEnum.singleton, +}); + +export default removeByIdFromActiveHotbarInjectable; diff --git a/src/common/hotbar-store/store.injectable.ts b/src/common/hotbar-store/store.injectable.ts new file mode 100644 index 0000000000..db0d398b27 --- /dev/null +++ b/src/common/hotbar-store/store.injectable.ts @@ -0,0 +1,16 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { hotbarStoreMigrationsInjectionToken } from "./migrations-injectable-token"; +import { HotbarStore } from "./store"; + +const hotbarStoreInjectable = getInjectable({ + instantiate: (di) => new HotbarStore({ + migrations: di.inject(hotbarStoreMigrationsInjectionToken), + }), + lifecycle: lifecycleEnum.singleton, +}); + +export default hotbarStoreInjectable; diff --git a/src/common/hotbar-store.ts b/src/common/hotbar-store/store.ts similarity index 57% rename from src/common/hotbar-store.ts rename to src/common/hotbar-store/store.ts index 9375ba7231..34bb4a40c7 100644 --- a/src/common/hotbar-store.ts +++ b/src/common/hotbar-store/store.ts @@ -4,26 +4,29 @@ */ import { action, comparer, observable, makeObservable, computed } from "mobx"; -import { BaseStore } from "./base-store"; -import migrations from "../migrations/hotbar-store"; -import { toJS } from "./utils"; -import { CatalogEntity } from "./catalog"; -import { catalogEntity } from "../main/catalog-sources/general"; -import logger from "../main/logger"; -import { broadcastMessage, HotbarTooManyItems } from "./ipc"; -import { defaultHotbarCells, getEmptyHotbar, Hotbar, CreateHotbarData, CreateHotbarOptions } from "./hotbar-types"; +import { BaseStore } from "../base-store"; +import { toJS } from "../utils"; +import { catalogEntity } from "../../main/catalog-sources/general"; +import { defaultHotbarCells, getEmptyHotbar, CreateHotbarData, CreateHotbarOptions } from "./hotbar-types"; +import { Hotbar } from "./hotbar"; +import logger from "../logger"; +import type { Migrations } from "conf/dist/source/types"; export interface HotbarStoreModel { - hotbars: Hotbar[]; + hotbars: Required[]; activeHotbarId: string; } +export interface HotbarStoreDependencies { + migrations: Migrations | undefined; +} + export class HotbarStore extends BaseStore { readonly displayName = "HotbarStore"; @observable hotbars: Hotbar[] = []; @observable private _activeHotbarId: string; - constructor() { + constructor({ migrations }: HotbarStoreDependencies) { super({ configName: "lens-hotbar-store", accessPropertiesByDotNotation: false, // To make dots safe in cluster context names @@ -81,9 +84,9 @@ export class HotbarStore extends BaseStore { hotbar.items[0] = initialItem; - this.hotbars = [hotbar]; + this.hotbars = [new Hotbar(hotbar, { logger })]; } else { - this.hotbars = data.hotbars; + this.hotbars = data.hotbars.map(hotbar => new Hotbar(hotbar, { logger })); } this.hotbars.forEach(ensureExactHotbarItemLength); @@ -110,16 +113,16 @@ export class HotbarStore extends BaseStore { return this.getById(this.activeHotbarId); } - getByName(name: string) { + getByName = (name: string) => { return this.hotbars.find((hotbar) => hotbar.name === name); - } + }; getById(id: string) { return this.hotbars.find((hotbar) => hotbar.id === id); } add = action((data: CreateHotbarData, { setActive = false }: CreateHotbarOptions = {}) => { - const hotbar = getEmptyHotbar(data.name, data.id); + const hotbar = new Hotbar(getEmptyHotbar(data.name, data.id), { logger }); this.hotbars.push(hotbar); @@ -150,138 +153,35 @@ export class HotbarStore extends BaseStore { } }); - @action - addToHotbar(item: CatalogEntity, cellIndex?: number) { - const hotbar = this.getActive(); - const uid = item.metadata?.uid; - const name = item.metadata?.name; - - if (typeof uid !== "string") { - throw new TypeError("CatalogEntity.metadata.uid must be a string"); - } - - if (typeof name !== "string") { - throw new TypeError("CatalogEntity.metadata.name must be a string"); - } - - const newItem = { entity: { - uid, - name, - source: item.metadata.source, - }}; - - - if (this.isAddedToActive(item)) { - return; - } - - if (cellIndex === undefined) { - // Add item to empty cell - const emptyCellIndex = hotbar.items.indexOf(null); - - if (emptyCellIndex != -1) { - hotbar.items[emptyCellIndex] = newItem; - } else { - broadcastMessage(HotbarTooManyItems); - } - } else if (0 <= cellIndex && cellIndex < hotbar.items.length) { - hotbar.items[cellIndex] = newItem; - } else { - logger.error(`[HOTBAR-STORE]: cannot pin entity to hotbar outside of index range`, { entityId: uid, hotbarId: hotbar.id, cellIndex }); - } - } - - @action - removeFromHotbar(uid: string): void { - const hotbar = this.getActive(); - const index = hotbar.items.findIndex(item => item?.entity.uid === uid); - - if (index < 0) { - return; - } - - hotbar.items[index] = null; - } - /** * Remove all hotbar items that reference the `uid`. * @param uid The `EntityId` that each hotbar item refers to * @returns A function that will (in an action) undo the removing of the hotbar items. This function will not complete if the hotbar has changed. */ - @action - removeAllHotbarItems(uid: string) { + removeAllHotbarItems = action((uid: string) => { for (const hotbar of this.hotbars) { - const index = hotbar.items.findIndex((i) => i?.entity.uid === uid); - - if (index >= 0) { - hotbar.items[index] = null; - } + hotbar.removeItemById(uid); } - } - - findClosestEmptyIndex(from: number, direction = 1) { - let index = from; - - while(this.getActive().items[index] != null) { - index += direction; - } - - return index; - } - - @action - restackItems(from: number, to: number): void { - const { items } = this.getActive(); - const source = items[from]; - const moveDown = from < to; - - if (from < 0 || to < 0 || from >= items.length || to >= items.length || isNaN(from) || isNaN(to)) { - throw new Error("Invalid 'from' or 'to' arguments"); - } - - if (from == to) { - return; - } - - items.splice(from, 1, null); - - if (items[to] == null) { - items.splice(to, 1, source); - } else { - // Move cells up or down to closes empty cell - items.splice(this.findClosestEmptyIndex(to, moveDown ? -1 : 1), 1); - items.splice(to, 0, source); - } - } + }); switchToPrevious() { - const hotbarStore = HotbarStore.getInstance(); - let index = hotbarStore.activeHotbarIndex - 1; + let index = this.activeHotbarIndex - 1; if (index < 0) { - index = hotbarStore.hotbars.length - 1; + index = this.hotbars.length - 1; } - hotbarStore.setActiveHotbar(index); + this.setActiveHotbar(index); } switchToNext() { - const hotbarStore = HotbarStore.getInstance(); - let index = hotbarStore.activeHotbarIndex + 1; + let index = this.activeHotbarIndex + 1; - if (index >= hotbarStore.hotbars.length) { + if (index >= this.hotbars.length) { index = 0; } - hotbarStore.setActiveHotbar(index); - } - - /** - * Checks if entity already pinned to hotbar - * @returns boolean - */ - isAddedToActive(entity: CatalogEntity) { - return !!this.getActive().items.find(item => item?.entity.uid === entity.metadata.uid); + this.setActiveHotbar(index); } getDisplayLabel(hotbar: Hotbar): string { diff --git a/src/common/ipc/ipc.ts b/src/common/ipc/ipc.ts index 78ef1f3a16..da1fcc91aa 100644 --- a/src/common/ipc/ipc.ts +++ b/src/common/ipc/ipc.ts @@ -28,7 +28,7 @@ const electronRemote = (() => { const subFramesChannel = "ipc:get-sub-frames"; -export async function requestMain(channel: string, ...args: any[]) { +export function requestMain(channel: string, ...args: any[]) { return ipcRenderer.invoke(channel, ...args.map(sanitizePayload)); } diff --git a/src/common/ipc/type-enforced-ipc.ts b/src/common/ipc/type-enforced-ipc.ts index f53f9a2c05..5e658bba88 100644 --- a/src/common/ipc/type-enforced-ipc.ts +++ b/src/common/ipc/type-enforced-ipc.ts @@ -38,7 +38,7 @@ export function onceCorrect< if (verifier(args)) { source.removeListener(channel, wrappedListener); // remove immediately - (async () => (listener(event, ...args)))() // might return a promise, or throw, or reject + (async () => await listener(event, ...args))() // might return a promise, or throw, or reject .catch((error: any) => logger.error("[IPC]: channel once handler threw error", { channel, error })); } else { logger.error("[IPC]: channel was emitted with invalid data", { channel, args }); @@ -70,7 +70,7 @@ export function onCorrect< }): Disposer { function wrappedListener(event: ListenerEvent, ...args: unknown[]) { if (verifier(args)) { - (async () => (listener(event, ...args)))() // might return a promise, or throw, or reject + (async () => await listener(event, ...args))() // might return a promise, or throw, or reject .catch(error => logger.error("[IPC]: channel on handler threw error", { channel, error })); } else { logger.error("[IPC]: channel was emitted with invalid data", { channel, args }); diff --git a/src/common/item.store.ts b/src/common/item.store.ts index c2296a7515..7373cda1ae 100644 --- a/src/common/item.store.ts +++ b/src/common/item.store.ts @@ -28,6 +28,8 @@ export abstract class ItemStore { autoBind(this); } + readonly computedItems = computed(() => [...this.items]); + @computed get selectedItems(): Item[] { return this.items.filter(item => this.selectedItemsIds.get(item.getId())); } diff --git a/src/common/k8s-api/__tests__/api-manager.test.ts b/src/common/k8s-api/__tests__/api-manager.test.ts index 25d613ee1c..6d5c129fcf 100644 --- a/src/common/k8s-api/__tests__/api-manager.test.ts +++ b/src/common/k8s-api/__tests__/api-manager.test.ts @@ -3,45 +3,53 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import { ingressStore } from "../../../renderer/components/+network-ingresses/ingress.store"; -import { apiManager } from "../api-manager"; +import type { ConfigurableDependencyInjectionContainer } from "@ogre-tools/injectable"; +import { getDiForUnitTesting } from "../../getDiForUnitTesting"; +import type { ApiManager } from "../api-manager"; +import apiManagerInjectable from "../api-manager.injectable"; import { KubeApi } from "../kube-api"; import { KubeObject } from "../kube-object"; +import { KubeObjectStore } from "../kube-object.store"; class TestApi extends KubeApi { - - protected async checkPreferredVersion() { - return; + protected checkPreferredVersion() { + return Promise.resolve(); } } describe("ApiManager", () => { - describe("registerApi", () => { - it("re-register store if apiBase changed", async () => { - const apiBase = "apis/v1/foo"; - const fallbackApiBase = "/apis/extensions/v1beta1/foo"; - const kubeApi = new TestApi({ - objectConstructor: KubeObject, - apiBase, - fallbackApiBases: [fallbackApiBase], - checkPreferredVersion: true, - }); + let di: ConfigurableDependencyInjectionContainer; + let apiManager: ApiManager; - apiManager.registerApi(apiBase, kubeApi); + beforeEach(() => { + di = getDiForUnitTesting(); + apiManager = di.inject(apiManagerInjectable); + }); - // Define to use test api for ingress store - Object.defineProperty(ingressStore, "api", { value: kubeApi }); - apiManager.registerStore(ingressStore, [kubeApi]); - - // Test that store is returned with original apiBase - expect(apiManager.getStore(kubeApi)).toBe(ingressStore); - - // Change apiBase similar as checkPreferredVersion does - Object.defineProperty(kubeApi, "apiBase", { value: fallbackApiBase }); - apiManager.registerApi(fallbackApiBase, kubeApi); - - // Test that store is returned with new apiBase - expect(apiManager.getStore(kubeApi)).toBe(ingressStore); + it("allows apis to be accessible by their new apiBase if it changes", () => { + const apiBase = "apis/v1/foo"; + const fallbackApiBase = "/apis/extensions/v1beta1/foo"; + const kubeApi = new TestApi({ + objectConstructor: KubeObject, + apiBase, + fallbackApiBases: [fallbackApiBase], + checkPreferredVersion: true, }); + const kubeStore = new class extends KubeObjectStore { + api = kubeApi; + }; + + apiManager.registerApi(kubeApi); + apiManager.registerStore(kubeStore); + + // Test that store is returned with original apiBase + expect(apiManager.getStore(kubeApi)).toBe(kubeStore); + + // Change apiBase similar as checkPreferredVersion does + Object.defineProperty(kubeApi, "apiBase", { value: fallbackApiBase }); + apiManager.registerApi(fallbackApiBase, kubeApi); + + // Test that store is returned with new apiBase + expect(apiManager.getStore(kubeApi)).toBe(kubeStore); }); }); diff --git a/src/common/k8s-api/__tests__/helm-charts.api.test.ts b/src/common/k8s-api/__tests__/helm-charts.api.test.ts index dbe477d333..dcec6b5564 100644 --- a/src/common/k8s-api/__tests__/helm-charts.api.test.ts +++ b/src/common/k8s-api/__tests__/helm-charts.api.test.ts @@ -4,7 +4,7 @@ */ import { anyObject } from "jest-mock-extended"; -import { HelmChart } from "../endpoints/helm-charts.api"; +import { HelmChart } from "../endpoints/helm-chart.api"; describe("HelmChart tests", () => { describe("HelmChart.create() tests", () => { diff --git a/src/common/k8s-api/__tests__/kube-api.test.ts b/src/common/k8s-api/__tests__/kube-api.test.ts index 12b4577b17..47ae8a507f 100644 --- a/src/common/k8s-api/__tests__/kube-api.test.ts +++ b/src/common/k8s-api/__tests__/kube-api.test.ts @@ -10,12 +10,6 @@ import { KubeObject } from "../kube-object"; import AbortController from "abort-controller"; import { delay } from "../../utils/delay"; import { PassThrough } from "stream"; -import { ApiManager, apiManager } from "../api-manager"; -import { Ingress, Pod } from "../endpoints"; - -jest.mock("../api-manager"); - -const mockApiManager = apiManager as jest.Mocked; class TestKubeObject extends KubeObject { static kind = "Pod"; @@ -24,13 +18,13 @@ class TestKubeObject extends KubeObject { } class TestKubeApi extends KubeApi { - public async checkPreferredVersion() { + public checkPreferredVersion() { return super.checkPreferredVersion(); } } describe("forRemoteCluster", () => { - it("builds api client for KubeObject", async () => { + it("builds api client for KubeObject", () => { const api = forRemoteCluster({ cluster: { server: "https://127.0.0.1:6443", @@ -43,7 +37,7 @@ describe("forRemoteCluster", () => { expect(api).toBeInstanceOf(KubeApi); }); - it("builds api client for given KubeApi", async () => { + it("builds api client for given KubeApi", () => { const api = forRemoteCluster({ cluster: { server: "https://127.0.0.1:6443", @@ -66,7 +60,7 @@ describe("forRemoteCluster", () => { }, }, TestKubeObject); - (fetch as any).mockResponse(async (request: any) => { + (fetch as any).mockResponse((request: any) => { expect(request.url).toEqual("https://127.0.0.1:6443/api/v1/pods"); return { @@ -91,7 +85,7 @@ describe("KubeApi", () => { }); it("uses url from apiBase if apiBase contains the resource", async () => { - (fetch as any).mockResponse(async (request: any) => { + (fetch as any).mockResponse((request: any) => { if (request.url === "http://127.0.0.1:9999/api-kube/apis/networking.k8s.io/v1") { return { body: JSON.stringify({ @@ -137,7 +131,7 @@ describe("KubeApi", () => { }); it("uses url from fallbackApiBases if apiBase lacks the resource", async () => { - (fetch as any).mockResponse(async (request: any) => { + (fetch as any).mockResponse((request: any) => { if (request.url === "http://127.0.0.1:9999/api-kube/apis/networking.k8s.io/v1") { return { body: JSON.stringify({ @@ -178,94 +172,6 @@ describe("KubeApi", () => { expect(kubeApi.apiGroup).toEqual("extensions"); }); - describe("checkPreferredVersion", () => { - it("registers with apiManager if checkPreferredVersion changes apiVersionPreferred", async () => { - expect.hasAssertions(); - - const api = new TestKubeApi({ - objectConstructor: Ingress, - checkPreferredVersion: true, - fallbackApiBases: ["/apis/extensions/v1beta1/ingresses"], - request: { - get: jest.fn() - .mockImplementationOnce((path: string) => { - expect(path).toBe("/apis/networking.k8s.io/v1"); - - throw new Error("no"); - }) - .mockImplementationOnce((path: string) => { - expect(path).toBe("/apis/extensions/v1beta1"); - - return { - resources: [ - { - name: "ingresses", - }, - ], - }; - }) - .mockImplementationOnce((path: string) => { - expect(path).toBe("/apis/extensions"); - - return { - preferredVersion: { - version: "v1beta1", - }, - }; - }), - } as any, - }); - - await api.checkPreferredVersion(); - - expect(api.apiVersionPreferred).toBe("v1beta1"); - expect(mockApiManager.registerApi).toBeCalledWith("/apis/extensions/v1beta1/ingresses", expect.anything()); - }); - - it("registers with apiManager if checkPreferredVersion changes apiVersionPreferred with non-grouped apis", async () => { - expect.hasAssertions(); - - const api = new TestKubeApi({ - objectConstructor: Pod, - checkPreferredVersion: true, - fallbackApiBases: ["/api/v1beta1/pods"], - request: { - get: jest.fn() - .mockImplementationOnce((path: string) => { - expect(path).toBe("/api/v1"); - - throw new Error("no"); - }) - .mockImplementationOnce((path: string) => { - expect(path).toBe("/api/v1beta1"); - - return { - resources: [ - { - name: "pods", - }, - ], - }; - }) - .mockImplementationOnce((path: string) => { - expect(path).toBe("/api"); - - return { - preferredVersion: { - version: "v1beta1", - }, - }; - }), - } as any, - }); - - await api.checkPreferredVersion(); - - expect(api.apiVersionPreferred).toBe("v1beta1"); - expect(mockApiManager.registerApi).toBeCalledWith("/api/v1beta1/pods", expect.anything()); - }); - }); - describe("patch", () => { let api: TestKubeApi; @@ -279,7 +185,7 @@ describe("KubeApi", () => { it("sends strategic patch by default", async () => { expect.hasAssertions(); - (fetch as any).mockResponse(async (request: Request) => { + (fetch as any).mockResponse((request: Request) => { expect(request.method).toEqual("PATCH"); expect(request.headers.get("content-type")).toMatch("strategic-merge-patch"); expect(request.body.toString()).toEqual(JSON.stringify({ spec: { replicas: 2 }})); @@ -295,7 +201,7 @@ describe("KubeApi", () => { it("allows to use merge patch", async () => { expect.hasAssertions(); - (fetch as any).mockResponse(async (request: Request) => { + (fetch as any).mockResponse((request: Request) => { expect(request.method).toEqual("PATCH"); expect(request.headers.get("content-type")).toMatch("merge-patch"); expect(request.body.toString()).toEqual(JSON.stringify({ spec: { replicas: 2 }})); @@ -311,7 +217,7 @@ describe("KubeApi", () => { it("allows to use json patch", async () => { expect.hasAssertions(); - (fetch as any).mockResponse(async (request: Request) => { + (fetch as any).mockResponse((request: Request) => { expect(request.method).toEqual("PATCH"); expect(request.headers.get("content-type")).toMatch("json-patch"); expect(request.body.toString()).toEqual(JSON.stringify([{ op: "replace", path: "/spec/replicas", value: 2 }])); @@ -327,7 +233,7 @@ describe("KubeApi", () => { it("allows deep partial patch", async () => { expect.hasAssertions(); - (fetch as any).mockResponse(async (request: Request) => { + (fetch as any).mockResponse((request: Request) => { expect(request.method).toEqual("PATCH"); expect(request.headers.get("content-type")).toMatch("merge-patch"); expect(request.body.toString()).toEqual(JSON.stringify({ metadata: { annotations: { provisioned: "true" }}})); @@ -355,7 +261,7 @@ describe("KubeApi", () => { it("sends correct request with empty namespace", async () => { expect.hasAssertions(); - (fetch as any).mockResponse(async (request: Request) => { + (fetch as any).mockResponse((request: Request) => { expect(request.method).toEqual("DELETE"); expect(request.url).toEqual("http://127.0.0.1:9999/api-kube/api/v1/pods/foo?propagationPolicy=Background"); @@ -367,7 +273,7 @@ describe("KubeApi", () => { it("sends correct request without namespace", async () => { expect.hasAssertions(); - (fetch as any).mockResponse(async (request: Request) => { + (fetch as any).mockResponse((request: Request) => { expect(request.method).toEqual("DELETE"); expect(request.url).toEqual("http://127.0.0.1:9999/api-kube/api/v1/namespaces/default/pods/foo?propagationPolicy=Background"); @@ -379,7 +285,7 @@ describe("KubeApi", () => { it("sends correct request with namespace", async () => { expect.hasAssertions(); - (fetch as any).mockResponse(async (request: Request) => { + (fetch as any).mockResponse((request: Request) => { expect(request.method).toEqual("DELETE"); expect(request.url).toEqual("http://127.0.0.1:9999/api-kube/api/v1/namespaces/kube-system/pods/foo?propagationPolicy=Background"); @@ -391,7 +297,7 @@ describe("KubeApi", () => { it("allows to change propagationPolicy", async () => { expect.hasAssertions(); - (fetch as any).mockResponse(async (request: Request) => { + (fetch as any).mockResponse((request: Request) => { expect(request.method).toEqual("DELETE"); expect(request.url).toMatch("propagationPolicy=Orphan"); @@ -422,7 +328,7 @@ describe("KubeApi", () => { it("sends a valid watch request", () => { const spy = jest.spyOn(request, "getResponse"); - (fetch as any).mockResponse(async () => { + (fetch as any).mockResponse(() => { return { body: stream, }; @@ -432,10 +338,10 @@ describe("KubeApi", () => { expect(spy).toHaveBeenCalledWith("/api/v1/namespaces/kube-system/pods?watch=1&resourceVersion=", expect.anything(), expect.anything()); }); - it("sends timeout as a query parameter", async () => { + it("sends timeout as a query parameter", () => { const spy = jest.spyOn(request, "getResponse"); - (fetch as any).mockResponse(async () => { + (fetch as any).mockResponse(() => { return { body: stream, }; @@ -448,7 +354,7 @@ describe("KubeApi", () => { it("aborts watch using abortController", async (done) => { const spy = jest.spyOn(request, "getResponse"); - (fetch as any).mockResponse(async (request: Request) => { + (fetch as any).mockResponse((request: Request) => { (request as any).signal.addEventListener("abort", () => { done(); }); @@ -489,7 +395,7 @@ describe("KubeApi", () => { }); // we need to mock using jest as jest-fetch-mock doesn't support mocking the body completely - jest.spyOn(global, "fetch").mockImplementation(async () => { + jest.spyOn(global, "fetch").mockImplementation(() => { return { ok: true, body: stream, @@ -511,7 +417,7 @@ describe("KubeApi", () => { it("if request not closed after timeout", (done) => { const spy = jest.spyOn(request, "getResponse"); - (fetch as any).mockResponse(async () => { + (fetch as any).mockResponse(() => { return { body: stream, }; @@ -547,7 +453,7 @@ describe("KubeApi", () => { }); // we need to mock using jest as jest-fetch-mock doesn't support mocking the body completely - jest.spyOn(global, "fetch").mockImplementation(async () => { + jest.spyOn(global, "fetch").mockImplementation(() => { return { ok: true, body: stream, @@ -588,7 +494,7 @@ describe("KubeApi", () => { it("should add kind and apiVersion", async () => { expect.hasAssertions(); - (fetch as any).mockResponse(async (request: Request) => { + (fetch as any).mockResponse((request: Request) => { expect(request.method).toEqual("POST"); expect(JSON.parse(request.body.toString())).toEqual({ kind: "Pod", @@ -642,7 +548,7 @@ describe("KubeApi", () => { it("doesn't override metadata.labels", async () => { expect.hasAssertions(); - (fetch as any).mockResponse(async (request: Request) => { + (fetch as any).mockResponse((request: Request) => { expect(request.method).toEqual("POST"); expect(JSON.parse(request.body.toString())).toEqual({ kind: "Pod", @@ -685,7 +591,7 @@ describe("KubeApi", () => { it("doesn't override metadata.labels", async () => { expect.hasAssertions(); - (fetch as any).mockResponse(async (request: Request) => { + (fetch as any).mockResponse((request: Request) => { expect(request.method).toEqual("PUT"); expect(JSON.parse(request.body.toString())).toEqual({ metadata: { diff --git a/src/common/k8s-api/api-manager.injectable.ts b/src/common/k8s-api/api-manager.injectable.ts new file mode 100644 index 0000000000..4ecc23006c --- /dev/null +++ b/src/common/k8s-api/api-manager.injectable.ts @@ -0,0 +1,256 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { ClusterStore } from "../../renderer/components/+cluster/store"; +import { HorizontalPodAutoscalerStore } from "../../renderer/components/+autoscalers/store"; +import { LimitRangeStore } from "../../renderer/components/+limit-ranges/store"; +import { ConfigMapStore } from "../../renderer/components/+config-maps/store"; +import { PodDisruptionBudgetStore } from "../../renderer/components/+pod-disruption-budgets/store"; +import { ResourceQuotaStore } from "../../renderer/components/+resource-quotas/store"; +import { SecretStore } from "../../renderer/components/+secrets/store"; +import { CustomResourceDefinitionStore } from "../../renderer/components/+custom-resource/store"; +import { EventStore } from "../../renderer/components/+events/store"; +import { NamespaceStore } from "../../renderer/components/+namespaces/store"; +import { EndpointStore } from "../../renderer/components/+endpoints/store"; +import { IngressStore } from "../../renderer/components/+ingresses/store"; +import { NetworkPolicyStore } from "../../renderer/components/+network-policies/store"; +import { ServiceStore } from "../../renderer/components/+services/store"; +import { NodeStore } from "../../renderer/components/+nodes/store"; +import { PodSecurityPolicyStore } from "../../renderer/components/+pod-security-policies/store"; +import { StorageClassStore } from "../../renderer/components/+storage-classes/store"; +import { PersistentVolumeClaimStore } from "../../renderer/components/+persistent-volume-claims/store"; +import { PersistentVolumeStore } from "../../renderer/components/+persistent-volumes/store"; +import { ClusterRoleBindingStore } from "../../renderer/components/+cluster-role-bindings/store"; +import { ClusterRoleStore } from "../../renderer/components/+cluster-roles/store"; +import { RoleBindingStore } from "../../renderer/components/+role-bindings/store"; +import { RoleStore } from "../../renderer/components/+roles/store"; +import { ServiceAccountStore } from "../../renderer/components/+service-accounts/store"; +import { CronJobStore } from "../../renderer/components/+cronjobs/store"; +import { DaemonSetStore } from "../../renderer/components/+daemonsets/store"; +import { DeploymentStore } from "../../renderer/components/+deployments/store"; +import { JobStore } from "../../renderer/components/+jobs/store"; +import { PodStore } from "../../renderer/components/+pods/store"; +import { ReplicaSetStore } from "../../renderer/components/+replica-sets/store"; +import { StatefulSetStore } from "../../renderer/components/+stateful-sets/store"; +import { ApiManager } from "./api-manager"; +import { ClusterApi, ClusterRoleApi, ClusterRoleBindingApi, ConfigMapApi, CronJobApi, CustomResourceDefinition, CustomResourceDefinitionApi, DaemonSetApi, DeploymentApi, EndpointApi, EventApi, HorizontalPodAutoscalerApi, IngressApi, JobApi, LimitRangeApi, NamespaceApi, NetworkPolicyApi, NodeApi, PersistentVolumeApi, PersistentVolumeClaimApi, PodApi, PodDisruptionBudgetApi, PodMetricsApi, PodSecurityPolicyApi, ReplicaSetApi, ResourceQuotaApi, RoleApi, RoleBindingApi, SecretApi, SelfSubjectRulesReviewApi, ServiceAccountApi, ServiceApi, StatefulSetApi, StorageClassApi } from "./endpoints"; +import { KubeApi } from "./kube-api"; +import { KubeObject } from "./kube-object"; +import { KubeObjectStore } from "./kube-object.store"; + +function createAndInit(): ApiManager { + const apiManager = new ApiManager(); + + const clusterApi = new ClusterApi(); + + apiManager.registerApi(clusterApi); + apiManager.registerStore(new ClusterStore(clusterApi)); + + const clusterRoleApi = new ClusterRoleApi(); + + apiManager.registerApi(clusterRoleApi); + apiManager.registerStore(new ClusterRoleStore(clusterRoleApi)); + + const clusterRoleBindingApi = new ClusterRoleBindingApi(); + + apiManager.registerApi(clusterRoleBindingApi); + apiManager.registerStore(new ClusterRoleBindingStore(clusterRoleBindingApi)); + + const configMapApi = new ConfigMapApi(); + + apiManager.registerApi(configMapApi); + apiManager.registerStore(new ConfigMapStore(configMapApi)); + + const podApi = new PodApi(); + const podStore = new PodStore(podApi); + + apiManager.registerApi(podApi); + apiManager.registerStore(podStore); + + const jobApi = new JobApi(); + const jobStore = new JobStore(jobApi, { + podStore, + }); + + apiManager.registerApi(jobApi); + apiManager.registerStore(jobStore); + + const cronJobApi = new CronJobApi(); + + apiManager.registerApi(cronJobApi); + apiManager.registerStore(new CronJobStore(cronJobApi, { + jobStore, + })); + + const customResourceDefinitionApi = new CustomResourceDefinitionApi(); + + apiManager.registerApi(customResourceDefinitionApi); + apiManager.registerStore(new CustomResourceDefinitionStore(customResourceDefinitionApi, { + initCustomResourceStore(crd: CustomResourceDefinition) { + const objectConstructor = class extends KubeObject { + static readonly kind = crd.getResourceKind(); + static readonly namespaced = crd.isNamespaced(); + static readonly apiBase = crd.getResourceApiBase(); + }; + + const api = apiManager.getApi(objectConstructor.apiBase) + ?? new KubeApi({ objectConstructor }); + + if (!apiManager.hasApi(api)) { + apiManager.registerApi(api); + } + + if (!apiManager.getStore(api)) { + apiManager.registerStore(new class extends KubeObjectStore { + api = api; + }); + } + }, + })); + + + const daemonSetApi = new DaemonSetApi(); + + apiManager.registerApi(daemonSetApi); + apiManager.registerStore(new DaemonSetStore(daemonSetApi, { + podStore, + })); + + const deploymentApi = new DeploymentApi(); + + apiManager.registerApi(deploymentApi); + apiManager.registerStore(new DeploymentStore(deploymentApi, { + podStore, + })); + + const endpointApi = new EndpointApi(); + + apiManager.registerApi(endpointApi); + apiManager.registerStore(new EndpointStore(endpointApi)); + + const eventApi = new EventApi(); + + apiManager.registerApi(eventApi); + apiManager.registerStore(new EventStore(eventApi, { + podStore, + })); + + const horizontalPodAutoscalerApi = new HorizontalPodAutoscalerApi(); + + apiManager.registerApi(horizontalPodAutoscalerApi); + apiManager.registerStore(new HorizontalPodAutoscalerStore(horizontalPodAutoscalerApi)); + + const ingressApi = new IngressApi(); + + apiManager.registerApi(ingressApi); + apiManager.registerStore(new IngressStore(ingressApi)); + + const limitRangeApi = new LimitRangeApi(); + + apiManager.registerApi(limitRangeApi); + apiManager.registerStore(new LimitRangeStore(limitRangeApi)); + + const namespaceApi = new NamespaceApi(); + + apiManager.registerApi(namespaceApi); + apiManager.registerStore(new NamespaceStore(namespaceApi)); + + const networkPolicyApi = new NetworkPolicyApi(); + + apiManager.registerApi(networkPolicyApi); + apiManager.registerStore(new NetworkPolicyStore(networkPolicyApi)); + + const nodeApi = new NodeApi(); + + apiManager.registerApi(nodeApi); + apiManager.registerStore(new NodeStore(nodeApi)); + + const persistentVolumeApi = new PersistentVolumeApi(); + const persistentVolumeStore = new PersistentVolumeStore(persistentVolumeApi); + + apiManager.registerApi(persistentVolumeApi); + apiManager.registerStore(persistentVolumeStore); + + const persistentVolumeClaimApi = new PersistentVolumeClaimApi(); + + apiManager.registerApi(persistentVolumeClaimApi); + apiManager.registerStore(new PersistentVolumeClaimStore(persistentVolumeClaimApi)); + + const podDisruptionBudgetApi = new PodDisruptionBudgetApi(); + + apiManager.registerApi(podDisruptionBudgetApi); + apiManager.registerStore(new PodDisruptionBudgetStore(podDisruptionBudgetApi)); + + const podSecurityPolicyApi = new PodSecurityPolicyApi(); + + apiManager.registerApi(podSecurityPolicyApi); + apiManager.registerStore(new PodSecurityPolicyStore(podSecurityPolicyApi)); + + const replicaSetApi = new ReplicaSetApi(); + + apiManager.registerApi(replicaSetApi); + apiManager.registerStore(new ReplicaSetStore(replicaSetApi, { + podStore, + })); + + const resourceQuotaApi = new ResourceQuotaApi(); + + apiManager.registerApi(resourceQuotaApi); + apiManager.registerStore(new ResourceQuotaStore(resourceQuotaApi)); + + const roleApi = new RoleApi(); + + apiManager.registerApi(roleApi); + apiManager.registerStore(new RoleStore(roleApi)); + + const roleBindingApi = new RoleBindingApi(); + + apiManager.registerApi(roleBindingApi); + apiManager.registerStore(new RoleBindingStore(roleBindingApi)); + + const secretApi = new SecretApi(); + + apiManager.registerApi(secretApi); + apiManager.registerStore(new SecretStore(secretApi)); + + const serviceAccountApi = new ServiceAccountApi(); + + apiManager.registerApi(serviceAccountApi); + apiManager.registerStore(new ServiceAccountStore(serviceAccountApi)); + + const serviceApi = new ServiceApi(); + + apiManager.registerApi(serviceApi); + apiManager.registerStore(new ServiceStore(serviceApi)); + + const statefulSetApi = new StatefulSetApi(); + + apiManager.registerApi(statefulSetApi); + apiManager.registerStore(new StatefulSetStore(statefulSetApi, { + podStore, + })); + + const storageClassApi = new StorageClassApi(); + + apiManager.registerApi(storageClassApi); + apiManager.registerStore(new StorageClassStore(storageClassApi, { + persistentVolumeStore, + })); + + + // There is no store for these apis, so just register them + apiManager.registerApi(new PodMetricsApi()); + apiManager.registerApi(new SelfSubjectRulesReviewApi()); + + return apiManager; +} + +const apiManagerInjectable = getInjectable({ + instantiate: createAndInit, + lifecycle: lifecycleEnum.singleton, +}); + +export default apiManagerInjectable; diff --git a/src/common/k8s-api/api-manager.ts b/src/common/k8s-api/api-manager.ts index d392673364..ee3a5836c5 100644 --- a/src/common/k8s-api/api-manager.ts +++ b/src/common/k8s-api/api-manager.ts @@ -5,44 +5,109 @@ import type { KubeObjectStore } from "./kube-object.store"; -import { action, observable, makeObservable } from "mobx"; +import { action, observable, makeObservable, computed } from "mobx"; import { autoBind, iter } from "../utils"; import type { KubeApi } from "./kube-api"; import type { KubeObject } from "./kube-object"; import { IKubeObjectRef, parseKubeApi, createKubeApiURL } from "./kube-api-parse"; export class ApiManager { - private apis = observable.map>(); - private stores = observable.map>(); + private apiSet = observable.set>(); + private stores = observable.map, KubeObjectStore>(); constructor() { makeObservable(this); autoBind(this); } - getApi(pathOrCallback: string | ((api: KubeApi) => boolean)) { - if (typeof pathOrCallback === "string") { - return this.apis.get(pathOrCallback) || this.apis.get(parseKubeApi(pathOrCallback).apiBase); + /** + * The private `apiBase` mapping of api instances. This is computed so that + * it can react to changes in the instances' apiBase fields. + */ + @computed private get apis() { + const res = new Map>(); + + for (const api of this.apiSet) { + if (typeof api.apiBase !== "string" || !api.apiBase) { + throw new TypeError("KubeApi.apiBase must be a non-empty string"); + } + + if (res.has(api.apiBase)) { + throw new Error(`Multiple api instances for ${api.apiBase}`); + } + + res.set(api.apiBase, api); } - return iter.find(this.apis.values(), pathOrCallback ?? (() => true)); + return res; } + /** + * @param api The instance to check if it has been registered + * @returns Returns `true` if the api instance has been registered + */ + hasApi(api: KubeApi): boolean { + return this.apiSet.has(api); + } + + /** + * Get a registered api, if a callback is provided then the registered + * instances are iterated until it returns `true` + * @param pathOrCallbacks Either the `apiBase` of an instance, a resource path for the kind of the api, or a callback function. Will search for each until one is found. + * @returns The kube api instance that was registered + */ + getApi(...pathOrCallbacks: (string | ((api: KubeApi) => boolean))[]): KubeApi | undefined { + for (const pathOrCallback of pathOrCallbacks) { + if (typeof pathOrCallback === "string") { + return this.apis.get(pathOrCallback) || this.apis.get(parseKubeApi(pathOrCallback).apiBase); + } + + return iter.find(this.apis.values(), pathOrCallback); + } + + return undefined; + } + + /** + * Get the registered api instance by the kube object kind and version + * @param kind The kind of resource that the api is for + * @param apiVersion The version of the resource that the api is for + * @returns The kube api instance that was registered + */ getApiByKind(kind: string, apiVersion: string) { return iter.find(this.apis.values(), api => api.kind === kind && api.apiVersionWithGroup === apiVersion); } - registerApi(apiBase: string, api: KubeApi) { - if (!api.apiBase) return; + /** + * Registeres `api` so that it can be retreived in the future. + * + * Notes: + * - Changes to the instance's `apiBase` field are reacted to for the `getApi()` method + * @param api The instance to register + * @throws if `api.apiBase` is not a non-empty string + * @throws if there is already an instance with the same `apiBase` registered + */ + registerApi(api: KubeApi): void; + /** + * @deprecated Just provide the `api` instance + */ + registerApi(apiOrBase: string, api: KubeApi): void + @action + registerApi(apiOrBase: string | KubeApi, api?: KubeApi): void { + api = typeof apiOrBase === "string" + ? api + : apiOrBase; - if (!this.apis.has(apiBase)) { - this.stores.forEach((store) => { - if (store.api === api) { - this.stores.set(apiBase, store); - } - }); + if (!this.apiSet.has(api)) { + if (typeof api.apiBase !== "string" || !api.apiBase) { + throw new TypeError("api.apiBase but be defined"); + } - this.apis.set(apiBase, api); + if (this.apis.has(api.apiBase)) { + throw new Error(`Cannot register second api for ${api.apiBase}`); + } + + this.apiSet.add(api); } } @@ -58,28 +123,70 @@ export class ApiManager { return api; } - unregisterApi(api: string | KubeApi) { - if (typeof api === "string") this.apis.delete(api); - else { - const apis = Array.from(this.apis.entries()); - const entry = apis.find(entry => entry[1] === api); + /** + * Removes `api` from the set of registered apis + * @param api The instance to de-register + * @returns `true` if the instance was previously registered + */ + @action + unregisterApi(api: KubeApi) { + return this.apiSet.delete(api); + } - if (entry) this.unregisterApi(entry[0]); + /** + * Registeres a `KubeObjectStore` instance that can be retrieved by the `apiBase` its api is for + * @param store The store to register + */ + registerStore(store: KubeObjectStore): void; + /** + * @deprecated stores should only be registered for the single api that the store is for. + */ + registerStore(store: KubeObjectStore, apis: KubeApi[]): void; + @action + registerStore(store: KubeObjectStore, apis?: KubeApi[]) { + apis ??= [store.api]; + + for (const api of apis) { + if (!this.apiSet.has(api)) { + throw new Error(`Cannot register store under ${api.apiBase} api, as that api is not registered`); + } + + if (this.stores.has(api)) { + throw new Error(`Each api instance can only have one store associated with it. Attempt to register a duplicate store for the ${api.apiBase} api`); + } + + this.stores.set(api, store); } } - @action - registerStore(store: KubeObjectStore, apis: KubeApi[] = [store.api]) { - apis.filter(Boolean).forEach(api => { - if (api.apiBase) this.stores.set(api.apiBase, store); - }); + /** + * + * @param apiOrBases The `apiBase`, resource descriptor, or `KubeApi` instance that the store is for. In order of searching + * @returns The registered store whose api has also been registered + */ + getStore(...apiOrBases: (string | KubeApi)[]): KubeObjectStore | undefined; + /** + * @deprecated Should use a cast instead as this is an unchecked type param. + */ + getStore>(...apiOrBases: (string | KubeApi)[]): S | undefined { + for (const apiOrBase of apiOrBases) { + const store = this.stores.get(this.resolveApi(apiOrBase)) as S; + + if (store) { + return store; + } + } + + return undefined; } - getStore>(api: string | KubeApi): S | undefined { - return this.stores.get(this.resolveApi(api)?.apiBase) as S; - } - - lookupApiLink(ref: IKubeObjectRef, parentObject?: KubeObject): string { + /** + * Get a URL pathname for a specific kube resource instance + * @param ref The kube object reference + * @param parentObject If provided then the namespace of this will be used if the `ref` does not provided it + * @returns A kube resource string + */ + lookupApiLink = (ref: IKubeObjectRef, parentObject?: KubeObject): string => { const { kind, apiVersion, name, namespace = parentObject?.getNs(), @@ -116,7 +223,5 @@ export class ApiManager { // otherwise generate link with default prefix // resource still might exists in k8s, but api is not registered in the app return createKubeApiURL({ apiVersion, name, namespace, resource }); - } + }; } - -export const apiManager = new ApiManager(); diff --git a/src/common/k8s-api/endpoints/cluster-role-binding.api.injectable.ts b/src/common/k8s-api/endpoints/cluster-role-binding.api.injectable.ts new file mode 100644 index 0000000000..d5e62ee59c --- /dev/null +++ b/src/common/k8s-api/endpoints/cluster-role-binding.api.injectable.ts @@ -0,0 +1,14 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import apiManagerInjectable from "../api-manager.injectable"; +import type { ClusterRoleBindingApi } from "./cluster-role-binding.api"; + +const clusterRoleBindingApiInjectable = getInjectable({ + instantiate: (di) => di.inject(apiManagerInjectable).getApi("/apis/rbac.authorization.k8s.io/v1/clusterrolebindings") as ClusterRoleBindingApi, + lifecycle: lifecycleEnum.singleton, +}); + +export default clusterRoleBindingApiInjectable; diff --git a/src/common/k8s-api/endpoints/cluster-role-binding.api.ts b/src/common/k8s-api/endpoints/cluster-role-binding.api.ts index b6e6a517ae..5aa59d5a8a 100644 --- a/src/common/k8s-api/endpoints/cluster-role-binding.api.ts +++ b/src/common/k8s-api/endpoints/cluster-role-binding.api.ts @@ -2,8 +2,7 @@ * Copyright (c) OpenLens Authors. All rights reserved. * Licensed under MIT License. See LICENSE in root directory for more information. */ -import { isClusterPageContext } from "../../utils/cluster-id-url-parsing"; -import { KubeApi } from "../kube-api"; +import { KubeApi, SpecificApiOptions } from "../kube-api"; import { KubeObject } from "../kube-object"; export type ClusterRoleBindingSubjectKind = "Group" | "ServiceAccount" | "User"; @@ -38,17 +37,11 @@ export class ClusterRoleBinding extends KubeObject { } } -/** - * Only available within kubernetes cluster pages - */ -let clusterRoleBindingApi: KubeApi; - -if (isClusterPageContext()) { - clusterRoleBindingApi = new KubeApi({ - objectConstructor: ClusterRoleBinding, - }); +export class ClusterRoleBindingApi extends KubeApi { + constructor(args: SpecificApiOptions = {}) { + super({ + ...args, + objectConstructor: ClusterRoleBinding, + }); + } } - -export { - clusterRoleBindingApi, -}; diff --git a/src/common/k8s-api/endpoints/cluster-role.api.injectable.ts b/src/common/k8s-api/endpoints/cluster-role.api.injectable.ts new file mode 100644 index 0000000000..7c0f8f8e75 --- /dev/null +++ b/src/common/k8s-api/endpoints/cluster-role.api.injectable.ts @@ -0,0 +1,14 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import apiManagerInjectable from "../api-manager.injectable"; +import type { ClusterRoleApi } from "./cluster-role.api"; + +const clusterRoleApiInjectable = getInjectable({ + instantiate: (di) => di.inject(apiManagerInjectable).getApi("/apis/rbac.authorization.k8s.io/v1/clusterroles") as ClusterRoleApi, + lifecycle: lifecycleEnum.singleton, +}); + +export default clusterRoleApiInjectable; diff --git a/src/common/k8s-api/endpoints/cluster-role.api.ts b/src/common/k8s-api/endpoints/cluster-role.api.ts index 70a5ef05e8..58b2fb9344 100644 --- a/src/common/k8s-api/endpoints/cluster-role.api.ts +++ b/src/common/k8s-api/endpoints/cluster-role.api.ts @@ -3,8 +3,7 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import { isClusterPageContext } from "../../utils/cluster-id-url-parsing"; -import { KubeApi } from "../kube-api"; +import { KubeApi, SpecificApiOptions } from "../kube-api"; import { KubeObject } from "../kube-object"; export interface ClusterRole { @@ -26,17 +25,11 @@ export class ClusterRole extends KubeObject { } } -/** - * Only available within kubernetes cluster pages - */ -let clusterRoleApi: KubeApi; - -if (isClusterPageContext()) { // initialize automatically only when within a cluster iframe/context - clusterRoleApi = new KubeApi({ - objectConstructor: ClusterRole, - }); +export class ClusterRoleApi extends KubeApi { + constructor(args: SpecificApiOptions = {}) { + super({ + ...args, + objectConstructor: ClusterRole, + }); + } } - -export { - clusterRoleApi, -}; diff --git a/src/common/k8s-api/endpoints/cluster.api.injectable.ts b/src/common/k8s-api/endpoints/cluster.api.injectable.ts new file mode 100644 index 0000000000..47b348297b --- /dev/null +++ b/src/common/k8s-api/endpoints/cluster.api.injectable.ts @@ -0,0 +1,14 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import apiManagerInjectable from "../api-manager.injectable"; +import type { ClusterApi } from "./cluster.api"; + +const clusterApiInjectable = getInjectable({ + instantiate: (di) => di.inject(apiManagerInjectable).getApi("/apis/cluster.k8s.io/v1alpha1/clusters") as ClusterApi, + lifecycle: lifecycleEnum.singleton, +}); + +export default clusterApiInjectable; diff --git a/src/common/k8s-api/endpoints/cluster.api.ts b/src/common/k8s-api/endpoints/cluster.api.ts index 1d32806382..ae759291c1 100644 --- a/src/common/k8s-api/endpoints/cluster.api.ts +++ b/src/common/k8s-api/endpoints/cluster.api.ts @@ -5,13 +5,7 @@ import { IMetrics, IMetricsReqParams, metricsApi } from "./metrics.api"; import { KubeObject } from "../kube-object"; -import { KubeApi } from "../kube-api"; -import { isClusterPageContext } from "../../utils/cluster-id-url-parsing"; - -export class ClusterApi extends KubeApi { - static kind = "Cluster"; - static namespaced = true; -} +import { KubeApi, SpecificApiOptions } from "../kube-api"; export function getMetricsByNodeNames(nodeNames: string[], params?: IMetricsReqParams): Promise { const nodes = nodeNames.join("|"); @@ -97,6 +91,7 @@ export interface Cluster { export class Cluster extends KubeObject { static kind = "Cluster"; static apiBase = "/apis/cluster.k8s.io/v1alpha1/clusters"; + static namespaced = true; getStatus() { if (this.metadata.deletionTimestamp) return ClusterStatus.REMOVING; @@ -107,17 +102,11 @@ export class Cluster extends KubeObject { } } -/** - * Only available within kubernetes cluster pages - */ -let clusterApi: ClusterApi; - -if (isClusterPageContext()) { // initialize automatically only when within a cluster iframe/context - clusterApi = new ClusterApi({ - objectConstructor: Cluster, - }); +export class ClusterApi extends KubeApi { + constructor(args: SpecificApiOptions = {}) { + super({ + ...args, + objectConstructor: Cluster, + }); + } } - -export { - clusterApi, -}; diff --git a/src/common/k8s-api/endpoints/component-status.api.ts b/src/common/k8s-api/endpoints/component-status.api.ts deleted file mode 100644 index 30f272400a..0000000000 --- a/src/common/k8s-api/endpoints/component-status.api.ts +++ /dev/null @@ -1,31 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import { KubeObject } from "../kube-object"; -import { KubeApi } from "../kube-api"; - -export interface IComponentStatusCondition { - type: string; - status: string; - message: string; -} - -export interface ComponentStatus { - conditions: IComponentStatusCondition[]; -} - -export class ComponentStatus extends KubeObject { - static kind = "ComponentStatus"; - static namespaced = false; - static apiBase = "/api/v1/componentstatuses"; - - getTruthyConditions() { - return this.conditions.filter(c => c.status === "True"); - } -} - -export const componentStatusApi = new KubeApi({ - objectConstructor: ComponentStatus, -}); diff --git a/src/common/k8s-api/endpoints/configmap.api.injectable.ts b/src/common/k8s-api/endpoints/configmap.api.injectable.ts new file mode 100644 index 0000000000..ceb56c25ca --- /dev/null +++ b/src/common/k8s-api/endpoints/configmap.api.injectable.ts @@ -0,0 +1,14 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import apiManagerInjectable from "../api-manager.injectable"; +import type { ConfigMapApi } from "./configmap.api"; + +const configMapApiInjectable = getInjectable({ + instantiate: (di) => di.inject(apiManagerInjectable).getApi("/api/v1/configmaps") as ConfigMapApi, + lifecycle: lifecycleEnum.singleton, +}); + +export default configMapApiInjectable; diff --git a/src/common/k8s-api/endpoints/configmap.api.ts b/src/common/k8s-api/endpoints/configmap.api.ts index 82bbc47bf7..74ba999d97 100644 --- a/src/common/k8s-api/endpoints/configmap.api.ts +++ b/src/common/k8s-api/endpoints/configmap.api.ts @@ -5,9 +5,8 @@ import { KubeObject } from "../kube-object"; import type { KubeJsonApiData } from "../kube-json-api"; -import { KubeApi } from "../kube-api"; +import { KubeApi, SpecificApiOptions } from "../kube-api"; import { autoBind } from "../../utils"; -import { isClusterPageContext } from "../../utils/cluster-id-url-parsing"; export interface ConfigMap { data: { @@ -32,17 +31,11 @@ export class ConfigMap extends KubeObject { } } -/** - * Only available within kubernetes cluster pages - */ -let configMapApi: KubeApi; - -if (isClusterPageContext()) { - configMapApi = new KubeApi({ - objectConstructor: ConfigMap, - }); +export class ConfigMapApi extends KubeApi { + constructor(args: SpecificApiOptions = {}) { + super({ + ...args, + objectConstructor: ConfigMap, + }); + } } - -export { - configMapApi, -}; diff --git a/src/common/k8s-api/endpoints/cron-job.api.injectable.ts b/src/common/k8s-api/endpoints/cron-job.api.injectable.ts new file mode 100644 index 0000000000..b4fa8780a9 --- /dev/null +++ b/src/common/k8s-api/endpoints/cron-job.api.injectable.ts @@ -0,0 +1,14 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import type { CronJobApi } from "./cron-job.api"; +import apiManagerInjectable from "../api-manager.injectable"; + +const cronJobApiInjectable = getInjectable({ + instantiate: (di) => di.inject(apiManagerInjectable).getApi("/apis/batch/v1beta1/cronjobs") as CronJobApi, + lifecycle: lifecycleEnum.singleton, +}); + +export default cronJobApiInjectable; diff --git a/src/common/k8s-api/endpoints/cron-job.api.ts b/src/common/k8s-api/endpoints/cron-job.api.ts index cf50780b0f..e12c93ca10 100644 --- a/src/common/k8s-api/endpoints/cron-job.api.ts +++ b/src/common/k8s-api/endpoints/cron-job.api.ts @@ -5,44 +5,11 @@ import moment from "moment"; import { KubeObject } from "../kube-object"; -import type { IPodContainer } from "./pods.api"; +import type { IPodContainer } from "./pod.api"; import { formatDuration } from "../../utils/formatDuration"; import { autoBind } from "../../utils"; -import { KubeApi } from "../kube-api"; +import { KubeApi, SpecificApiOptions } from "../kube-api"; import type { KubeJsonApiData } from "../kube-json-api"; -import { isClusterPageContext } from "../../utils/cluster-id-url-parsing"; - -export class CronJobApi extends KubeApi { - suspend(params: { namespace: string; name: string }) { - return this.request.patch(this.getUrl(params), { - data: { - spec: { - suspend: true, - }, - }, - }, - { - headers: { - "content-type": "application/strategic-merge-patch+json", - }, - }); - } - - resume(params: { namespace: string; name: string }) { - return this.request.patch(this.getUrl(params), { - data: { - spec: { - suspend: false, - }, - }, - }, - { - headers: { - "content-type": "application/strategic-merge-patch+json", - }, - }); - } -} export interface CronJob { spec: { @@ -125,17 +92,41 @@ export class CronJob extends KubeObject { } } -/** - * Only available within kubernetes cluster pages - */ -let cronJobApi: CronJobApi; +export class CronJobApi extends KubeApi { + constructor(args: SpecificApiOptions = {}) { + super({ + ...args, + objectConstructor: CronJob, + }); + } -if (isClusterPageContext()) { - cronJobApi = new CronJobApi({ - objectConstructor: CronJob, - }); + suspend(params: { namespace: string; name: string }) { + return this.request.patch(this.getUrl(params), { + data: { + spec: { + suspend: true, + }, + }, + }, + { + headers: { + "content-type": "application/strategic-merge-patch+json", + }, + }); + } + + resume(params: { namespace: string; name: string }) { + return this.request.patch(this.getUrl(params), { + data: { + spec: { + suspend: false, + }, + }, + }, + { + headers: { + "content-type": "application/strategic-merge-patch+json", + }, + }); + } } - -export { - cronJobApi, -}; diff --git a/src/common/k8s-api/endpoints/custom-resource-definition.api.injectable.ts b/src/common/k8s-api/endpoints/custom-resource-definition.api.injectable.ts new file mode 100644 index 0000000000..9b2bfbda0b --- /dev/null +++ b/src/common/k8s-api/endpoints/custom-resource-definition.api.injectable.ts @@ -0,0 +1,14 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import apiManagerInjectable from "../api-manager.injectable"; +import type { CustomResourceDefinitionApi } from "./custom-resource-definition.api"; + +const customResourceDefinitionApiInjectable = getInjectable({ + instantiate: (di) => di.inject(apiManagerInjectable).getApi("/apis/apiextensions.k8s.io/v1/customresourcedefinitions") as CustomResourceDefinitionApi, + lifecycle: lifecycleEnum.singleton, +}); + +export default customResourceDefinitionApiInjectable; diff --git a/src/common/k8s-api/endpoints/crd.api.ts b/src/common/k8s-api/endpoints/custom-resource-definition.api.ts similarity index 92% rename from src/common/k8s-api/endpoints/crd.api.ts rename to src/common/k8s-api/endpoints/custom-resource-definition.api.ts index 2f330feaf9..6b5c039f28 100644 --- a/src/common/k8s-api/endpoints/crd.api.ts +++ b/src/common/k8s-api/endpoints/custom-resource-definition.api.ts @@ -4,9 +4,8 @@ */ import { KubeCreationError, KubeObject } from "../kube-object"; -import { KubeApi } from "../kube-api"; +import { KubeApi, SpecificApiOptions } from "../kube-api"; import { crdResourcesURL } from "../../routes"; -import { isClusterPageContext } from "../../utils/cluster-id-url-parsing"; import type { KubeJsonApiData } from "../kube-json-api"; type AdditionalPrinterColumnsCommon = { @@ -210,18 +209,11 @@ export class CustomResourceDefinition extends KubeObject { } } -/** - * Only available within kubernetes cluster pages - */ -let crdApi: KubeApi; - -if (isClusterPageContext()) { - crdApi = new KubeApi({ - objectConstructor: CustomResourceDefinition, - checkPreferredVersion: true, - }); +export class CustomResourceDefinitionApi extends KubeApi { + constructor(args: SpecificApiOptions = {}) { + super({ + ...args, + objectConstructor: CustomResourceDefinition, + }); + } } - -export { - crdApi, -}; diff --git a/src/common/k8s-api/endpoints/daemon-set.api.injectable.ts b/src/common/k8s-api/endpoints/daemon-set.api.injectable.ts new file mode 100644 index 0000000000..d69b8b9de3 --- /dev/null +++ b/src/common/k8s-api/endpoints/daemon-set.api.injectable.ts @@ -0,0 +1,14 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import apiManagerInjectable from "../api-manager.injectable"; +import type { DaemonSetApi } from "./daemon-set.api"; + +const daemonSetApiInjectable = getInjectable({ + instantiate: (di) => di.inject(apiManagerInjectable).getApi("/apis/apps/v1/daemonsets") as DaemonSetApi, + lifecycle: lifecycleEnum.singleton, +}); + +export default daemonSetApiInjectable; diff --git a/src/common/k8s-api/endpoints/daemon-set.api.ts b/src/common/k8s-api/endpoints/daemon-set.api.ts index 74198de4e5..ddf02e151f 100644 --- a/src/common/k8s-api/endpoints/daemon-set.api.ts +++ b/src/common/k8s-api/endpoints/daemon-set.api.ts @@ -6,11 +6,10 @@ import get from "lodash/get"; import { IAffinity, WorkloadKubeObject } from "../workload-kube-object"; import { autoBind } from "../../utils"; -import { KubeApi } from "../kube-api"; +import { KubeApi, SpecificApiOptions } from "../kube-api"; import { metricsApi } from "./metrics.api"; import type { KubeJsonApiData } from "../kube-json-api"; -import type { IPodContainer, IPodMetrics } from "./pods.api"; -import { isClusterPageContext } from "../../utils/cluster-id-url-parsing"; +import type { IPodContainer, IPodMetrics } from "./pod.api"; import type { LabelSelector } from "../kube-object"; export class DaemonSet extends WorkloadKubeObject { @@ -80,9 +79,6 @@ export class DaemonSet extends WorkloadKubeObject { } } -export class DaemonSetApi extends KubeApi { -} - export function getMetricsForDaemonSets(daemonsets: DaemonSet[], namespace: string, selector = ""): Promise { const podSelector = daemonsets.map(daemonset => `${daemonset.getName()}-[[:alnum:]]{5}`).join("|"); const opts = { category: "pods", pods: podSelector, namespace, selector }; @@ -100,17 +96,11 @@ export function getMetricsForDaemonSets(daemonsets: DaemonSet[], namespace: stri }); } -/** - * Only available within kubernetes cluster pages - */ -let daemonSetApi: DaemonSetApi; - -if (isClusterPageContext()) { - daemonSetApi = new DaemonSetApi({ - objectConstructor: DaemonSet, - }); +export class DaemonSetApi extends KubeApi { + constructor(args: SpecificApiOptions = {}) { + super({ + ...args, + objectConstructor: DaemonSet, + }); + } } - -export { - daemonSetApi, -}; diff --git a/src/common/k8s-api/endpoints/deployment.api.injectable.ts b/src/common/k8s-api/endpoints/deployment.api.injectable.ts new file mode 100644 index 0000000000..806c486577 --- /dev/null +++ b/src/common/k8s-api/endpoints/deployment.api.injectable.ts @@ -0,0 +1,14 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import apiManagerInjectable from "../api-manager.injectable"; +import type { DeploymentApi } from "./deployment.api"; + +const deploymentApiInjectable = getInjectable({ + instantiate: (di) => di.inject(apiManagerInjectable).getApi("/apis/apps/v1/deployments") as DeploymentApi, + lifecycle: lifecycleEnum.singleton, +}); + +export default deploymentApiInjectable; diff --git a/src/common/k8s-api/endpoints/deployment.api.ts b/src/common/k8s-api/endpoints/deployment.api.ts index 505b32dd96..d86b510b5e 100644 --- a/src/common/k8s-api/endpoints/deployment.api.ts +++ b/src/common/k8s-api/endpoints/deployment.api.ts @@ -7,59 +7,12 @@ import moment from "moment"; import { IAffinity, WorkloadKubeObject } from "../workload-kube-object"; import { autoBind } from "../../utils"; -import { KubeApi } from "../kube-api"; +import { KubeApi, SpecificApiOptions } from "../kube-api"; import { metricsApi } from "./metrics.api"; -import type { IPodMetrics } from "./pods.api"; +import type { IPodMetrics } from "./pod.api"; import type { KubeJsonApiData } from "../kube-json-api"; -import { isClusterPageContext } from "../../utils/cluster-id-url-parsing"; import type { LabelSelector } from "../kube-object"; -export class DeploymentApi extends KubeApi { - protected getScaleApiUrl(params: { namespace: string; name: string }) { - return `${this.getUrl(params)}/scale`; - } - - getReplicas(params: { namespace: string; name: string }): Promise { - return this.request - .get(this.getScaleApiUrl(params)) - .then(({ status }: any) => status?.replicas); - } - - scale(params: { namespace: string; name: string }, replicas: number) { - return this.request.patch(this.getScaleApiUrl(params), { - data: { - spec: { - replicas, - }, - }, - }, - { - headers: { - "content-type": "application/merge-patch+json", - }, - }); - } - - restart(params: { namespace: string; name: string }) { - return this.request.patch(this.getUrl(params), { - data: { - spec: { - template: { - metadata: { - annotations: { "kubectl.kubernetes.io/restartedAt" : moment.utc().format() }, - }, - }, - }, - }, - }, - { - headers: { - "content-type": "application/strategic-merge-patch+json", - }, - }); - } -} - export function getMetricsForDeployments(deployments: Deployment[], namespace: string, selector = ""): Promise { const podSelector = deployments.map(deployment => `${deployment.getName()}-[[:alnum:]]{9,}-[[:alnum:]]{5}`).join("|"); const opts = { category: "pods", pods: podSelector, namespace, selector }; @@ -224,14 +177,61 @@ export class Deployment extends WorkloadKubeObject { } } -let deploymentApi: DeploymentApi; - -if (isClusterPageContext()) { - deploymentApi = new DeploymentApi({ - objectConstructor: Deployment, - }); +interface ReplicasStatus { + status?: { + replicas: number; + } } -export { - deploymentApi, -}; +export class DeploymentApi extends KubeApi { + constructor(args: SpecificApiOptions = {}) { + super({ + ...args, + objectConstructor: Deployment, + }); + } + + protected getScaleApiUrl(params: { namespace: string; name: string }) { + return `${this.getUrl(params)}/scale`; + } + + async getReplicas(params: { namespace: string; name: string }): Promise { + const { status } = await this.request.get(this.getScaleApiUrl(params)); + + return status?.replicas ?? 0; + } + + scale(params: { namespace: string; name: string }, replicas: number) { + return this.request.patch(this.getScaleApiUrl(params), { + data: { + spec: { + replicas, + }, + }, + }, + { + headers: { + "content-type": "application/merge-patch+json", + }, + }); + } + + restart(params: { namespace: string; name: string }) { + return this.request.patch(this.getUrl(params), { + data: { + spec: { + template: { + metadata: { + annotations: { "kubectl.kubernetes.io/restartedAt" : moment.utc().format() }, + }, + }, + }, + }, + }, + { + headers: { + "content-type": "application/strategic-merge-patch+json", + }, + }); + } +} diff --git a/src/common/k8s-api/endpoints/endpoint.api.injectable.ts b/src/common/k8s-api/endpoints/endpoint.api.injectable.ts new file mode 100644 index 0000000000..a3010d9921 --- /dev/null +++ b/src/common/k8s-api/endpoints/endpoint.api.injectable.ts @@ -0,0 +1,14 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import apiManagerInjectable from "../api-manager.injectable"; +import type { EndpointApi } from "./endpoint.api"; + +const endpointApiInjectable = getInjectable({ + instantiate: (di) => di.inject(apiManagerInjectable).getApi("/api/v1/endpoints") as EndpointApi, + lifecycle: lifecycleEnum.singleton, +}); + +export default endpointApiInjectable; diff --git a/src/common/k8s-api/endpoints/endpoint.api.ts b/src/common/k8s-api/endpoints/endpoint.api.ts index 9e025eb158..2561206668 100644 --- a/src/common/k8s-api/endpoints/endpoint.api.ts +++ b/src/common/k8s-api/endpoints/endpoint.api.ts @@ -5,10 +5,9 @@ import { autoBind } from "../../utils"; import { KubeObject } from "../kube-object"; -import { KubeApi } from "../kube-api"; +import { KubeApi, SpecificApiOptions } from "../kube-api"; import type { KubeJsonApiData } from "../kube-json-api"; import { get } from "lodash"; -import { isClusterPageContext } from "../../utils/cluster-id-url-parsing"; export interface IEndpointPort { name?: string; @@ -131,17 +130,13 @@ export class Endpoint extends KubeObject { return ""; } } - } -let endpointApi: KubeApi; - -if (isClusterPageContext()) { - endpointApi = new KubeApi({ - objectConstructor: Endpoint, - }); +export class EndpointApi extends KubeApi { + constructor(args: SpecificApiOptions = {}) { + super({ + ...args, + objectConstructor: Endpoint, + }); + } } - -export { - endpointApi, -}; diff --git a/src/common/k8s-api/endpoints/event.api.injectable.ts b/src/common/k8s-api/endpoints/event.api.injectable.ts new file mode 100644 index 0000000000..8ac5394e13 --- /dev/null +++ b/src/common/k8s-api/endpoints/event.api.injectable.ts @@ -0,0 +1,14 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import apiManagerInjectable from "../api-manager.injectable"; +import type { EventApi } from "./event.api"; + +const eventApiInjectable = getInjectable({ + instantiate: (di) => di.inject(apiManagerInjectable).getApi("/api/v1/events") as EventApi, + lifecycle: lifecycleEnum.singleton, +}); + +export default eventApiInjectable; diff --git a/src/common/k8s-api/endpoints/events.api.ts b/src/common/k8s-api/endpoints/event.api.ts similarity index 77% rename from src/common/k8s-api/endpoints/events.api.ts rename to src/common/k8s-api/endpoints/event.api.ts index f4218ba8c0..5708140a0d 100644 --- a/src/common/k8s-api/endpoints/events.api.ts +++ b/src/common/k8s-api/endpoints/event.api.ts @@ -6,10 +6,9 @@ import moment from "moment"; import { KubeObject } from "../kube-object"; import { formatDuration } from "../../utils/formatDuration"; -import { KubeApi } from "../kube-api"; -import { isClusterPageContext } from "../../utils/cluster-id-url-parsing"; +import { KubeApi, SpecificApiOptions } from "../kube-api"; -export interface KubeEvent { +export interface Event { involvedObject: { kind: string; namespace: string; @@ -34,7 +33,7 @@ export interface KubeEvent { reportingInstance: string; } -export class KubeEvent extends KubeObject { +export class Event extends KubeObject { static kind = "Event"; static namespaced = true; static apiBase = "/api/v1/events"; @@ -62,14 +61,11 @@ export class KubeEvent extends KubeObject { } } -let eventApi: KubeApi; - -if (isClusterPageContext()) { - eventApi = new KubeApi({ - objectConstructor: KubeEvent, - }); +export class EventApi extends KubeApi { + constructor(args: SpecificApiOptions = {}) { + super({ + ...args, + objectConstructor: Event, + }); + } } - -export { - eventApi, -}; diff --git a/src/common/k8s-api/endpoints/helm-charts.api.ts b/src/common/k8s-api/endpoints/helm-chart.api.ts similarity index 98% rename from src/common/k8s-api/endpoints/helm-charts.api.ts rename to src/common/k8s-api/endpoints/helm-chart.api.ts index bfe5ac052b..e789c68bbf 100644 --- a/src/common/k8s-api/endpoints/helm-charts.api.ts +++ b/src/common/k8s-api/endpoints/helm-chart.api.ts @@ -65,7 +65,7 @@ export async function getChartDetails(repo: string, name: string, { version, req * @param name The name of the chart to request the data of * @param version The version to get the values from */ -export async function getChartValues(repo: string, name: string, version: string): Promise { +export function getChartValues(repo: string, name: string, version: string): Promise { return apiBase.get(`/v2/charts/${repo}/${name}/values?${stringify({ version })}`); } diff --git a/src/common/k8s-api/endpoints/helm-releases.api.ts b/src/common/k8s-api/endpoints/helm-release.api.ts similarity index 74% rename from src/common/k8s-api/endpoints/helm-releases.api.ts rename to src/common/k8s-api/endpoints/helm-release.api.ts index ce0e5eed8e..0e3c3480fc 100644 --- a/src/common/k8s-api/endpoints/helm-releases.api.ts +++ b/src/common/k8s-api/endpoints/helm-release.api.ts @@ -4,10 +4,10 @@ */ import yaml from "js-yaml"; -import { autoBind, formatDuration } from "../../utils"; +import { formatDuration } from "../../utils"; import capitalize from "lodash/capitalize"; import { apiBase } from "../index"; -import { helmChartStore } from "../../../renderer/components/+apps-helm-charts/helm-chart.store"; +import { helmChartStore } from "../../../renderer/components/+helm-charts/store"; import type { ItemObject } from "../../item.store"; import { KubeObject } from "../kube-object"; import type { JsonApiData } from "../json-api"; @@ -80,9 +80,9 @@ interface EndpointQuery { const endpoint = buildURLPositional("/v2/releases/:namespace?/:name?/:route?"); export async function listReleases(namespace?: string): Promise { - const releases = await apiBase.get(endpoint({ namespace })); + const releases = await apiBase.get(endpoint({ namespace })); - return releases.map(HelmRelease.create); + return releases.map(toHelmRelease); } export async function getRelease(name: string, namespace: string): Promise { @@ -96,7 +96,7 @@ export async function getRelease(name: string, namespace: string): Promise { +export function createRelease(payload: IReleaseCreatePayload): Promise { const { repo, chart: rawChart, values: rawValues, ...data } = payload; const chart = `${repo}/${rawChart}`; const values = yaml.load(rawValues); @@ -110,7 +110,7 @@ export async function createRelease(payload: IReleaseCreatePayload): Promise { +export function updateRelease(name: string, namespace: string, payload: IReleaseUpdatePayload): Promise { const { repo, chart: rawChart, values: rawValues, ...data } = payload; const chart = `${repo}/${rawChart}`; const values = yaml.load(rawValues); @@ -124,27 +124,27 @@ export async function updateRelease(name: string, namespace: string, payload: IR }); } -export async function deleteRelease(name: string, namespace: string): Promise { +export function deleteRelease(name: string, namespace: string): Promise { const path = endpoint({ name, namespace }); return apiBase.del(path); } -export async function getReleaseValues(name: string, namespace: string, all?: boolean): Promise { +export function getReleaseValues(name: string, namespace: string, all?: boolean): Promise { const route = "values"; const path = endpoint({ name, namespace, route }, { all }); return apiBase.get(path); } -export async function getReleaseHistory(name: string, namespace: string): Promise { +export function getReleaseHistory(name: string, namespace: string): Promise { const route = "history"; const path = endpoint({ name, namespace, route }); return apiBase.get(path); } -export async function rollbackRelease(name: string, namespace: string, revision: number): Promise { +export function rollbackRelease(name: string, namespace: string, revision: number): Promise { const route = "rollback"; const path = endpoint({ name, namespace, route }); const data = { revision }; @@ -152,7 +152,7 @@ export async function rollbackRelease(name: string, namespace: string, revision: return apiBase.put(path, { data }); } -export interface HelmRelease { +interface HelmReleaseDto { appVersion: string; name: string; namespace: string; @@ -162,27 +162,30 @@ export interface HelmRelease { revision: string; } -export class HelmRelease implements ItemObject { - constructor(data: any) { - Object.assign(this, data); - autoBind(this); - } +export interface HelmRelease extends HelmReleaseDto, ItemObject { + getNs: () => string + getChart: (withVersion?: boolean) => string + getRevision: () => number + getStatus: () => string + getVersion: () => string + getUpdated: (humanize?: boolean, compact?: boolean) => string | number + getRepo: () => Promise +} - static create(data: any) { - return new HelmRelease(data); - } +const toHelmRelease = (release: HelmReleaseDto) : HelmRelease => ({ + ...release, getId() { return this.namespace + this.name; - } + }, getName() { return this.name; - } + }, getNs() { return this.namespace; - } + }, getChart(withVersion = false) { let chart = this.chart; @@ -194,24 +197,24 @@ export class HelmRelease implements ItemObject { } return chart; - } + }, getRevision() { return parseInt(this.revision, 10); - } + }, getStatus() { return capitalize(this.status); - } + }, getVersion() { const versions = this.chart.match(/(?<=-)(v?\d+)[^-].*$/); return versions?.[0] ?? ""; - } + }, getUpdated(humanize = true, compact = true) { - const updated = this.updated.replace(/\s\w*$/, ""); // 2019-11-26 10:58:09 +0300 MSK -> 2019-11-26 10:58:09 +0300 to pass into Date() + const updated = this.updated.replace(/\s\w*$/, ""); // 2019-11-26 10:58:09 +0300 MSK -> 2019-11-26 10:58:09 +0300 to pass into Date() const updatedDate = new Date(updated).getTime(); const diff = Date.now() - updatedDate; @@ -220,7 +223,7 @@ export class HelmRelease implements ItemObject { } return diff; - } + }, // Helm does not store from what repository the release is installed, // so we have to try to guess it by searching charts @@ -228,8 +231,10 @@ export class HelmRelease implements ItemObject { const chartName = this.getChart(); const version = this.getVersion(); const versions = await helmChartStore.getVersions(chartName); - const chartVersion = versions.find(chartVersion => chartVersion.version === version); + const chartVersion = versions.find( + (chartVersion) => chartVersion.version === version, + ); return chartVersion ? chartVersion.repo : ""; - } -} + }, +}); diff --git a/src/common/k8s-api/endpoints/horizontal-pod-autoscaler.api.injectable.ts b/src/common/k8s-api/endpoints/horizontal-pod-autoscaler.api.injectable.ts new file mode 100644 index 0000000000..129a94a4dc --- /dev/null +++ b/src/common/k8s-api/endpoints/horizontal-pod-autoscaler.api.injectable.ts @@ -0,0 +1,14 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import apiManagerInjectable from "../api-manager.injectable"; +import type { HorizontalPodAutoscalerApi } from "./horizontal-pod-autoscaler.api"; + +const horizontalPodAutoscalerApiInjectable = getInjectable({ + instantiate: (di) => di.inject(apiManagerInjectable).getApi("/apis/autoscaling/v2beta1/horizontalpodautoscalers") as HorizontalPodAutoscalerApi, + lifecycle: lifecycleEnum.singleton, +}); + +export default horizontalPodAutoscalerApiInjectable; diff --git a/src/common/k8s-api/endpoints/hpa.api.ts b/src/common/k8s-api/endpoints/horizontal-pod-autoscaler.api.ts similarity index 91% rename from src/common/k8s-api/endpoints/hpa.api.ts rename to src/common/k8s-api/endpoints/horizontal-pod-autoscaler.api.ts index e284f3a3e8..ccd07cbaa0 100644 --- a/src/common/k8s-api/endpoints/hpa.api.ts +++ b/src/common/k8s-api/endpoints/horizontal-pod-autoscaler.api.ts @@ -4,8 +4,7 @@ */ import { KubeObject } from "../kube-object"; -import { KubeApi } from "../kube-api"; -import { isClusterPageContext } from "../../utils/cluster-id-url-parsing"; +import { KubeApi, SpecificApiOptions } from "../kube-api"; export enum HpaMetricType { Resource = "Resource", @@ -148,14 +147,11 @@ export class HorizontalPodAutoscaler extends KubeObject { } } -let hpaApi: KubeApi; - -if (isClusterPageContext()) { - hpaApi = new KubeApi({ - objectConstructor: HorizontalPodAutoscaler, - }); +export class HorizontalPodAutoscalerApi extends KubeApi { + constructor(args: SpecificApiOptions = {}) { + super({ + ...args, + objectConstructor: HorizontalPodAutoscaler, + }); + } } - -export { - hpaApi, -}; diff --git a/src/common/k8s-api/endpoints/index.ts b/src/common/k8s-api/endpoints/index.ts index 0fb21635ae..5f132983b5 100644 --- a/src/common/k8s-api/endpoints/index.ts +++ b/src/common/k8s-api/endpoints/index.ts @@ -6,36 +6,38 @@ // Kubernetes apis // Docs: https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.10/ -export * from "./cluster.api"; -export * from "./cluster-role.api"; export * from "./cluster-role-binding.api"; +export * from "./cluster-role.api"; +export * from "./cluster.api"; export * from "./configmap.api"; -export * from "./crd.api"; export * from "./cron-job.api"; +export * from "./custom-resource-definition.api"; export * from "./daemon-set.api"; export * from "./deployment.api"; export * from "./endpoint.api"; -export * from "./events.api"; -export * from "./hpa.api"; +export * from "./event.api"; +export * from "./helm-release.api"; +export * from "./helm-chart.api"; +export * from "./horizontal-pod-autoscaler.api"; export * from "./ingress.api"; export * from "./job.api"; export * from "./limit-range.api"; -export * from "./namespaces.api"; +export * from "./namespace.api"; export * from "./network-policy.api"; -export * from "./nodes.api"; -export * from "./persistent-volume.api"; +export * from "./node.api"; export * from "./persistent-volume-claims.api"; -export * from "./pods.api"; -export * from "./poddisruptionbudget.api"; +export * from "./persistent-volume.api"; +export * from "./pod-disruption-budget.api"; export * from "./pod-metrics.api"; -export * from "./podsecuritypolicy.api"; +export * from "./pod-security-policy.api"; +export * from "./pod.api"; export * from "./replica-set.api"; export * from "./resource-quota.api"; -export * from "./role.api"; export * from "./role-binding.api"; +export * from "./role.api"; export * from "./secret.api"; -export * from "./selfsubjectrulesreviews.api"; +export * from "./self-subject-rules-review.api"; +export * from "./service-account.api"; export * from "./service.api"; -export * from "./service-accounts.api"; export * from "./stateful-set.api"; export * from "./storage-class.api"; diff --git a/src/common/k8s-api/endpoints/ingress.api.injectable.ts b/src/common/k8s-api/endpoints/ingress.api.injectable.ts new file mode 100644 index 0000000000..716b859245 --- /dev/null +++ b/src/common/k8s-api/endpoints/ingress.api.injectable.ts @@ -0,0 +1,14 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import apiManagerInjectable from "../api-manager.injectable"; +import type { IngressApi } from "./ingress.api"; + +const ingressApiInjectable = getInjectable({ + instantiate: (di) => di.inject(apiManagerInjectable).getApi("/apis/networking.k8s.io/v1/ingresses", "/apis/extensions/v1beta1/ingresses") as IngressApi, + lifecycle: lifecycleEnum.singleton, +}); + +export default ingressApiInjectable; diff --git a/src/common/k8s-api/endpoints/ingress.api.ts b/src/common/k8s-api/endpoints/ingress.api.ts index d08269919f..51c56680fc 100644 --- a/src/common/k8s-api/endpoints/ingress.api.ts +++ b/src/common/k8s-api/endpoints/ingress.api.ts @@ -6,14 +6,10 @@ import { KubeObject } from "../kube-object"; import { autoBind } from "../../utils"; import { IMetrics, metricsApi } from "./metrics.api"; -import { KubeApi } from "../kube-api"; +import { KubeApi, SpecificApiOptions } from "../kube-api"; import type { KubeJsonApiData } from "../kube-json-api"; -import { isClusterPageContext } from "../../utils/cluster-id-url-parsing"; import type { RequireExactlyOne } from "type-fest"; -export class IngressApi extends KubeApi { -} - export function getMetricsForIngress(ingress: string, namespace: string): Promise { const opts = { category: "ingress", ingress, namespace }; @@ -194,17 +190,14 @@ export class Ingress extends KubeObject { } } -let ingressApi: IngressApi; +export class IngressApi extends KubeApi { + constructor(args: SpecificApiOptions = {}) { + args.checkPreferredVersion ??= true; + (args.fallbackApiBases ??= []).push("/apis/extensions/v1beta1/ingresses"); -if (isClusterPageContext()) { - ingressApi = new IngressApi({ - objectConstructor: Ingress, - // Add fallback for Kubernetes <1.19 - checkPreferredVersion: true, - fallbackApiBases: ["/apis/extensions/v1beta1/ingresses"], - }); + super({ + ...args, + objectConstructor: Ingress, + }); + } } - -export { - ingressApi, -}; diff --git a/src/common/k8s-api/endpoints/job.api.injectable.ts b/src/common/k8s-api/endpoints/job.api.injectable.ts new file mode 100644 index 0000000000..d84025e2f8 --- /dev/null +++ b/src/common/k8s-api/endpoints/job.api.injectable.ts @@ -0,0 +1,14 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import apiManagerInjectable from "../api-manager.injectable"; +import type { JobApi } from "./job.api"; + +const jobApiInjectable = getInjectable({ + instantiate: (di) => di.inject(apiManagerInjectable).getApi("/apis/batch/v1/jobs") as JobApi, + lifecycle: lifecycleEnum.singleton, +}); + +export default jobApiInjectable; diff --git a/src/common/k8s-api/endpoints/job.api.ts b/src/common/k8s-api/endpoints/job.api.ts index 9ceee0ce99..aaad710b00 100644 --- a/src/common/k8s-api/endpoints/job.api.ts +++ b/src/common/k8s-api/endpoints/job.api.ts @@ -3,14 +3,12 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import get from "lodash/get"; import { autoBind } from "../../utils"; import { IAffinity, WorkloadKubeObject } from "../workload-kube-object"; -import { KubeApi } from "../kube-api"; +import { KubeApi, SpecificApiOptions } from "../kube-api"; import { metricsApi } from "./metrics.api"; import type { KubeJsonApiData } from "../kube-json-api"; -import type { IPodContainer, IPodMetrics } from "./pods.api"; -import { isClusterPageContext } from "../../utils/cluster-id-url-parsing"; +import type { IPodContainer, IPodMetrics } from "./pod.api"; import type { LabelSelector } from "../kube-object"; export class Job extends WorkloadKubeObject { @@ -97,15 +95,12 @@ export class Job extends WorkloadKubeObject { } getImages() { - const containers: IPodContainer[] = get(this, "spec.template.spec.containers", []); + const { containers = [] } = this.spec?.template?.spec ?? {}; - return [...containers].map(container => container.image); + return containers.map(container => container.image); } } -export class JobApi extends KubeApi { -} - export function getMetricsForJobs(jobs: Job[], namespace: string, selector = ""): Promise { const podSelector = jobs.map(job => `${job.getName()}-[[:alnum:]]{5}`).join("|"); const opts = { category: "pods", pods: podSelector, namespace, selector }; @@ -123,14 +118,11 @@ export function getMetricsForJobs(jobs: Job[], namespace: string, selector = "") }); } -let jobApi: JobApi; - -if (isClusterPageContext()) { - jobApi = new JobApi({ - objectConstructor: Job, - }); +export class JobApi extends KubeApi { + constructor(args: SpecificApiOptions = {}) { + super({ + ...args, + objectConstructor: Job, + }); + } } - -export { - jobApi, -}; diff --git a/src/common/k8s-api/endpoints/limit-range.api.injectable.ts b/src/common/k8s-api/endpoints/limit-range.api.injectable.ts new file mode 100644 index 0000000000..0769644df5 --- /dev/null +++ b/src/common/k8s-api/endpoints/limit-range.api.injectable.ts @@ -0,0 +1,14 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import apiManagerInjectable from "../api-manager.injectable"; +import type { LimitRangeApi } from "./limit-range.api"; + +const limitRangeApiInjectable = getInjectable({ + instantiate: (di) => di.inject(apiManagerInjectable).getApi("/api/v1/limitranges") as LimitRangeApi, + lifecycle: lifecycleEnum.singleton, +}); + +export default limitRangeApiInjectable; diff --git a/src/common/k8s-api/endpoints/limit-range.api.ts b/src/common/k8s-api/endpoints/limit-range.api.ts index 0018d9c34e..da35702fd5 100644 --- a/src/common/k8s-api/endpoints/limit-range.api.ts +++ b/src/common/k8s-api/endpoints/limit-range.api.ts @@ -4,10 +4,9 @@ */ import { KubeObject } from "../kube-object"; -import { KubeApi } from "../kube-api"; +import { KubeApi, SpecificApiOptions } from "../kube-api"; import { autoBind } from "../../utils"; import type { KubeJsonApiData } from "../kube-json-api"; -import { isClusterPageContext } from "../../utils/cluster-id-url-parsing"; export enum LimitType { CONTAINER = "Container", @@ -65,14 +64,11 @@ export class LimitRange extends KubeObject { } } -let limitRangeApi: KubeApi; - -if (isClusterPageContext()) { - limitRangeApi = new KubeApi({ - objectConstructor: LimitRange, - }); +export class LimitRangeApi extends KubeApi { + constructor(args: SpecificApiOptions = {}) { + super({ + ...args, + objectConstructor: LimitRange, + }); + } } - -export { - limitRangeApi, -}; diff --git a/src/common/k8s-api/endpoints/metrics.api.ts b/src/common/k8s-api/endpoints/metrics.api.ts index 29a7ac4ee7..8de73d42af 100644 --- a/src/common/k8s-api/endpoints/metrics.api.ts +++ b/src/common/k8s-api/endpoints/metrics.api.ts @@ -7,7 +7,7 @@ import moment from "moment"; import { apiBase } from "../index"; -import type { IMetricsQuery } from "../../../main/routes/metrics-route"; +import type { IMetricsQuery } from "../../../main/routes/metrics/route"; export interface IMetrics { status: string; @@ -56,7 +56,7 @@ export interface IResourceMetrics { } export const metricsApi = { - async getMetrics(query: T, reqParams: IMetricsReqParams = {}): Promise { + getMetrics(query: T, reqParams: IMetricsReqParams = {}): Promise { const { range = 3600, step = 60, namespace } = reqParams; let { start, end } = reqParams; @@ -77,7 +77,7 @@ export const metricsApi = { }); }, - async getMetricProviders(): Promise { + getMetricProviders(): Promise { return apiBase.get("/metrics/providers"); }, }; @@ -141,8 +141,8 @@ export function isMetricsEmpty(metrics: Record) { return Object.values(metrics).every(metric => !metric?.data?.result?.length); } -export function getItemMetrics(metrics: Record, itemName: string): Record | void { - if (!metrics) return; +export function getItemMetrics(metrics: Record, itemName: string): Record | null { + if (!metrics) return null; const itemMetrics = { ...metrics }; for (const metric in metrics) { @@ -159,7 +159,7 @@ export function getItemMetrics(metrics: Record, itemName: stri } export function getMetricLastPoints(metrics: Record) { - const result: Partial<{ [metric: string]: number }> = {}; + const result: Partial> = {}; Object.keys(metrics).forEach(metricName => { try { diff --git a/src/common/k8s-api/endpoints/namespace.api.injectable.ts b/src/common/k8s-api/endpoints/namespace.api.injectable.ts new file mode 100644 index 0000000000..7d6e370fb2 --- /dev/null +++ b/src/common/k8s-api/endpoints/namespace.api.injectable.ts @@ -0,0 +1,14 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import apiManagerInjectable from "../api-manager.injectable"; +import type { NamespaceApi } from "./namespace.api"; + +const namespaceApiInjectable = getInjectable({ + instantiate: (di) => di.inject(apiManagerInjectable).getApi("/api/v1/namespaces") as NamespaceApi, + lifecycle: lifecycleEnum.singleton, +}); + +export default namespaceApiInjectable; diff --git a/src/common/k8s-api/endpoints/namespaces.api.ts b/src/common/k8s-api/endpoints/namespace.api.ts similarity index 78% rename from src/common/k8s-api/endpoints/namespaces.api.ts rename to src/common/k8s-api/endpoints/namespace.api.ts index 07b51f701d..45c1009d95 100644 --- a/src/common/k8s-api/endpoints/namespaces.api.ts +++ b/src/common/k8s-api/endpoints/namespace.api.ts @@ -3,13 +3,12 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import { KubeApi } from "../kube-api"; +import { KubeApi, SpecificApiOptions } from "../kube-api"; import { KubeObject } from "../kube-object"; import { autoBind } from "../../../renderer/utils"; import { metricsApi } from "./metrics.api"; -import type { IPodMetrics } from "./pods.api"; +import type { IPodMetrics } from "./pod.api"; import type { KubeJsonApiData } from "../kube-json-api"; -import { isClusterPageContext } from "../../utils/cluster-id-url-parsing"; export enum NamespaceStatus { ACTIVE = "Active", @@ -37,9 +36,6 @@ export class Namespace extends KubeObject { } } -export class NamespaceApi extends KubeApi { -} - export function getMetricsForNamespace(namespace: string, selector = ""): Promise { const opts = { category: "pods", pods: ".*", namespace, selector }; @@ -56,14 +52,11 @@ export function getMetricsForNamespace(namespace: string, selector = ""): Promis }); } -let namespacesApi: NamespaceApi; - -if (isClusterPageContext()) { - namespacesApi = new NamespaceApi({ - objectConstructor: Namespace, - }); +export class NamespaceApi extends KubeApi { + constructor(args: SpecificApiOptions = {}) { + super({ + ...args, + objectConstructor: Namespace, + }); + } } - -export { - namespacesApi, -}; diff --git a/src/common/k8s-api/endpoints/network-policy.api.injectable.ts b/src/common/k8s-api/endpoints/network-policy.api.injectable.ts new file mode 100644 index 0000000000..c41474cee8 --- /dev/null +++ b/src/common/k8s-api/endpoints/network-policy.api.injectable.ts @@ -0,0 +1,14 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import apiManagerInjectable from "../api-manager.injectable"; +import type { NetworkPolicyApi } from "./network-policy.api"; + +const networkPolicyApiInjectable = getInjectable({ + instantiate: (di) => di.inject(apiManagerInjectable).getApi("/apis/networking.k8s.io/v1/networkpolicies") as NetworkPolicyApi, + lifecycle: lifecycleEnum.singleton, +}); + +export default networkPolicyApiInjectable; diff --git a/src/common/k8s-api/endpoints/network-policy.api.ts b/src/common/k8s-api/endpoints/network-policy.api.ts index f51c11d70f..135b6d7c29 100644 --- a/src/common/k8s-api/endpoints/network-policy.api.ts +++ b/src/common/k8s-api/endpoints/network-policy.api.ts @@ -3,11 +3,10 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import { KubeObject, LabelSelector } from "../kube-object"; +import { KubeObject, KubeObjectMetadata, KubeObjectStatus, LabelSelector } from "../kube-object"; import { autoBind } from "../../utils"; -import { KubeApi } from "../kube-api"; +import { KubeApi, SpecificApiOptions } from "../kube-api"; import type { KubeJsonApiData } from "../kube-json-api"; -import { isClusterPageContext } from "../../utils/cluster-id-url-parsing"; export interface IPolicyIpBlock { cidr: string; @@ -100,16 +99,12 @@ export interface NetworkPolicySpec { egress?: IPolicyEgress[]; } -export interface NetworkPolicy { - spec: NetworkPolicySpec; -} - -export class NetworkPolicy extends KubeObject { +export class NetworkPolicy extends KubeObject { static kind = "NetworkPolicy"; static namespaced = true; static apiBase = "/apis/networking.k8s.io/v1/networkpolicies"; - constructor(data: KubeJsonApiData) { + constructor(data: KubeJsonApiData) { super(data); autoBind(this); } @@ -129,14 +124,11 @@ export class NetworkPolicy extends KubeObject { } } -let networkPolicyApi: KubeApi; - -if (isClusterPageContext()) { - networkPolicyApi = new KubeApi({ - objectConstructor: NetworkPolicy, - }); +export class NetworkPolicyApi extends KubeApi { + constructor(args: SpecificApiOptions = {}) { + super({ + ...args, + objectConstructor: NetworkPolicy, + }); + } } - -export { - networkPolicyApi, -}; diff --git a/src/common/k8s-api/endpoints/node.api.injectable.ts b/src/common/k8s-api/endpoints/node.api.injectable.ts new file mode 100644 index 0000000000..563b691ca7 --- /dev/null +++ b/src/common/k8s-api/endpoints/node.api.injectable.ts @@ -0,0 +1,15 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import type { NodeApi } from "./node.api"; +import apiManagerInjectable from "../api-manager.injectable"; + +const nodeApiInjectable = getInjectable({ + instantiate: (di) => di.inject(apiManagerInjectable).getApi("/api/v1/nodes") as NodeApi, + lifecycle: lifecycleEnum.singleton, +}); + +export default nodeApiInjectable; diff --git a/src/common/k8s-api/endpoints/nodes.api.ts b/src/common/k8s-api/endpoints/node.api.ts similarity index 93% rename from src/common/k8s-api/endpoints/nodes.api.ts rename to src/common/k8s-api/endpoints/node.api.ts index 34e1dd0863..5b569d8ac4 100644 --- a/src/common/k8s-api/endpoints/nodes.api.ts +++ b/src/common/k8s-api/endpoints/node.api.ts @@ -6,12 +6,9 @@ import { KubeObject } from "../kube-object"; import { autoBind, cpuUnitsToNumber, iter, unitsToBytes } from "../../../renderer/utils"; import { IMetrics, metricsApi } from "./metrics.api"; -import { KubeApi } from "../kube-api"; +import { KubeApi, SpecificApiOptions } from "../kube-api"; import type { KubeJsonApiData } from "../kube-json-api"; -import { isClusterPageContext } from "../../utils/cluster-id-url-parsing"; -export class NodesApi extends KubeApi { -} export function getMetricsForAllNodes(): Promise { const opts = { category: "nodes" }; @@ -171,7 +168,7 @@ export class Node extends KubeObject { } const roleLabels = Object.keys(this.metadata.labels) - .filter(key => key.includes("node-role.kubernetes.io")) + .filter(key => key.startsWith("node-role.kubernetes.io/")) .map(key => key.match(/([^/]+$)/)[0]); // all after last slash if (this.metadata.labels["kubernetes.io/role"] != undefined) { @@ -231,14 +228,11 @@ export class Node extends KubeObject { } } -let nodesApi: NodesApi; - -if (isClusterPageContext()) { - nodesApi = new NodesApi({ - objectConstructor: Node, - }); +export class NodeApi extends KubeApi { + constructor(args: SpecificApiOptions = {}) { + super({ + ...args, + objectConstructor: Node, + }); + } } - -export { - nodesApi, -}; diff --git a/src/common/k8s-api/endpoints/persistent-volume-claim.api.injectable.ts b/src/common/k8s-api/endpoints/persistent-volume-claim.api.injectable.ts new file mode 100644 index 0000000000..4005ef9c1e --- /dev/null +++ b/src/common/k8s-api/endpoints/persistent-volume-claim.api.injectable.ts @@ -0,0 +1,14 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import apiManagerInjectable from "../api-manager.injectable"; +import type { PersistentVolumeClaimApi } from "./persistent-volume-claims.api"; + +const persistentVolumeClaimApiInjectable = getInjectable({ + instantiate: (di) => di.inject(apiManagerInjectable).getApi("/api/v1/persistentvolumeclaims") as PersistentVolumeClaimApi, + lifecycle: lifecycleEnum.singleton, +}); + +export default persistentVolumeClaimApiInjectable; diff --git a/src/common/k8s-api/endpoints/persistent-volume-claims.api.ts b/src/common/k8s-api/endpoints/persistent-volume-claims.api.ts index a7815e774c..bc2af15d55 100644 --- a/src/common/k8s-api/endpoints/persistent-volume-claims.api.ts +++ b/src/common/k8s-api/endpoints/persistent-volume-claims.api.ts @@ -6,13 +6,9 @@ import { KubeObject, LabelSelector } from "../kube-object"; import { autoBind } from "../../utils"; import { IMetrics, metricsApi } from "./metrics.api"; -import type { Pod } from "./pods.api"; -import { KubeApi } from "../kube-api"; +import type { Pod } from "./pod.api"; +import { KubeApi, SpecificApiOptions } from "../kube-api"; import type { KubeJsonApiData } from "../kube-json-api"; -import { isClusterPageContext } from "../../utils/cluster-id-url-parsing"; - -export class PersistentVolumeClaimsApi extends KubeApi { -} export function getMetricsForPvc(pvc: PersistentVolumeClaim): Promise { const opts = { category: "pvc", pvc: pvc.getName(), namespace: pvc.getNs() }; @@ -94,14 +90,11 @@ export class PersistentVolumeClaim extends KubeObject { } } -let pvcApi: PersistentVolumeClaimsApi; - -if (isClusterPageContext()) { - pvcApi = new PersistentVolumeClaimsApi({ - objectConstructor: PersistentVolumeClaim, - }); +export class PersistentVolumeClaimApi extends KubeApi { + constructor(args: SpecificApiOptions = {}) { + super({ + ...args, + objectConstructor: PersistentVolumeClaim, + }); + } } - -export { - pvcApi, -}; diff --git a/src/common/k8s-api/endpoints/persistent-volume.api.injectable.ts b/src/common/k8s-api/endpoints/persistent-volume.api.injectable.ts new file mode 100644 index 0000000000..bf6812b114 --- /dev/null +++ b/src/common/k8s-api/endpoints/persistent-volume.api.injectable.ts @@ -0,0 +1,14 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import apiManagerInjectable from "../api-manager.injectable"; +import type { PersistentVolumeApi } from "./persistent-volume.api"; + +const persistentVolumeApiInjectable = getInjectable({ + instantiate: (di) => di.inject(apiManagerInjectable).getApi("/api/v1/persistentvolumes") as PersistentVolumeApi, + lifecycle: lifecycleEnum.singleton, +}); + +export default persistentVolumeApiInjectable; diff --git a/src/common/k8s-api/endpoints/persistent-volume.api.ts b/src/common/k8s-api/endpoints/persistent-volume.api.ts index cf225dace8..0b26e12cf7 100644 --- a/src/common/k8s-api/endpoints/persistent-volume.api.ts +++ b/src/common/k8s-api/endpoints/persistent-volume.api.ts @@ -5,9 +5,8 @@ import { KubeObject } from "../kube-object"; import { autoBind, unitsToBytes } from "../../utils"; -import { KubeApi } from "../kube-api"; +import { KubeApi, SpecificApiOptions } from "../kube-api"; import type { KubeJsonApiData } from "../kube-json-api"; -import { isClusterPageContext } from "../../utils/cluster-id-url-parsing"; export interface PersistentVolume { spec: { @@ -86,14 +85,11 @@ export class PersistentVolume extends KubeObject { } } -let persistentVolumeApi: KubeApi; - -if (isClusterPageContext()) { - persistentVolumeApi = new KubeApi({ - objectConstructor: PersistentVolume, - }); +export class PersistentVolumeApi extends KubeApi { + constructor(args: SpecificApiOptions = {}) { + super({ + ...args, + objectConstructor: PersistentVolume, + }); + } } - -export { - persistentVolumeApi, -}; diff --git a/src/common/k8s-api/endpoints/pod-disruption-budget.api.injectable.ts b/src/common/k8s-api/endpoints/pod-disruption-budget.api.injectable.ts new file mode 100644 index 0000000000..d82a91d5c6 --- /dev/null +++ b/src/common/k8s-api/endpoints/pod-disruption-budget.api.injectable.ts @@ -0,0 +1,14 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import apiManagerInjectable from "../api-manager.injectable"; +import type { PodDisruptionBudgetApi } from "./pod-disruption-budget.api"; + +const podDisruptionBudgetApiInjectable = getInjectable({ + instantiate: (di) => di.inject(apiManagerInjectable).getApi("/apis/policy/v1beta1/poddisruptionbudgets") as PodDisruptionBudgetApi, + lifecycle: lifecycleEnum.singleton, +}); + +export default podDisruptionBudgetApiInjectable; diff --git a/src/common/k8s-api/endpoints/poddisruptionbudget.api.ts b/src/common/k8s-api/endpoints/pod-disruption-budget.api.ts similarity index 81% rename from src/common/k8s-api/endpoints/poddisruptionbudget.api.ts rename to src/common/k8s-api/endpoints/pod-disruption-budget.api.ts index 699028cc2e..70cc78d275 100644 --- a/src/common/k8s-api/endpoints/poddisruptionbudget.api.ts +++ b/src/common/k8s-api/endpoints/pod-disruption-budget.api.ts @@ -5,9 +5,8 @@ import { autoBind } from "../../utils"; import { KubeObject, LabelSelector } from "../kube-object"; -import { KubeApi } from "../kube-api"; +import { KubeApi, SpecificApiOptions } from "../kube-api"; import type { KubeJsonApiData } from "../kube-json-api"; -import { isClusterPageContext } from "../../utils/cluster-id-url-parsing"; export interface PodDisruptionBudget { spec: { @@ -54,17 +53,13 @@ export class PodDisruptionBudget extends KubeObject { getDesiredHealthy() { return this.status.desiredHealthy; } - } -let pdbApi: KubeApi; - -if (isClusterPageContext()) { - pdbApi = new KubeApi({ - objectConstructor: PodDisruptionBudget, - }); +export class PodDisruptionBudgetApi extends KubeApi { + constructor(args: SpecificApiOptions = {}) { + super({ + ...args, + objectConstructor: PodDisruptionBudget, + }); + } } - -export { - pdbApi, -}; diff --git a/src/common/k8s-api/endpoints/pod-metrics.api.injectable.ts b/src/common/k8s-api/endpoints/pod-metrics.api.injectable.ts new file mode 100644 index 0000000000..1506f7757d --- /dev/null +++ b/src/common/k8s-api/endpoints/pod-metrics.api.injectable.ts @@ -0,0 +1,14 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import apiManagerInjectable from "../api-manager.injectable"; +import type { PodMetricsApi } from "./pod-metrics.api"; + +const podMetricsApiInjectable = getInjectable({ + instantiate: (di) => di.inject(apiManagerInjectable).getApi("/apis/metrics.k8s.io/v1beta1/pods") as PodMetricsApi, + lifecycle: lifecycleEnum.singleton, +}); + +export default podMetricsApiInjectable; diff --git a/src/common/k8s-api/endpoints/pod-metrics.api.ts b/src/common/k8s-api/endpoints/pod-metrics.api.ts index f92e6a6403..98331b9b43 100644 --- a/src/common/k8s-api/endpoints/pod-metrics.api.ts +++ b/src/common/k8s-api/endpoints/pod-metrics.api.ts @@ -4,8 +4,7 @@ */ import { KubeObject } from "../kube-object"; -import { KubeApi } from "../kube-api"; -import { isClusterPageContext } from "../../utils/cluster-id-url-parsing"; +import { KubeApi, SpecificApiOptions } from "../kube-api"; export interface PodMetrics { timestamp: string; @@ -25,14 +24,11 @@ export class PodMetrics extends KubeObject { static apiBase = "/apis/metrics.k8s.io/v1beta1/pods"; } -let podMetricsApi: KubeApi; - -if (isClusterPageContext()) { - podMetricsApi = new KubeApi({ - objectConstructor: PodMetrics, - }); +export class PodMetricsApi extends KubeApi { + constructor(args: SpecificApiOptions = {}) { + super({ + ...args, + objectConstructor: PodMetrics, + }); + } } - -export { - podMetricsApi, -}; diff --git a/src/common/k8s-api/endpoints/pod-security-policy.api.injectable.ts b/src/common/k8s-api/endpoints/pod-security-policy.api.injectable.ts new file mode 100644 index 0000000000..191f73a6ef --- /dev/null +++ b/src/common/k8s-api/endpoints/pod-security-policy.api.injectable.ts @@ -0,0 +1,14 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import apiManagerInjectable from "../api-manager.injectable"; +import type { PodSecurityPolicyApi } from "./pod-security-policy.api"; + +const podSecurityPolicyApiInjectable = getInjectable({ + instantiate: (di) => di.inject(apiManagerInjectable).getApi("/apis/policy/v1beta1/podsecuritypolicies") as PodSecurityPolicyApi, + lifecycle: lifecycleEnum.singleton, +}); + +export default podSecurityPolicyApiInjectable; diff --git a/src/common/k8s-api/endpoints/podsecuritypolicy.api.ts b/src/common/k8s-api/endpoints/pod-security-policy.api.ts similarity index 89% rename from src/common/k8s-api/endpoints/podsecuritypolicy.api.ts rename to src/common/k8s-api/endpoints/pod-security-policy.api.ts index 9220d65e77..8415fb5c46 100644 --- a/src/common/k8s-api/endpoints/podsecuritypolicy.api.ts +++ b/src/common/k8s-api/endpoints/pod-security-policy.api.ts @@ -5,9 +5,8 @@ import { autoBind } from "../../utils"; import { KubeObject } from "../kube-object"; -import { KubeApi } from "../kube-api"; +import { KubeApi, SpecificApiOptions } from "../kube-api"; import type { KubeJsonApiData } from "../kube-json-api"; -import { isClusterPageContext } from "../../utils/cluster-id-url-parsing"; export interface PodSecurityPolicy { spec: { @@ -102,14 +101,11 @@ export class PodSecurityPolicy extends KubeObject { } } -let pspApi: KubeApi; - -if (isClusterPageContext()) { - pspApi = new KubeApi({ - objectConstructor: PodSecurityPolicy, - }); +export class PodSecurityPolicyApi extends KubeApi { + constructor(args: SpecificApiOptions = {}) { + super({ + ...args, + objectConstructor: PodSecurityPolicy, + }); + } } - -export { - pspApi, -}; diff --git a/src/common/k8s-api/endpoints/pod.api.injectable.ts b/src/common/k8s-api/endpoints/pod.api.injectable.ts new file mode 100644 index 0000000000..0895a65e5d --- /dev/null +++ b/src/common/k8s-api/endpoints/pod.api.injectable.ts @@ -0,0 +1,14 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import apiManagerInjectable from "../api-manager.injectable"; +import type { PodApi } from "./pod.api"; + +const podApiInjectable = getInjectable({ + instantiate: (di) => di.inject(apiManagerInjectable).getApi("/api/v1/pods") as PodApi, + lifecycle: lifecycleEnum.singleton, +}); + +export default podApiInjectable; diff --git a/src/common/k8s-api/endpoints/pods.api.ts b/src/common/k8s-api/endpoints/pod.api.ts similarity index 80% rename from src/common/k8s-api/endpoints/pods.api.ts rename to src/common/k8s-api/endpoints/pod.api.ts index 7648fb49c4..34175b8fe4 100644 --- a/src/common/k8s-api/endpoints/pods.api.ts +++ b/src/common/k8s-api/endpoints/pod.api.ts @@ -3,20 +3,12 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import { IAffinity, WorkloadKubeObject } from "../workload-kube-object"; +import { IAffinity, WorkloadKubeObject, WorkloadSpec } from "../workload-kube-object"; import { autoBind } from "../../utils"; import { IMetrics, metricsApi } from "./metrics.api"; -import { KubeApi } from "../kube-api"; +import { KubeApi, SpecificApiOptions } from "../kube-api"; import type { KubeJsonApiData } from "../kube-json-api"; -import { isClusterPageContext } from "../../utils/cluster-id-url-parsing"; - -export class PodsApi extends KubeApi { - getLogs = async (params: { namespace: string; name: string }, query?: IPodLogsQuery): Promise => { - const path = `${this.getUrl(params)}/log`; - - return this.request.get(path, { query }); - }; -} +import type { KubeObjectMetadata, KubeObjectStatus } from "../kube-object"; export function getMetricsForPods(pods: Pod[], namespace: string, selector = "pod, namespace"): Promise { const podSelector = pods.map(pod => pod.getName()).join("|"); @@ -195,93 +187,95 @@ export interface IPodContainerStatus { started?: boolean; } -export class Pod extends WorkloadKubeObject { +export interface PodSpec extends WorkloadSpec { + volumes?: { + name: string; + persistentVolumeClaim: { + claimName: string; + }; + emptyDir: { + medium?: string; + sizeLimit?: string; + }; + configMap: { + name: string; + }; + secret: { + secretName: string; + defaultMode: number; + }; + }[]; + initContainers: IPodContainer[]; + containers: IPodContainer[]; + restartPolicy?: string; + terminationGracePeriodSeconds?: number; + activeDeadlineSeconds?: number; + dnsPolicy?: string; + serviceAccountName: string; + serviceAccount: string; + automountServiceAccountToken?: boolean; + priority?: number; + priorityClassName?: string; + nodeName?: string; + nodeSelector?: { + [selector: string]: string; + }; + securityContext?: {}; + imagePullSecrets?: { + name: string; + }[]; + hostNetwork?: boolean; + hostPID?: boolean; + hostIPC?: boolean; + shareProcessNamespace?: boolean; + hostname?: string; + subdomain?: string; + schedulerName?: string; + tolerations?: { + key?: string; + operator?: string; + effect?: string; + tolerationSeconds?: number; + value?: string; + }[]; + hostAliases?: { + ip: string; + hostnames: string[]; + }; + affinity?: IAffinity; +} + +export interface PodStatusCondition { + type: string; + status: string; + lastProbeTime: number; + lastTransitionTime: string; +} + +export interface PodObjectStatus extends KubeObjectStatus { + phase: string; + hostIP: string; + podIP: string; + podIPs?: { + ip: string; + }[]; + startTime: string; + initContainerStatuses?: IPodContainerStatus[]; + containerStatuses?: IPodContainerStatus[]; + qosClass?: string; + reason?: string; +} + +export class Pod extends WorkloadKubeObject { static kind = "Pod"; static namespaced = true; static apiBase = "/api/v1/pods"; - constructor(data: KubeJsonApiData) { + constructor(data: KubeJsonApiData) { super(data); autoBind(this); } - declare spec?: { - volumes?: { - name: string; - persistentVolumeClaim: { - claimName: string; - }; - emptyDir: { - medium?: string; - sizeLimit?: string; - }; - configMap: { - name: string; - }; - secret: { - secretName: string; - defaultMode: number; - }; - }[]; - initContainers: IPodContainer[]; - containers: IPodContainer[]; - restartPolicy?: string; - terminationGracePeriodSeconds?: number; - activeDeadlineSeconds?: number; - dnsPolicy?: string; - serviceAccountName: string; - serviceAccount: string; - automountServiceAccountToken?: boolean; - priority?: number; - priorityClassName?: string; - nodeName?: string; - nodeSelector?: { - [selector: string]: string; - }; - securityContext?: {}; - imagePullSecrets?: { - name: string; - }[]; - hostNetwork?: boolean; - hostPID?: boolean; - hostIPC?: boolean; - shareProcessNamespace?: boolean; - hostname?: string; - subdomain?: string; - schedulerName?: string; - tolerations?: { - key?: string; - operator?: string; - effect?: string; - tolerationSeconds?: number; - value?: string; - }[]; - hostAliases?: { - ip: string; - hostnames: string[]; - }; - affinity?: IAffinity; - }; - declare status?: { - phase: string; - conditions: { - type: string; - status: string; - lastProbeTime: number; - lastTransitionTime: string; - }[]; - hostIP: string; - podIP: string; - podIPs?: { - ip: string - }[]; - startTime: string; - initContainerStatuses?: IPodContainerStatus[]; - containerStatuses?: IPodContainerStatus[]; - qosClass?: string; - reason?: string; - }; - getInitContainers() { return this.spec?.initContainers || []; } @@ -501,14 +495,17 @@ export class Pod extends WorkloadKubeObject { } } -let podsApi: PodsApi; +export class PodApi extends KubeApi { + constructor(args: SpecificApiOptions = {}) { + super({ + ...args, + objectConstructor: Pod, + }); + } -if (isClusterPageContext()) { - podsApi = new PodsApi({ - objectConstructor: Pod, - }); + getLogs(params: { namespace: string; name: string }, query?: IPodLogsQuery): Promise { + const path = `${this.getUrl(params)}/log`; + + return this.request.get(path, { query }); + } } - -export { - podsApi, -}; diff --git a/src/common/k8s-api/endpoints/replica-set.api.injectable.ts b/src/common/k8s-api/endpoints/replica-set.api.injectable.ts new file mode 100644 index 0000000000..2c45b0b4dd --- /dev/null +++ b/src/common/k8s-api/endpoints/replica-set.api.injectable.ts @@ -0,0 +1,14 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import apiManagerInjectable from "../api-manager.injectable"; +import type { ReplicaSetApi } from "./replica-set.api"; + +const replicaSetApiInjectable = getInjectable({ + instantiate: (di) => di.inject(apiManagerInjectable).getApi("/apis/apps/v1/replicasets") as ReplicaSetApi, + lifecycle: lifecycleEnum.singleton, +}); + +export default replicaSetApiInjectable; diff --git a/src/common/k8s-api/endpoints/replica-set.api.ts b/src/common/k8s-api/endpoints/replica-set.api.ts index 302ad96b17..9e18c17e52 100644 --- a/src/common/k8s-api/endpoints/replica-set.api.ts +++ b/src/common/k8s-api/endpoints/replica-set.api.ts @@ -6,36 +6,12 @@ import get from "lodash/get"; import { autoBind } from "../../../renderer/utils"; import { WorkloadKubeObject } from "../workload-kube-object"; -import { KubeApi } from "../kube-api"; +import { KubeApi, SpecificApiOptions } from "../kube-api"; import { metricsApi } from "./metrics.api"; -import type { IPodContainer, IPodMetrics, Pod } from "./pods.api"; +import type { IPodContainer, IPodMetrics, Pod } from "./pod.api"; import type { KubeJsonApiData } from "../kube-json-api"; -import { isClusterPageContext } from "../../utils/cluster-id-url-parsing"; import type { LabelSelector } from "../kube-object"; -export class ReplicaSetApi extends KubeApi { - protected getScaleApiUrl(params: { namespace: string; name: string }) { - return `${this.getUrl(params)}/scale`; - } - - getReplicas(params: { namespace: string; name: string }): Promise { - return this.request - .get(this.getScaleApiUrl(params)) - .then(({ status }: any) => status?.replicas); - } - - scale(params: { namespace: string; name: string }, replicas: number) { - return this.request.put(this.getScaleApiUrl(params), { - data: { - metadata: params, - spec: { - replicas, - }, - }, - }); - } -} - export function getMetricsForReplicaSets(replicasets: ReplicaSet[], namespace: string, selector = ""): Promise { const podSelector = replicasets.map(replicaset => `${replicaset.getName()}-[[:alnum:]]{5}`).join("|"); const opts = { category: "pods", pods: podSelector, namespace, selector }; @@ -111,14 +87,38 @@ export class ReplicaSet extends WorkloadKubeObject { } } -let replicaSetApi: ReplicaSetApi; - -if (isClusterPageContext()) { - replicaSetApi = new ReplicaSetApi({ - objectConstructor: ReplicaSet, - }); +interface ReplicasStatus { + status?: { + replicas: number; + } } -export { - replicaSetApi, -}; +export class ReplicaSetApi extends KubeApi { + constructor(args: SpecificApiOptions = {}) { + super({ + ...args, + objectConstructor: ReplicaSet, + }); + } + + protected getScaleApiUrl(params: { namespace: string; name: string }) { + return `${this.getUrl(params)}/scale`; + } + + async getReplicas(params: { namespace: string; name: string }): Promise { + const { status } = await this.request.get(this.getScaleApiUrl(params)); + + return status?.replicas ?? 0; + } + + scale(params: { namespace: string; name: string }, replicas: number) { + return this.request.put(this.getScaleApiUrl(params), { + data: { + metadata: params, + spec: { + replicas, + }, + }, + }); + } +} diff --git a/src/common/k8s-api/endpoints/resource-applier.api.ts b/src/common/k8s-api/endpoints/resource-applier.api.ts index 0bc5d4e551..ed37388968 100644 --- a/src/common/k8s-api/endpoints/resource-applier.api.ts +++ b/src/common/k8s-api/endpoints/resource-applier.api.ts @@ -12,7 +12,7 @@ export const annotations = [ "kubectl.kubernetes.io/last-applied-configuration", ]; -export async function update(resource: object | string): Promise { +export function update(resource: object | string): Promise { if (typeof resource === "string") { const parsed = yaml.load(resource); @@ -26,7 +26,7 @@ export async function update(resource: object | string): Promise("/stack", { data: resource }); } -export async function patch(name: string, kind: string, ns: string, patch: Patch): Promise { +export function patch(name: string, kind: string, ns: string, patch: Patch): Promise { return apiBase.patch("/stack", { data: { name, diff --git a/src/common/k8s-api/endpoints/resource-quota.api.injectable.ts b/src/common/k8s-api/endpoints/resource-quota.api.injectable.ts new file mode 100644 index 0000000000..2ea41aa0ca --- /dev/null +++ b/src/common/k8s-api/endpoints/resource-quota.api.injectable.ts @@ -0,0 +1,14 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import apiManagerInjectable from "../api-manager.injectable"; +import type { ResourceQuotaApi } from "./resource-quota.api"; + +const resourceQuotaApiInjectable = getInjectable({ + instantiate: (di) => di.inject(apiManagerInjectable).getApi("/api/v1/resourcequotas") as ResourceQuotaApi, + lifecycle: lifecycleEnum.singleton, +}); + +export default resourceQuotaApiInjectable; diff --git a/src/common/k8s-api/endpoints/resource-quota.api.ts b/src/common/k8s-api/endpoints/resource-quota.api.ts index eda54a7249..3c75effa64 100644 --- a/src/common/k8s-api/endpoints/resource-quota.api.ts +++ b/src/common/k8s-api/endpoints/resource-quota.api.ts @@ -4,36 +4,32 @@ */ import { KubeObject } from "../kube-object"; -import { KubeApi } from "../kube-api"; -import { isClusterPageContext } from "../../utils/cluster-id-url-parsing"; +import { KubeApi, SpecificApiOptions } from "../kube-api"; -export interface IResourceQuotaValues { - [quota: string]: string; +export const resourceQuotaKinds = [ + "limits.cpu", + "limits.memory", + "requests.cpu", + "requests.memory", + "requests.storage", + "persistentvolumeclaims", + "count/pods", + "count/persistentvolumeclaims", + "count/services", + "count/secrets", + "count/configmaps", + "count/replicationcontrollers", + "count/deployments.apps", + "count/replicasets.apps", + "count/statefulsets.apps", + "count/jobs.batch", + "count/cronjobs.batch", + "count/deployments.extensions", +] as const; - // Compute Resource Quota - "limits.cpu"?: string; - "limits.memory"?: string; - "requests.cpu"?: string; - "requests.memory"?: string; +export type ResourceQuotaKinds = typeof resourceQuotaKinds[number]; - // Storage Resource Quota - "requests.storage"?: string; - "persistentvolumeclaims"?: string; - - // Object Count Quota - "count/pods"?: string; - "count/persistentvolumeclaims"?: string; - "count/services"?: string; - "count/secrets"?: string; - "count/configmaps"?: string; - "count/replicationcontrollers"?: string; - "count/deployments.apps"?: string; - "count/replicasets.apps"?: string; - "count/statefulsets.apps"?: string; - "count/jobs.batch"?: string; - "count/cronjobs.batch"?: string; - "count/deployments.extensions"?: string; -} +export type IResourceQuotaValues = Partial>; export interface ResourceQuota { spec: { @@ -65,14 +61,11 @@ export class ResourceQuota extends KubeObject { } } -let resourceQuotaApi: KubeApi; - -if (isClusterPageContext()) { - resourceQuotaApi = new KubeApi({ - objectConstructor: ResourceQuota, - }); +export class ResourceQuotaApi extends KubeApi { + constructor(args: SpecificApiOptions = {}) { + super({ + ...args, + objectConstructor: ResourceQuota, + }); + } } - -export { - resourceQuotaApi, -}; diff --git a/src/common/k8s-api/endpoints/role-binding.api.injectable.ts b/src/common/k8s-api/endpoints/role-binding.api.injectable.ts new file mode 100644 index 0000000000..94a26d9e3d --- /dev/null +++ b/src/common/k8s-api/endpoints/role-binding.api.injectable.ts @@ -0,0 +1,14 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import apiManagerInjectable from "../api-manager.injectable"; +import type { RoleBindingApi } from "./role-binding.api"; + +const roleBindingApiInjectable = getInjectable({ + instantiate: (di) => di.inject(apiManagerInjectable).getApi("/apis/rbac.authorization.k8s.io/v1/rolebindings") as RoleBindingApi, + lifecycle: lifecycleEnum.singleton, +}); + +export default roleBindingApiInjectable; diff --git a/src/common/k8s-api/endpoints/role-binding.api.ts b/src/common/k8s-api/endpoints/role-binding.api.ts index dca2510dd3..44b2591057 100644 --- a/src/common/k8s-api/endpoints/role-binding.api.ts +++ b/src/common/k8s-api/endpoints/role-binding.api.ts @@ -5,9 +5,8 @@ import { autoBind } from "../../utils"; import { KubeObject } from "../kube-object"; -import { KubeApi } from "../kube-api"; +import { KubeApi, SpecificApiOptions } from "../kube-api"; import type { KubeJsonApiData } from "../kube-json-api"; -import { isClusterPageContext } from "../../utils/cluster-id-url-parsing"; export type RoleBindingSubjectKind = "Group" | "ServiceAccount" | "User"; @@ -46,14 +45,11 @@ export class RoleBinding extends KubeObject { } } -let roleBindingApi: KubeApi; - -if (isClusterPageContext()) { - roleBindingApi = new KubeApi({ - objectConstructor: RoleBinding, - }); +export class RoleBindingApi extends KubeApi { + constructor(args: SpecificApiOptions = {}) { + super({ + ...args, + objectConstructor: RoleBinding, + }); + } } - -export { - roleBindingApi, -}; diff --git a/src/common/k8s-api/endpoints/role.api.injectable.ts b/src/common/k8s-api/endpoints/role.api.injectable.ts new file mode 100644 index 0000000000..c61f4f1378 --- /dev/null +++ b/src/common/k8s-api/endpoints/role.api.injectable.ts @@ -0,0 +1,14 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import apiManagerInjectable from "../api-manager.injectable"; +import type { RoleApi } from "./role.api"; + +const roleApiInjectable = getInjectable({ + instantiate: (di) => di.inject(apiManagerInjectable).getApi("/apis/rbac.authorization.k8s.io/v1/roles") as RoleApi, + lifecycle: lifecycleEnum.singleton, +}); + +export default roleApiInjectable; diff --git a/src/common/k8s-api/endpoints/role.api.ts b/src/common/k8s-api/endpoints/role.api.ts index ee960bb934..0678708b98 100644 --- a/src/common/k8s-api/endpoints/role.api.ts +++ b/src/common/k8s-api/endpoints/role.api.ts @@ -4,11 +4,10 @@ */ import { KubeObject } from "../kube-object"; -import { KubeApi } from "../kube-api"; -import { isClusterPageContext } from "../../utils/cluster-id-url-parsing"; +import { KubeApi, SpecificApiOptions } from "../kube-api"; export interface Role { - rules: { + rules?: { verbs: string[]; apiGroups: string[]; resources: string[]; @@ -26,14 +25,11 @@ export class Role extends KubeObject { } } -let roleApi: KubeApi; - -if (isClusterPageContext()) { - roleApi = new KubeApi({ - objectConstructor: Role, - }); +export class RoleApi extends KubeApi { + constructor(args: SpecificApiOptions = {}) { + super({ + ...args, + objectConstructor: Role, + }); + } } - -export{ - roleApi, -}; diff --git a/src/common/k8s-api/endpoints/secret.api.injectable.ts b/src/common/k8s-api/endpoints/secret.api.injectable.ts new file mode 100644 index 0000000000..09f5508cf6 --- /dev/null +++ b/src/common/k8s-api/endpoints/secret.api.injectable.ts @@ -0,0 +1,14 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import apiManagerInjectable from "../api-manager.injectable"; +import type { SecretApi } from "./secret.api"; + +const secretApiInjectable = getInjectable({ + instantiate: (di) => di.inject(apiManagerInjectable).getApi("/api/v1/secrets") as SecretApi, + lifecycle: lifecycleEnum.singleton, +}); + +export default secretApiInjectable; diff --git a/src/common/k8s-api/endpoints/secret.api.ts b/src/common/k8s-api/endpoints/secret.api.ts index a3b821fcf8..e5a3a7157e 100644 --- a/src/common/k8s-api/endpoints/secret.api.ts +++ b/src/common/k8s-api/endpoints/secret.api.ts @@ -6,8 +6,7 @@ import { KubeObject } from "../kube-object"; import type { KubeJsonApiData } from "../kube-json-api"; import { autoBind } from "../../utils"; -import { KubeApi } from "../kube-api"; -import { isClusterPageContext } from "../../utils/cluster-id-url-parsing"; +import { KubeApi, SpecificApiOptions } from "../kube-api"; export enum SecretType { Opaque = "Opaque", @@ -25,9 +24,9 @@ export interface ISecretRef { name: string; } -export interface SecretData extends KubeJsonApiData { - type: SecretType; +export interface SecretData { data?: Record; + type: SecretType; } export class Secret extends KubeObject { @@ -38,7 +37,7 @@ export class Secret extends KubeObject { declare type: SecretType; declare data: Record; - constructor(data: SecretData) { + constructor(data: SecretData & KubeJsonApiData) { super(data); autoBind(this); @@ -54,14 +53,11 @@ export class Secret extends KubeObject { } } -let secretsApi: KubeApi; - -if (isClusterPageContext()) { - secretsApi = new KubeApi({ - objectConstructor: Secret, - }); +export class SecretApi extends KubeApi { + constructor(args: SpecificApiOptions = {}) { + super({ + ...args, + objectConstructor: Secret, + }); + } } - -export { - secretsApi, -}; diff --git a/src/common/k8s-api/endpoints/self-subject-rules-review.api.injectable.ts b/src/common/k8s-api/endpoints/self-subject-rules-review.api.injectable.ts new file mode 100644 index 0000000000..5835b9307e --- /dev/null +++ b/src/common/k8s-api/endpoints/self-subject-rules-review.api.injectable.ts @@ -0,0 +1,14 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import apiManagerInjectable from "../api-manager.injectable"; +import type { SelfSubjectRulesReviewApi } from "./self-subject-rules-review.api"; + +const selfSubjectRulesReviewApiInjectable = getInjectable({ + instantiate: (di) => di.inject(apiManagerInjectable).getApi("/apis/authorization.k8s.io/v1/selfsubjectrulesreviews") as SelfSubjectRulesReviewApi, + lifecycle: lifecycleEnum.singleton, +}); + +export default selfSubjectRulesReviewApiInjectable; diff --git a/src/common/k8s-api/endpoints/selfsubjectrulesreviews.api.ts b/src/common/k8s-api/endpoints/self-subject-rules-review.api.ts similarity index 83% rename from src/common/k8s-api/endpoints/selfsubjectrulesreviews.api.ts rename to src/common/k8s-api/endpoints/self-subject-rules-review.api.ts index c83271b47a..4c02abeeb3 100644 --- a/src/common/k8s-api/endpoints/selfsubjectrulesreviews.api.ts +++ b/src/common/k8s-api/endpoints/self-subject-rules-review.api.ts @@ -4,18 +4,7 @@ */ import { KubeObject } from "../kube-object"; -import { KubeApi } from "../kube-api"; -import { isClusterPageContext } from "../../utils/cluster-id-url-parsing"; - -export class SelfSubjectRulesReviewApi extends KubeApi { - create({ namespace = "default" }): Promise { - return super.create({}, { - spec: { - namespace, - }, - }); - } -} +import { KubeApi, SpecificApiOptions } from "../kube-api"; export interface ISelfSubjectReviewRule { verbs: string[]; @@ -71,15 +60,19 @@ export class SelfSubjectRulesReview extends KubeObject { } } -let selfSubjectRulesReviewApi: SelfSubjectRulesReviewApi; +export class SelfSubjectRulesReviewApi extends KubeApi { + constructor(args: SpecificApiOptions = {}) { + super({ + ...args, + objectConstructor: SelfSubjectRulesReview, + }); + } -if (isClusterPageContext()) { - selfSubjectRulesReviewApi = new SelfSubjectRulesReviewApi({ - objectConstructor: SelfSubjectRulesReview, - }); + create({ namespace = "default" }): Promise { + return super.create({}, { + spec: { + namespace, + }, + }); + } } - -export { - selfSubjectRulesReviewApi, -}; - diff --git a/src/common/k8s-api/endpoints/service-account.api.injectable.ts b/src/common/k8s-api/endpoints/service-account.api.injectable.ts new file mode 100644 index 0000000000..5aade6f02f --- /dev/null +++ b/src/common/k8s-api/endpoints/service-account.api.injectable.ts @@ -0,0 +1,14 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import apiManagerInjectable from "../api-manager.injectable"; +import type { ServiceAccountApi } from "./service-account.api"; + +const serviceAccountApiInjectable = getInjectable({ + instantiate: (di) => di.inject(apiManagerInjectable).getApi("/api/v1/serviceaccounts") as ServiceAccountApi, + lifecycle: lifecycleEnum.singleton, +}); + +export default serviceAccountApiInjectable; diff --git a/src/common/k8s-api/endpoints/service-accounts.api.ts b/src/common/k8s-api/endpoints/service-account.api.ts similarity index 62% rename from src/common/k8s-api/endpoints/service-accounts.api.ts rename to src/common/k8s-api/endpoints/service-account.api.ts index 02cdd453d8..2225c5ec85 100644 --- a/src/common/k8s-api/endpoints/service-accounts.api.ts +++ b/src/common/k8s-api/endpoints/service-account.api.ts @@ -5,17 +5,16 @@ import { autoBind } from "../../utils"; import { KubeObject } from "../kube-object"; -import { KubeApi } from "../kube-api"; +import { KubeApi, SpecificApiOptions } from "../kube-api"; import type { KubeJsonApiData } from "../kube-json-api"; -import { isClusterPageContext } from "../../utils/cluster-id-url-parsing"; + +export interface SecretRef { + name: string; +} export interface ServiceAccount { - secrets?: { - name: string; - }[]; - imagePullSecrets?: { - name: string; - }[]; + secrets?: SecretRef[]; + imagePullSecrets?: SecretRef[]; } export class ServiceAccount extends KubeObject { @@ -37,14 +36,11 @@ export class ServiceAccount extends KubeObject { } } -let serviceAccountsApi: KubeApi; - -if (isClusterPageContext()) { - serviceAccountsApi = new KubeApi({ - objectConstructor: ServiceAccount, - }); +export class ServiceAccountApi extends KubeApi { + constructor(args: SpecificApiOptions = {}) { + super({ + ...args, + objectConstructor: ServiceAccount, + }); + } } - -export { - serviceAccountsApi, -}; diff --git a/src/common/k8s-api/endpoints/service.api.injectable.ts b/src/common/k8s-api/endpoints/service.api.injectable.ts new file mode 100644 index 0000000000..38d91244e2 --- /dev/null +++ b/src/common/k8s-api/endpoints/service.api.injectable.ts @@ -0,0 +1,14 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import apiManagerInjectable from "../api-manager.injectable"; +import type { ServiceApi } from "./service.api"; + +const serviceApiInjectable = getInjectable({ + instantiate: (di) => di.inject(apiManagerInjectable).getApi("/api/v1/services") as ServiceApi, + lifecycle: lifecycleEnum.singleton, +}); + +export default serviceApiInjectable; diff --git a/src/common/k8s-api/endpoints/service.api.ts b/src/common/k8s-api/endpoints/service.api.ts index 6a3eb5ba7b..93120788ff 100644 --- a/src/common/k8s-api/endpoints/service.api.ts +++ b/src/common/k8s-api/endpoints/service.api.ts @@ -5,9 +5,8 @@ import { autoBind } from "../../../renderer/utils"; import { KubeObject } from "../kube-object"; -import { KubeApi } from "../kube-api"; +import { KubeApi, SpecificApiOptions } from "../kube-api"; import type { KubeJsonApiData } from "../kube-json-api"; -import { isClusterPageContext } from "../../utils/cluster-id-url-parsing"; export interface ServicePort { name?: string; @@ -132,14 +131,11 @@ export class Service extends KubeObject { } } -let serviceApi: KubeApi; - -if (isClusterPageContext()) { - serviceApi = new KubeApi({ - objectConstructor: Service, - }); +export class ServiceApi extends KubeApi { + constructor(args: SpecificApiOptions = {}) { + super({ + ...args, + objectConstructor: Service, + }); + } } - -export { - serviceApi, -}; diff --git a/src/common/k8s-api/endpoints/stateful-set.api.injectable.ts b/src/common/k8s-api/endpoints/stateful-set.api.injectable.ts new file mode 100644 index 0000000000..fbaee330cf --- /dev/null +++ b/src/common/k8s-api/endpoints/stateful-set.api.injectable.ts @@ -0,0 +1,14 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import apiManagerInjectable from "../api-manager.injectable"; +import type { StatefulSetApi } from "./stateful-set.api"; + +const statefulSetApiInjectable = getInjectable({ + instantiate: (di) => di.inject(apiManagerInjectable).getApi("/apis/apps/v1/statefulsets") as StatefulSetApi, + lifecycle: lifecycleEnum.singleton, +}); + +export default statefulSetApiInjectable; diff --git a/src/common/k8s-api/endpoints/stateful-set.api.ts b/src/common/k8s-api/endpoints/stateful-set.api.ts index d32e24f952..90b198af49 100644 --- a/src/common/k8s-api/endpoints/stateful-set.api.ts +++ b/src/common/k8s-api/endpoints/stateful-set.api.ts @@ -5,40 +5,12 @@ import { IAffinity, WorkloadKubeObject } from "../workload-kube-object"; import { autoBind } from "../../utils"; -import { KubeApi } from "../kube-api"; +import { KubeApi, SpecificApiOptions } from "../kube-api"; import { metricsApi } from "./metrics.api"; -import type { IPodMetrics } from "./pods.api"; +import type { IPodMetrics } from "./pod.api"; import type { KubeJsonApiData } from "../kube-json-api"; -import { isClusterPageContext } from "../../utils/cluster-id-url-parsing"; import type { LabelSelector } from "../kube-object"; -export class StatefulSetApi extends KubeApi { - protected getScaleApiUrl(params: { namespace: string; name: string }) { - return `${this.getUrl(params)}/scale`; - } - - getReplicas(params: { namespace: string; name: string }): Promise { - return this.request - .get(this.getScaleApiUrl(params)) - .then(({ status }: any) => status?.replicas); - } - - scale(params: { namespace: string; name: string }, replicas: number) { - return this.request.patch(this.getScaleApiUrl(params), { - data: { - spec: { - replicas, - }, - }, - }, - { - headers: { - "content-type": "application/merge-patch+json", - }, - }); - } -} - export function getMetricsForStatefulSets(statefulSets: StatefulSet[], namespace: string, selector = ""): Promise { const podSelector = statefulSets.map(statefulset => `${statefulset.getName()}-[[:digit:]]+`).join("|"); const opts = { category: "pods", pods: podSelector, namespace, selector }; @@ -136,14 +108,43 @@ export class StatefulSet extends WorkloadKubeObject { } } -let statefulSetApi: StatefulSetApi; -if (isClusterPageContext()) { - statefulSetApi = new StatefulSetApi({ - objectConstructor: StatefulSet, - }); +interface ReplicasStatus { + status?: { + replicas: number; + } } -export { - statefulSetApi, -}; +export class StatefulSetApi extends KubeApi { + constructor(args: SpecificApiOptions = {}) { + super({ + ...args, + objectConstructor: StatefulSet, + }); + } + + protected getScaleApiUrl(params: { namespace: string; name: string }) { + return `${this.getUrl(params)}/scale`; + } + + async getReplicas(params: { namespace: string; name: string }): Promise { + const { status } = await this.request .get(this.getScaleApiUrl(params)); + + return status?.replicas ?? 0; + } + + scale(params: { namespace: string; name: string }, replicas: number) { + return this.request.patch(this.getScaleApiUrl(params), { + data: { + spec: { + replicas, + }, + }, + }, + { + headers: { + "content-type": "application/merge-patch+json", + }, + }); + } +} diff --git a/src/common/k8s-api/endpoints/storage-class.api.injectable.ts b/src/common/k8s-api/endpoints/storage-class.api.injectable.ts new file mode 100644 index 0000000000..0b70629260 --- /dev/null +++ b/src/common/k8s-api/endpoints/storage-class.api.injectable.ts @@ -0,0 +1,14 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import type { StorageClassApi } from "./storage-class.api"; +import apiManagerInjectable from "../api-manager.injectable"; + +const storageClassApiInjectable = getInjectable({ + instantiate: (di) => di.inject(apiManagerInjectable).getApi("/apis/storage.k8s.io/v1/storageclasses") as StorageClassApi, + lifecycle: lifecycleEnum.singleton, +}); + +export default storageClassApiInjectable; diff --git a/src/common/k8s-api/endpoints/storage-class.api.ts b/src/common/k8s-api/endpoints/storage-class.api.ts index c01c89f5b7..a04af814e4 100644 --- a/src/common/k8s-api/endpoints/storage-class.api.ts +++ b/src/common/k8s-api/endpoints/storage-class.api.ts @@ -5,9 +5,8 @@ import { autoBind } from "../../utils"; import { KubeObject } from "../kube-object"; -import { KubeApi } from "../kube-api"; +import { KubeApi, SpecificApiOptions } from "../kube-api"; import type { KubeJsonApiData } from "../kube-json-api"; -import { isClusterPageContext } from "../../utils/cluster-id-url-parsing"; export interface StorageClass { provisioner: string; // e.g. "storage.k8s.io/v1" @@ -47,14 +46,11 @@ export class StorageClass extends KubeObject { } } -let storageClassApi: KubeApi; - -if (isClusterPageContext()) { - storageClassApi = new KubeApi({ - objectConstructor: StorageClass, - }); +export class StorageClassApi extends KubeApi { + constructor(args: SpecificApiOptions = {}) { + super({ + ...args, + objectConstructor: StorageClass, + }); + } } - -export { - storageClassApi, -}; diff --git a/src/common/k8s-api/kube-api.ts b/src/common/k8s-api/kube-api.ts index db7f2516b6..5f7c6b096a 100644 --- a/src/common/k8s-api/kube-api.ts +++ b/src/common/k8s-api/kube-api.ts @@ -9,7 +9,6 @@ import { isFunction, merge } from "lodash"; import { stringify } from "querystring"; import { apiKubePrefix, isDevelopment } from "../../common/vars"; import logger from "../../main/logger"; -import { apiManager } from "./api-manager"; import { apiBase, apiKube } from "./index"; import { createKubeApiURL, parseKubeApi } from "./kube-api-parse"; import { KubeObjectConstructor, KubeObject, KubeStatus } from "./kube-object"; @@ -24,6 +23,14 @@ import type { RequestInit } from "node-fetch"; import AbortController from "abort-controller"; import { Agent, AgentOptions } from "https"; import type { Patch } from "rfc6902"; +import { makeObservable, observable } from "mobx"; + +export type SpecificApiOptions = Omit, "objectConstructor"> & { + /** + * @deprecated A specific objectConstructor should not be passed in and will be overridden + */ + objectConstructor?: any; +}; /** * The options used for creating a `KubeApi` @@ -135,7 +142,14 @@ export interface IRemoteKubeApiConfig { } } -export function forCluster = KubeApi>(cluster: ILocalKubeApiConfig, kubeClass: KubeObjectConstructor, apiClass: new (apiOpts: IKubeApiOptions) => Y = null): KubeApi { +export function forCluster< + T extends KubeObject, + Y extends KubeApi = KubeApi, +>( + cluster: ILocalKubeApiConfig, + kubeClass: KubeObjectConstructor, + apiClass = KubeApi as new (apiOpts: IKubeApiOptions) => Y, +): KubeApi { const url = new URL(apiBase.config.serverAddress); const request = new KubeJsonApi({ serverAddress: apiBase.config.serverAddress, @@ -147,17 +161,20 @@ export function forCluster = KubeApi< }, }); - if (!apiClass) { - apiClass = KubeApi as new (apiOpts: IKubeApiOptions) => Y; - } - return new apiClass({ objectConstructor: kubeClass, request, }); } -export function forRemoteCluster = KubeApi>(config: IRemoteKubeApiConfig, kubeClass: KubeObjectConstructor, apiClass: new (apiOpts: IKubeApiOptions) => Y = null): Y { +export function forRemoteCluster< + T extends KubeObject, + Y extends KubeApi = KubeApi, +>( + config: IRemoteKubeApiConfig, + kubeClass: KubeObjectConstructor, + apiClass = KubeApi as new (apiOpts: IKubeApiOptions) => Y, +): Y { const reqInit: RequestInit = {}; const agentOptions: AgentOptions = {}; @@ -195,10 +212,6 @@ export function forRemoteCluster = Ku } : {}), }, reqInit); - if (!apiClass) { - apiClass = KubeApi as new (apiOpts: IKubeApiOptions) => Y; - } - return new apiClass({ objectConstructor: kubeClass as KubeObjectConstructor, request, @@ -264,7 +277,7 @@ export interface DeleteResourceDescriptor extends ResourceDescriptor { export class KubeApi { readonly kind: string; readonly apiVersion: string; - apiBase: string; + @observable apiBase: string; // This needs to be observable so that the registry works apiPrefix: string; apiGroup: string; apiVersionPreferred?: string; @@ -278,6 +291,7 @@ export class KubeApi { private watchId = 1; constructor(protected options: IKubeApiOptions) { + makeObservable(this); const { objectConstructor, request, kind, isNamespaced } = options; const { apiBase, apiPrefix, apiGroup, apiVersion, resource } = parseKubeApi(options.apiBase || objectConstructor.apiBase); @@ -291,9 +305,6 @@ export class KubeApi { this.apiResource = resource; this.request = request ?? apiKube; this.objectConstructor = objectConstructor; - - this.parseResponse = this.parseResponse.bind(this); - apiManager.registerApi(apiBase, this); } get apiVersionWithGroup() { @@ -372,7 +383,6 @@ export class KubeApi { if (this.apiVersionPreferred) { this.apiBase = this.computeApiBase(); - apiManager.registerApi(this.apiBase, this); } } } @@ -385,7 +395,7 @@ export class KubeApi { return this.resourceVersions.get(namespace); } - async refreshResourceVersion(params?: KubeApiListOptions) { + refreshResourceVersion(params?: KubeApiListOptions) { return this.list(params, { limit: 1 }); } diff --git a/src/common/k8s-api/kube-json-api.ts b/src/common/k8s-api/kube-json-api.ts index bc578641c3..70c2237eac 100644 --- a/src/common/k8s-api/kube-json-api.ts +++ b/src/common/k8s-api/kube-json-api.ts @@ -5,8 +5,7 @@ import { JsonApi, JsonApiData, JsonApiError } from "./json-api"; import type { Response } from "node-fetch"; -import { LensProxy } from "../../main/lens-proxy"; -import { apiKubePrefix, isDebugging } from "../vars"; +import type { KubeObjectSpec, KubeObjectStatus } from "./kube-object"; export interface KubeJsonApiListMetadata { resourceVersion: string; @@ -29,19 +28,21 @@ export interface KubeJsonApiMetadata { continue?: string; finalizers?: string[]; selfLink?: string; - labels?: { - [label: string]: string; - }; - annotations?: { - [annotation: string]: string; - }; + labels?: Record; + annotations?: Record; [key: string]: any; } -export interface KubeJsonApiData extends JsonApiData { +export interface KubeJsonApiData< + Metadata extends KubeJsonApiMetadata = KubeJsonApiMetadata, + Status extends KubeObjectStatus = KubeObjectStatus, + Spec extends KubeObjectSpec = KubeObjectSpec, +> extends JsonApiData { kind: string; apiVersion: string; - metadata: KubeJsonApiMetadata; + metadata: Metadata; + status?: Status; + spec?: Spec; } export interface KubeJsonApiError extends JsonApiError { @@ -56,20 +57,6 @@ export interface KubeJsonApiError extends JsonApiError { } export class KubeJsonApi extends JsonApi { - static forCluster(clusterId: string): KubeJsonApi { - const port = LensProxy.getInstance().port; - - return new this({ - serverAddress: `http://127.0.0.1:${port}`, - apiBase: apiKubePrefix, - debug: isDebugging, - }, { - headers: { - "Host": `${clusterId}.localhost:${port}`, - }, - }); - } - protected parseError(error: KubeJsonApiError | any, res: Response): string[] { const { status, reason, message } = error; diff --git a/src/common/k8s-api/kube-object.store.ts b/src/common/k8s-api/kube-object.store.ts index 8f0d5351f0..2bc76dc427 100644 --- a/src/common/k8s-api/kube-object.store.ts +++ b/src/common/k8s-api/kube-object.store.ts @@ -59,7 +59,7 @@ export interface KubeObjectStoreSubscribeParams { export abstract class KubeObjectStore extends ItemStore { static defaultContext = observable.box(); // TODO: support multiple cluster contexts - public api: KubeApi; + public abstract api: KubeApi; public readonly limit?: number; public readonly bufferSize: number = 50000; @observable private loadedNamespaces?: string[]; @@ -72,10 +72,8 @@ export abstract class KubeObjectStore extends ItemStore return when(() => Boolean(this.loadedNamespaces)); } - constructor(api?: KubeApi) { + constructor() { super(); - if (api) this.api = api; - makeObservable(this); autoBind(this); this.bindWatchEventsUpdater(); @@ -247,11 +245,11 @@ export abstract class KubeObjectStore extends ItemStore } @action - async reloadAll(opts: { force?: boolean, namespaces?: string[], merge?: boolean } = {}) { + reloadAll(opts: { force?: boolean, namespaces?: string[], merge?: boolean } = {}) { const { force = false, ...loadingOptions } = opts; if (this.isLoading || (this.isLoaded && !force)) { - return; + return Promise.resolve(); } return this.loadAll(loadingOptions); @@ -282,7 +280,7 @@ export abstract class KubeObjectStore extends ItemStore if (error) this.reset(); } - protected async loadItem(params: { name: string; namespace?: string }): Promise { + protected loadItem(params: { name: string; namespace?: string }): Promise { return this.api.get(params); } @@ -302,13 +300,13 @@ export abstract class KubeObjectStore extends ItemStore } @action - async loadFromPath(resourcePath: string) { + loadFromPath(resourcePath: string) { const { namespace, name } = parseKubeApi(resourcePath); return this.load({ name, namespace }); } - protected async createItem(params: { name: string; namespace?: string }, data?: Partial): Promise { + protected createItem(params: { name: string; namespace?: string }, data?: Partial): Promise { return this.api.create(params, data); } @@ -365,7 +363,7 @@ export abstract class KubeObjectStore extends ItemStore this.selectedItemsIds.delete(item.getId()); } - async removeSelectedItems() { + removeSelectedItems() { return Promise.all(this.selectedItems.map(this.remove)); } diff --git a/src/common/k8s-api/kube-object.ts b/src/common/k8s-api/kube-object.ts index 7098c98847..30251c963a 100644 --- a/src/common/k8s-api/kube-object.ts +++ b/src/common/k8s-api/kube-object.ts @@ -25,18 +25,14 @@ export interface KubeObjectMetadata { uid: string; name: string; namespace?: string; - creationTimestamp: string; + creationTimestamp?: string; resourceVersion: string; - selfLink: string; + selfLink?: string; deletionTimestamp?: string; finalizers?: string[]; continue?: string; // provided when used "?limit=" query param to fetch objects list - labels?: { - [label: string]: string; - }; - annotations?: { - [annotation: string]: string; - }; + labels?: Record; + annotations?: Record; ownerReferences?: { apiVersion: string; kind: string; @@ -70,16 +66,20 @@ export class KubeStatus { } } -export interface KubeObjectStatus { - conditions?: { - lastTransitionTime: string; - message: string; - reason: string; - status: string; - type?: string; - }[]; +export interface KubeObjectCondition { + lastTransitionTime: string; + message: string; + reason: string; + status: string; + type?: string; } +export interface KubeObjectStatus { + conditions?: Condition[]; +} + +export interface KubeObjectSpec {} + export type KubeMetaField = keyof KubeObjectMetadata; export class KubeCreationError extends Error { @@ -116,7 +116,7 @@ export interface LabelSelector { matchExpressions?: LabelMatchExpression[]; } -export class KubeObject implements ItemObject { +export class KubeObject implements ItemObject { static readonly kind?: string; static readonly namespaced?: boolean; static readonly apiBase?: string; @@ -128,7 +128,7 @@ export class KubeObject(data: KubeJsonApiData) { return new KubeObject(data); } @@ -227,7 +227,7 @@ export class KubeObject) { if (typeof data !== "object") { throw new TypeError(`Cannot create a KubeObject from ${typeof data}`); } @@ -322,7 +322,7 @@ export class KubeObject { + patch(patch: Patch): Promise { for (const op of patch) { if (KubeObject.nonEditablePaths.has(op.path)) { throw new Error(`Failed to update ${this.kind}: JSON pointer ${op.path} has been modified`); @@ -347,7 +347,7 @@ export class KubeObject): Promise { + update(data: Partial): Promise { // use unified resource-applier api for updating all k8s objects return resourceApplierApi.update({ ...this.toPlainObject(), diff --git a/src/common/k8s-api/lookup-api-link.injectable.ts b/src/common/k8s-api/lookup-api-link.injectable.ts new file mode 100644 index 0000000000..3f83a5736a --- /dev/null +++ b/src/common/k8s-api/lookup-api-link.injectable.ts @@ -0,0 +1,13 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import apiManagerInjectable from "./api-manager.injectable"; + +const lookupApiLinkInjectable = getInjectable({ + instantiate: (di) => di.inject(apiManagerInjectable).lookupApiLink, + lifecycle: lifecycleEnum.singleton, +}); + +export default lookupApiLinkInjectable; diff --git a/src/common/k8s-api/workload-kube-object.ts b/src/common/k8s-api/workload-kube-object.ts index f612f14cd5..6628319d3b 100644 --- a/src/common/k8s-api/workload-kube-object.ts +++ b/src/common/k8s-api/workload-kube-object.ts @@ -4,7 +4,7 @@ */ import get from "lodash/get"; -import { KubeObject } from "./kube-object"; +import { KubeObject, KubeObjectMetadata, KubeObjectStatus, LabelSelector } from "./kube-object"; export interface IToleration { key?: string; @@ -52,7 +52,15 @@ export interface IAffinity { }; } -export class WorkloadKubeObject extends KubeObject { +export interface WorkloadSpec { + selector?: LabelSelector; +} + +export class WorkloadKubeObject< + Metadata extends KubeObjectMetadata = KubeObjectMetadata, + Status extends KubeObjectStatus = {}, + Spec extends WorkloadSpec = WorkloadSpec, +> extends KubeObject { getSelectors(): string[] { const selector = this.spec.selector; diff --git a/src/common/k8s/resource-stack.ts b/src/common/k8s/resource-stack.ts index 08aae15d0b..cfe0356236 100644 --- a/src/common/k8s/resource-stack.ts +++ b/src/common/k8s/resource-stack.ts @@ -11,9 +11,13 @@ import logger from "../../main/logger"; import { app } from "electron"; import { requestMain } from "../ipc"; import { clusterKubectlApplyAllHandler, clusterKubectlDeleteAllHandler } from "../cluster-ipc"; -import { ClusterStore } from "../cluster-store/cluster-store"; import yaml from "js-yaml"; import { productName } from "../vars"; +import { asLegacyGlobalFunctionForExtensionApi } from "../../extensions/as-legacy-globals-for-extension-api/as-legacy-global-function-for-extension-api"; +import getClusterByIdInjectable from "../cluster-store/get-cluster-by-id.injectable"; + +// TODO: fix over a major version bump +const getClusterById = asLegacyGlobalFunctionForExtensionApi(getClusterByIdInjectable); export class ResourceStack { constructor(protected cluster: KubernetesCluster, protected name: string) {} @@ -41,7 +45,7 @@ export class ResourceStack { } protected async applyResources(resources: string[], extraArgs?: string[]): Promise { - const clusterModel = ClusterStore.getInstance().getById(this.cluster.metadata.uid); + const clusterModel = getClusterById(this.cluster.metadata.uid); if (!clusterModel) { throw new Error(`cluster not found`); @@ -65,7 +69,7 @@ export class ResourceStack { } protected async deleteResources(resources: string[], extraArgs?: string[]): Promise { - const clusterModel = ClusterStore.getInstance().getById(this.cluster.metadata.uid); + const clusterModel = getClusterById(this.cluster.metadata.uid); if (!clusterModel) { throw new Error(`cluster not found`); diff --git a/src/common/logger.injectable.ts b/src/common/logger.injectable.ts new file mode 100644 index 0000000000..3dea5dac63 --- /dev/null +++ b/src/common/logger.injectable.ts @@ -0,0 +1,13 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import logger, { LensLogger } from "./logger"; + +const loggerInjectable = getInjectable({ + instantiate: () => logger as LensLogger, + lifecycle: lifecycleEnum.singleton, +}); + +export default loggerInjectable; diff --git a/src/common/logger.ts b/src/common/logger.ts index 504b0e126d..604fb39762 100644 --- a/src/common/logger.ts +++ b/src/common/logger.ts @@ -10,6 +10,14 @@ import { consoleFormat } from "winston-console-format"; import { isDebugging, isTestEnv } from "./vars"; import BrowserConsole from "winston-transport-browserconsole"; +export interface LensLogger { + silly(...args: any[]): void; + warn(...args: any[]): void; + info(...args: any[]): void; + debug(...args: any[]): void; + error(...args: any[]): void; +} + const logLevel = process.env.LOG_LEVEL ? process.env.LOG_LEVEL : isDebugging diff --git a/src/common/logger/create-null-logger.ts b/src/common/logger/create-null-logger.ts new file mode 100644 index 0000000000..db9cc7350f --- /dev/null +++ b/src/common/logger/create-null-logger.ts @@ -0,0 +1,23 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import type { LensLogger } from "../logger"; + +export interface MockLensLogger extends LensLogger { + debug: jest.Mock; + warn: jest.Mock; + silly: jest.Mock; + info: jest.Mock; + error: jest.Mock; +} + +export function createMockLogger(): MockLensLogger { + return { + debug: jest.fn(), + warn: jest.fn(), + silly: jest.fn(), + info: jest.fn(), + error: jest.fn(), + }; +} diff --git a/src/common/logger/create-prefixed-logger.injectable.ts b/src/common/logger/create-prefixed-logger.injectable.ts new file mode 100644 index 0000000000..f3bc7adee8 --- /dev/null +++ b/src/common/logger/create-prefixed-logger.injectable.ts @@ -0,0 +1,32 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import type { LensLogger } from "../logger"; +import logger from "../logger"; +import { bind } from "../utils"; + +interface Dependencies { + rootLogger: LensLogger; +} + +function createPrefixedLogger({ rootLogger }: Dependencies, prefix: string): LensLogger { + return { + debug: (...args: any[]) => void rootLogger.debug(prefix, ...args), + warn: (...args: any[]) => void rootLogger.warn(prefix, ...args), + silly: (...args: any[]) => void rootLogger.silly(prefix, ...args), + info: (...args: any[]) => void rootLogger.info(prefix, ...args), + error: (...args: any[]) => void rootLogger.error(prefix, ...args), + }; +} + +const createPrefixedLoggerInjectable = getInjectable({ + instantiate: () => bind(createPrefixedLogger, null, { + // TODO make injectable + rootLogger: logger, + }), + lifecycle: lifecycleEnum.singleton, +}); + +export default createPrefixedLoggerInjectable; diff --git a/src/common/request-promise.injectable.ts.ts b/src/common/request-promise.injectable.ts.ts new file mode 100644 index 0000000000..ae737a1ebc --- /dev/null +++ b/src/common/request-promise.injectable.ts.ts @@ -0,0 +1,40 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import requestPromise from "request-promise-native"; +import type { UserPreferencesStore } from "./user-preferences"; +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { bind } from "./utils"; +import userPreferencesStoreInjectable from "./user-preferences/store.injectable"; + +// todo: get rid of "request" (deprecated) +// https://github.com/lensapp/lens/issues/459 + +interface Dependencies { + userStore: UserPreferencesStore; +} + +function customRequestPromise({ userStore }: Dependencies, opts: requestPromise.Options) { + const { httpsProxy, allowUntrustedCAs } = userStore; + const defaultRequestOps = { + proxy: httpsProxy || undefined, + rejectUnauthorized: !allowUntrustedCAs, + }; + + return requestPromise.defaults(defaultRequestOps)(opts); +} + +/** + * @deprecated + */ +const customRequestPromiseInjectable = getInjectable({ + instantiate: (di) => bind(customRequestPromise, null, { + userStore: di.inject(userPreferencesStoreInjectable), + }), + lifecycle: lifecycleEnum.singleton, +}); + +export default customRequestPromiseInjectable; + diff --git a/src/common/request.ts b/src/common/request.ts deleted file mode 100644 index 331b257f4d..0000000000 --- a/src/common/request.ts +++ /dev/null @@ -1,34 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import request from "request"; -import requestPromise from "request-promise-native"; -import { UserStore } from "./user-store"; - -// todo: get rid of "request" (deprecated) -// https://github.com/lensapp/lens/issues/459 - -function getDefaultRequestOpts(): Partial { - const { httpsProxy, allowUntrustedCAs } = UserStore.getInstance(); - - return { - proxy: httpsProxy || undefined, - rejectUnauthorized: !allowUntrustedCAs, - }; -} - -/** - * @deprecated - */ -export function customRequest(opts: request.Options) { - return request.defaults(getDefaultRequestOpts())(opts); -} - -/** - * @deprecated - */ -export function customRequestPromise(opts: requestPromise.Options) { - return requestPromise.defaults(getDefaultRequestOpts())(opts); -} diff --git a/src/common/routes/preferences.ts b/src/common/routes/preferences.ts index 4c737fa08d..3afd81e614 100644 --- a/src/common/routes/preferences.ts +++ b/src/common/routes/preferences.ts @@ -34,6 +34,9 @@ export const extensionRoute: RouteProps = { path: `${preferencesRoute.path}/extensions`, }; +export const terminalRoute: RouteProps = { + path: `${preferencesRoute.path}/terminal`, +}; export const preferencesURL = buildURL(preferencesRoute.path); export const appURL = buildURL(appRoute.path); export const proxyURL = buildURL(proxyRoute.path); @@ -41,3 +44,4 @@ export const kubernetesURL = buildURL(kubernetesRoute.path); export const editorURL = buildURL(editorRoute.path); export const telemetryURL = buildURL(telemetryRoute.path); export const extensionURL = buildURL(extensionRoute.path); +export const terminalURL = buildURL(terminalRoute.path); diff --git a/src/common/sentry.ts b/src/common/sentry.injectable.ts similarity index 67% rename from src/common/sentry.ts rename to src/common/sentry.injectable.ts index 9ec604f5df..838f1f5d99 100644 --- a/src/common/sentry.ts +++ b/src/common/sentry.injectable.ts @@ -6,8 +6,11 @@ import { Dedupe, Offline } from "@sentry/integrations"; import * as Sentry from "@sentry/electron"; import { sentryDsn, isProduction } from "./vars"; -import { UserStore } from "./user-store"; import { inspect } from "util"; +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import type { IComputedValue } from "mobx"; +import { bind } from "./utils"; +import allowErrorReportingInjectable from "./user-preferences/allow-error-reporting.injectable"; /** * "Translate" 'browser' to 'main' as Lens developer more familiar with the term 'main' @@ -20,25 +23,27 @@ function mapProcessName(processType: string) { return processType; } +interface Dependencies { + allowErrorReporting: IComputedValue; +} + /** * Initialize Sentry for the current process so to send errors for debugging. */ -export function SentryInit() { +function initializeSentryReporting({ allowErrorReporting }: Dependencies) { const processName = mapProcessName(process.type); Sentry.init({ beforeSend: (event) => { // default to false, in case instance of UserStore is not created (yet) - const allowErrorReporting = UserStore.getInstance(false)?.allowErrorReporting ?? false; - - if (allowErrorReporting) { + if (allowErrorReporting.get()) { return event; } /** * Directly write to stdout so that no other integrations capture this and create an infinite loop */ - process.stdout.write(`🔒 [SENTRY-BEFORE-SEND-HOOK]: allowErrorReporting: ${allowErrorReporting}. Sentry event is caught but not sent to server.`); + process.stdout.write(`🔒 [SENTRY-BEFORE-SEND-HOOK]: allowErrorReporting: false. Sentry event is caught but not sent to server.`); process.stdout.write("🔒 [SENTRY-BEFORE-SEND-HOOK]: === START OF SENTRY EVENT ==="); process.stdout.write(inspect(event, false, null, true)); process.stdout.write("🔒 [SENTRY-BEFORE-SEND-HOOK]: === END OF SENTRY EVENT ==="); @@ -60,3 +65,13 @@ export function SentryInit() { environment: isProduction ? "production" : "development", }); } + +const initializeSentryReportingInjectable = getInjectable({ + instantiate: (di) => bind(initializeSentryReporting, null, { + allowErrorReporting: di.inject(allowErrorReportingInjectable), + }), + lifecycle: lifecycleEnum.singleton, +}); + +export default initializeSentryReportingInjectable; + diff --git a/src/common/user-preferences/allow-error-reporting.injectable.ts b/src/common/user-preferences/allow-error-reporting.injectable.ts new file mode 100644 index 0000000000..4731f51069 --- /dev/null +++ b/src/common/user-preferences/allow-error-reporting.injectable.ts @@ -0,0 +1,18 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { computed } from "mobx"; +import userPreferencesStoreInjectable from "./store.injectable"; + +const allowErrorReportingInjectable = getInjectable({ + instantiate: (di) => { + const userStore = di.inject(userPreferencesStoreInjectable); + + return computed(() => userStore.allowErrorReporting); + }, + lifecycle: lifecycleEnum.singleton, +}); + +export default allowErrorReportingInjectable; diff --git a/src/common/user-preferences/color-theme-id.injectable.ts b/src/common/user-preferences/color-theme-id.injectable.ts new file mode 100644 index 0000000000..4c4814d058 --- /dev/null +++ b/src/common/user-preferences/color-theme-id.injectable.ts @@ -0,0 +1,18 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { computed } from "mobx"; +import userPreferencesStoreInjectable from "./store.injectable"; + +const colorThemeIdInjectable = getInjectable({ + instantiate: (di) => { + const userStore = di.inject(userPreferencesStoreInjectable); + + return computed(() => userStore.colorTheme); + }, + lifecycle: lifecycleEnum.singleton, +}); + +export default colorThemeIdInjectable; diff --git a/src/common/user-preferences/download-kubectl-binaries.injectable.ts b/src/common/user-preferences/download-kubectl-binaries.injectable.ts new file mode 100644 index 0000000000..3a55268b2d --- /dev/null +++ b/src/common/user-preferences/download-kubectl-binaries.injectable.ts @@ -0,0 +1,18 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { computed } from "mobx"; +import userPreferencesStoreInjectable from "./store.injectable"; + +const downloadKubectlBinariesInjectable = getInjectable({ + instantiate: (di) => { + const userStore = di.inject(userPreferencesStoreInjectable); + + return computed(() => userStore.downloadKubectlBinaries); + }, + lifecycle: lifecycleEnum.singleton, +}); + +export default downloadKubectlBinariesInjectable; diff --git a/src/common/user-preferences/file-name-migration-injection-token.ts b/src/common/user-preferences/file-name-migration-injection-token.ts new file mode 100644 index 0000000000..341a5009a1 --- /dev/null +++ b/src/common/user-preferences/file-name-migration-injection-token.ts @@ -0,0 +1,7 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectionToken } from "@ogre-tools/injectable"; + +export const userStoreFileNameMigrationInjectionToken = getInjectionToken<() => void>(); diff --git a/src/common/user-store/index.ts b/src/common/user-preferences/index.ts similarity index 89% rename from src/common/user-store/index.ts rename to src/common/user-preferences/index.ts index 026167519b..eb8da0f26f 100644 --- a/src/common/user-store/index.ts +++ b/src/common/user-preferences/index.ts @@ -3,5 +3,5 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -export * from "./user-store"; +export * from "./store"; export type { KubeconfigSyncEntry, KubeconfigSyncValue, UserPreferencesModel } from "./preferences-helpers"; diff --git a/src/common/user-preferences/is-table-column-hidden.injectable.ts b/src/common/user-preferences/is-table-column-hidden.injectable.ts new file mode 100644 index 0000000000..ca4116eba2 --- /dev/null +++ b/src/common/user-preferences/is-table-column-hidden.injectable.ts @@ -0,0 +1,13 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import userPreferencesStoreInjectable from "./store.injectable"; + +const isTableColumnHiddenInjectable = getInjectable({ + instantiate: (di) => di.inject(userPreferencesStoreInjectable).isTableColumnHidden, + lifecycle: lifecycleEnum.singleton, +}); + +export default isTableColumnHiddenInjectable; diff --git a/src/common/user-preferences/kubeconfig-sync-entries.injectable.ts b/src/common/user-preferences/kubeconfig-sync-entries.injectable.ts new file mode 100644 index 0000000000..7022db0655 --- /dev/null +++ b/src/common/user-preferences/kubeconfig-sync-entries.injectable.ts @@ -0,0 +1,13 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import userPreferencesStoreInjectable from "./store.injectable"; + +const kubeconfigSyncEntriesInjectable = getInjectable({ + instantiate: (di) => di.inject(userPreferencesStoreInjectable).syncKubeconfigEntries, + lifecycle: lifecycleEnum.singleton, +}); + +export default kubeconfigSyncEntriesInjectable; diff --git a/src/common/user-preferences/kubectl-binaries-path.injectable.ts b/src/common/user-preferences/kubectl-binaries-path.injectable.ts new file mode 100644 index 0000000000..bcae4dadb9 --- /dev/null +++ b/src/common/user-preferences/kubectl-binaries-path.injectable.ts @@ -0,0 +1,18 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { computed } from "mobx"; +import userPreferencesStoreInjectable from "./store.injectable"; + +const kubectlBinariesPathInjectable = getInjectable({ + instantiate: (di) => { + const userStore = di.inject(userPreferencesStoreInjectable); + + return computed(() => userStore.kubectlBinariesPath); + }, + lifecycle: lifecycleEnum.singleton, +}); + +export default kubectlBinariesPathInjectable; diff --git a/src/common/user-preferences/migrations-injection-token.ts b/src/common/user-preferences/migrations-injection-token.ts new file mode 100644 index 0000000000..d1c054dd20 --- /dev/null +++ b/src/common/user-preferences/migrations-injection-token.ts @@ -0,0 +1,9 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectionToken } from "@ogre-tools/injectable"; +import type { Migrations } from "conf/dist/source/types"; +import type { UserPreferencesStoreModel } from "./store"; + +export const userPreferencesStoreMigrationsInjectionToken = getInjectionToken | undefined>(); diff --git a/src/common/user-store/preferences-helpers.ts b/src/common/user-preferences/preferences-helpers.ts similarity index 92% rename from src/common/user-store/preferences-helpers.ts rename to src/common/user-preferences/preferences-helpers.ts index c2e9adce7e..228b754387 100644 --- a/src/common/user-store/preferences-helpers.ts +++ b/src/common/user-preferences/preferences-helpers.ts @@ -10,7 +10,7 @@ import { getAppVersion, ObservableToggleSet } from "../utils"; import type { editor } from "monaco-editor"; import merge from "lodash/merge"; import { SemVer } from "semver"; -import { defaultTheme } from "../vars"; +import { defaultTheme, defaultEditorFontFamily, defaultFontSize, defaultTerminalFontFamily } from "../vars"; export interface KubeconfigSyncEntry extends KubeconfigSyncValue { filePath: string; @@ -18,19 +18,29 @@ export interface KubeconfigSyncEntry extends KubeconfigSyncValue { export interface KubeconfigSyncValue { } +export interface TerminalConfig { + fontSize: number; + fontFamily: string; +} + +export const defaultTerminalConfig: TerminalConfig = { + fontSize: defaultFontSize, + fontFamily: defaultTerminalFontFamily, +}; export type EditorConfiguration = Pick; + "minimap" | "tabSize" | "lineNumbers" | "fontSize" | "fontFamily">; export const defaultEditorConfig: EditorConfiguration = { tabSize: 2, lineNumbers: "on", + fontSize: defaultFontSize, + fontFamily: defaultEditorFontFamily, minimap: { enabled: true, side: "right", }, }; - interface PreferenceDescription { fromStore(val: T | undefined): R; toStore(val: R): T | undefined; @@ -273,6 +283,15 @@ const editorConfiguration: PreferenceDescription = { + fromStore(val) { + return merge(defaultTerminalConfig, val); + }, + toStore(val) { + return val; + }, +}; + const updateChannels = new Map([ ["latest", { label: "Stable", @@ -358,6 +377,7 @@ export const DESCRIPTORS = { syncKubeconfigEntries, editorConfiguration, terminalCopyOnSelect, + terminalConfig, updateChannel, extensionRegistryUrl, }; diff --git a/src/common/user-preferences/reset-theme-settings.injectable.ts b/src/common/user-preferences/reset-theme-settings.injectable.ts new file mode 100644 index 0000000000..770189fa2a --- /dev/null +++ b/src/common/user-preferences/reset-theme-settings.injectable.ts @@ -0,0 +1,13 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import userPreferencesStoreInjectable from "./store.injectable"; + +const resetThemeSettingsInjectable = getInjectable({ + instantiate: (di) => di.inject(userPreferencesStoreInjectable).resetTheme, + lifecycle: lifecycleEnum.singleton, +}); + +export default resetThemeSettingsInjectable; diff --git a/src/common/user-preferences/resolved-shell-injectable.ts b/src/common/user-preferences/resolved-shell-injectable.ts new file mode 100644 index 0000000000..ce4538fc41 --- /dev/null +++ b/src/common/user-preferences/resolved-shell-injectable.ts @@ -0,0 +1,13 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import userPreferencesStoreInjectable from "./store.injectable"; + +const resolvedShellInjectable = getInjectable({ + instantiate: (di) => di.inject(userPreferencesStoreInjectable).resolvedShell, + lifecycle: lifecycleEnum.singleton, +}); + +export default resolvedShellInjectable; diff --git a/src/common/user-preferences/store.injectable.ts b/src/common/user-preferences/store.injectable.ts new file mode 100644 index 0000000000..4e5cf297f5 --- /dev/null +++ b/src/common/user-preferences/store.injectable.ts @@ -0,0 +1,18 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { userStoreFileNameMigrationInjectionToken } from "./file-name-migration-injection-token"; +import { userPreferencesStoreMigrationsInjectionToken } from "./migrations-injection-token"; +import { UserPreferencesStore } from "./store"; + +const userPreferencesStoreInjectable = getInjectable({ + instantiate: (di) => new UserPreferencesStore({ + fileNameMigration: di.inject(userStoreFileNameMigrationInjectionToken), + migrations: di.inject(userPreferencesStoreMigrationsInjectionToken), + }), + lifecycle: lifecycleEnum.singleton, +}); + +export default userPreferencesStoreInjectable; diff --git a/src/common/user-store/user-store.ts b/src/common/user-preferences/store.ts similarity index 86% rename from src/common/user-store/user-store.ts rename to src/common/user-preferences/store.ts index b0726d62ca..c8a302358a 100644 --- a/src/common/user-store/user-store.ts +++ b/src/common/user-preferences/store.ts @@ -3,37 +3,38 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import { app, ipcMain } from "electron"; +import { app } from "electron"; import semver, { SemVer } from "semver"; import { action, computed, makeObservable, observable, reaction } from "mobx"; import { BaseStore } from "../base-store"; -import migrations, { fileNameMigration } from "../../migrations/user-store"; import { getAppVersion } from "../utils/app-version"; import { kubeConfigDefaultPath } from "../kube-helpers"; import { appEventBus } from "../app-event-bus/event-bus"; import { ObservableToggleSet, toJS } from "../../renderer/utils"; -import { DESCRIPTORS, EditorConfiguration, ExtensionRegistry, KubeconfigSyncValue, UserPreferencesModel } from "./preferences-helpers"; +import { DESCRIPTORS, EditorConfiguration, ExtensionRegistry, KubeconfigSyncValue, UserPreferencesModel, TerminalConfig } from "./preferences-helpers"; import logger from "../../main/logger"; +import type { Migrations } from "conf/dist/source/types"; -export interface UserStoreModel { +export interface UserPreferencesStoreModel { lastSeenAppVersion: string; preferences: UserPreferencesModel; } -export class UserStore extends BaseStore /* implements UserStoreFlatModel (when strict null is enabled) */ { +interface UserStoreDependencies { + migrations: Migrations | undefined; + fileNameMigration: () => void; +} + +export class UserPreferencesStore extends BaseStore /* implements UserStoreFlatModel (when strict null is enabled) */ { readonly displayName = "UserStore"; - constructor() { + constructor({ migrations, fileNameMigration }: UserStoreDependencies) { super({ configName: "lens-user-store", migrations, }); makeObservable(this); - - if (ipcMain) { - fileNameMigration(); - } - + fileNameMigration(); this.load(); } @@ -57,6 +58,7 @@ export class UserStore extends BaseStore /* implements UserStore @observable downloadBinariesPath?: string; @observable kubectlBinariesPath?: string; @observable terminalCopyOnSelect: boolean; + @observable terminalConfig: TerminalConfig; @observable updateChannel?: string; @observable extensionRegistryUrl: ExtensionRegistry; @@ -86,9 +88,7 @@ export class UserStore extends BaseStore /* implements UserStore return semver.gt(getAppVersion(), this.lastSeenAppVersion); } - @computed get resolvedShell(): string | undefined { - return this.shell || process.env.SHELL || process.env.PTYSHELL; - } + readonly resolvedShell = computed(() => this.shell || process.env.SHELL || process.env.PTYSHELL); @computed get isAllowedToDowngrade() { return new SemVer(getAppVersion()).prerelease[0] !== this.updateChannel; @@ -118,7 +118,7 @@ export class UserStore extends BaseStore /* implements UserStore * @param columnIds The list of IDs the check if one is hidden * @returns true if at least one column under the table is set to hidden */ - isTableColumnHidden(tableId: string, ...columnIds: string[]): boolean { + isTableColumnHidden = (tableId: string, ...columnIds: string[]): boolean => { if (columnIds.length === 0) { return false; } @@ -130,19 +130,19 @@ export class UserStore extends BaseStore /* implements UserStore } return columnIds.some(columnId => config.has(columnId)); - } + }; @action /** * Toggles the hidden configuration of a table's column */ - toggleTableColumnVisibility(tableId: string, columnId: string) { + toggleTableColumnVisibility = (tableId: string, columnId: string) => { if (!this.hiddenTableColumns.get(tableId)) { this.hiddenTableColumns.set(tableId, new ObservableToggleSet()); } this.hiddenTableColumns.get(tableId).toggle(columnId); - } + }; @action resetTheme() { @@ -161,7 +161,7 @@ export class UserStore extends BaseStore /* implements UserStore } @action - protected fromStore({ lastSeenAppVersion, preferences }: Partial = {}) { + protected fromStore({ lastSeenAppVersion, preferences }: Partial = {}) { logger.debug("UserStore.fromStore()", { lastSeenAppVersion, preferences }); if (lastSeenAppVersion) { @@ -185,12 +185,13 @@ export class UserStore extends BaseStore /* implements UserStore this.syncKubeconfigEntries.replace(DESCRIPTORS.syncKubeconfigEntries.fromStore(preferences?.syncKubeconfigEntries)); this.editorConfiguration = DESCRIPTORS.editorConfiguration.fromStore(preferences?.editorConfiguration); this.terminalCopyOnSelect = DESCRIPTORS.terminalCopyOnSelect.fromStore(preferences?.terminalCopyOnSelect); + this.terminalConfig = DESCRIPTORS.terminalConfig.fromStore(preferences?.terminalConfig); this.updateChannel = DESCRIPTORS.updateChannel.fromStore(preferences?.updateChannel); this.extensionRegistryUrl = DESCRIPTORS.extensionRegistryUrl.fromStore(preferences?.extensionRegistryUrl); } - toJSON(): UserStoreModel { - const model: UserStoreModel = { + toJSON(): UserPreferencesStoreModel { + const model: UserPreferencesStoreModel = { lastSeenAppVersion: this.lastSeenAppVersion, preferences: { httpsProxy: DESCRIPTORS.httpsProxy.toStore(this.httpsProxy), @@ -210,6 +211,7 @@ export class UserStore extends BaseStore /* implements UserStore syncKubeconfigEntries: DESCRIPTORS.syncKubeconfigEntries.toStore(this.syncKubeconfigEntries), editorConfiguration: DESCRIPTORS.editorConfiguration.toStore(this.editorConfiguration), terminalCopyOnSelect: DESCRIPTORS.terminalCopyOnSelect.toStore(this.terminalCopyOnSelect), + terminalConfig: DESCRIPTORS.terminalConfig.toStore(this.terminalConfig), updateChannel: DESCRIPTORS.updateChannel.toStore(this.updateChannel), extensionRegistryUrl: DESCRIPTORS.extensionRegistryUrl.toStore(this.extensionRegistryUrl), }, diff --git a/src/common/user-preferences/terminal-config.injectable.ts b/src/common/user-preferences/terminal-config.injectable.ts new file mode 100644 index 0000000000..97cc8190f9 --- /dev/null +++ b/src/common/user-preferences/terminal-config.injectable.ts @@ -0,0 +1,19 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { computed } from "mobx"; +import { toJS } from "../utils"; +import userPreferencesStoreInjectable from "./store.injectable"; + +const terminalConfigInjectable = getInjectable({ + instantiate: (di) => { + const userPreferencesStore = di.inject(userPreferencesStoreInjectable); + + return computed(() => toJS(userPreferencesStore.terminalConfig)); + }, + lifecycle: lifecycleEnum.singleton, +}); + +export default terminalConfigInjectable; diff --git a/src/common/user-preferences/terminal-copy-on-select.injectable.ts b/src/common/user-preferences/terminal-copy-on-select.injectable.ts new file mode 100644 index 0000000000..d6493e1f29 --- /dev/null +++ b/src/common/user-preferences/terminal-copy-on-select.injectable.ts @@ -0,0 +1,18 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { computed } from "mobx"; +import userPreferencesStoreInjectable from "./store.injectable"; + +const terminalCopyOnSelectInjectable = getInjectable({ + instantiate: (di) => { + const userStore = di.inject(userPreferencesStoreInjectable); + + return computed(() => userStore.terminalCopyOnSelect); + }, + lifecycle: lifecycleEnum.singleton, +}); + +export default terminalCopyOnSelectInjectable; diff --git a/src/common/user-preferences/terminal-theme-id.injectable.ts b/src/common/user-preferences/terminal-theme-id.injectable.ts new file mode 100644 index 0000000000..56da48cecd --- /dev/null +++ b/src/common/user-preferences/terminal-theme-id.injectable.ts @@ -0,0 +1,18 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { computed } from "mobx"; +import userPreferencesStoreInjectable from "./store.injectable"; + +const terminalThemeIdInjectable = getInjectable({ + instantiate: (di) => { + const userStore = di.inject(userPreferencesStoreInjectable); + + return computed(() => userStore.terminalTheme); + }, + lifecycle: lifecycleEnum.singleton, +}); + +export default terminalThemeIdInjectable; diff --git a/src/common/user-preferences/toggle-table-column-visibility.injectable.ts b/src/common/user-preferences/toggle-table-column-visibility.injectable.ts new file mode 100644 index 0000000000..5a99dd85ae --- /dev/null +++ b/src/common/user-preferences/toggle-table-column-visibility.injectable.ts @@ -0,0 +1,13 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import userPreferencesStoreInjectable from "./store.injectable"; + +const toggleTableColumnVisibilityInjectable = getInjectable({ + instantiate: (di) => di.inject(userPreferencesStoreInjectable).toggleTableColumnVisibility, + lifecycle: lifecycleEnum.singleton, +}); + +export default toggleTableColumnVisibilityInjectable; diff --git a/src/common/utils/allowed-resource.ts b/src/common/utils/allowed-resource.ts deleted file mode 100644 index d1be86b720..0000000000 --- a/src/common/utils/allowed-resource.ts +++ /dev/null @@ -1,25 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import { ClusterStore } from "../cluster-store/cluster-store"; -import type { KubeResource } from "../rbac"; -import { getHostedClusterId } from "./cluster-id-url-parsing"; - -export function isAllowedResource(resource: KubeResource | KubeResource[]) { - const resources = [resource].flat(); - const cluster = ClusterStore.getInstance().getById(getHostedClusterId()); - - if (!cluster?.allowedResources) { - return false; - } - - if (resources.length === 0) { - return true; - } - - const allowedResources = new Set(cluster.allowedResources); - - return resources.every(resource => allowedResources.has(resource)); -} diff --git a/src/common/utils/collection-functions.ts b/src/common/utils/collection-functions.ts index 205f7f0cb1..0d18a41e8a 100644 --- a/src/common/utils/collection-functions.ts +++ b/src/common/utils/collection-functions.ts @@ -17,3 +17,22 @@ export function getOrInsert(map: Map, key: K, value: V): V { return map.get(key); } + +/** + * Like `getOrInsert` but specifically for when `V` is `Map` so that + * the typings are inferred. + */ +export function getOrInsertMap(map: Map>, key: K): Map { + return getOrInsert(map, key, new Map()); +} + +/** + * Like `getOrInsert` but with delayed creation of the item + */ +export function getOrInsertWith(map: Map, key: K, value: () => V): V { + if (!map.has(key)) { + map.set(key, value()); + } + + return map.get(key); +} diff --git a/src/common/utils/disposer.ts b/src/common/utils/disposer.ts index a578160c3b..ba9a7d7bdf 100644 --- a/src/common/utils/disposer.ts +++ b/src/common/utils/disposer.ts @@ -11,14 +11,16 @@ interface Extendable { export type ExtendableDisposer = Disposer & Extendable; -export function disposer(...args: Disposer[]): ExtendableDisposer { +export function disposer(...disposers: Disposer[]): ExtendableDisposer { const res = () => { - args.forEach(dispose => dispose?.()); - args.length = 0; + for (const disposer of disposers) { + disposer(); + } + disposers.length = 0; }; res.push = (...vals: Disposer[]) => { - args.push(...vals); + disposers.push(...vals); }; return res; diff --git a/src/common/utils/index.ts b/src/common/utils/index.ts index 1400608aab..1eb50e2857 100644 --- a/src/common/utils/index.ts +++ b/src/common/utils/index.ts @@ -49,6 +49,7 @@ export * from "./toggle-set"; export * from "./toJS"; export * from "./type-narrowing"; export * from "./types"; +export * from "./wait-for-path"; import * as iter from "./iter"; import * as array from "./array"; diff --git a/src/common/utils/readableStream.ts b/src/common/utils/readableStream.ts index 750d4cc901..f1f6ba7059 100644 --- a/src/common/utils/readableStream.ts +++ b/src/common/utils/readableStream.ts @@ -88,6 +88,6 @@ export class ReadableWebToNodeStream extends Readable { private async syncAndRelease() { this.released = true; await this.waitForReadToComplete(); - await this.reader.releaseLock(); + this.reader.releaseLock(); } } diff --git a/src/common/utils/singleton.ts b/src/common/utils/singleton.ts index aff4970be1..47e87b1a85 100644 --- a/src/common/utils/singleton.ts +++ b/src/common/utils/singleton.ts @@ -5,6 +5,9 @@ type StaticThis = { new(...args: R): T }; +/** + * @deprecated Try and remove all uses of this + */ export class Singleton { private static instances = new WeakMap(); private static creating = ""; diff --git a/src/common/utils/sort-compare.ts b/src/common/utils/sort-compare.ts index dafed080da..1f2260c563 100644 --- a/src/common/utils/sort-compare.ts +++ b/src/common/utils/sort-compare.ts @@ -5,7 +5,7 @@ import semver, { coerce, SemVer } from "semver"; import * as iter from "./iter"; -import type { RawHelmChart } from "../k8s-api/endpoints/helm-charts.api"; +import type { RawHelmChart } from "../k8s-api/endpoints/helm-chart.api"; import logger from "../logger"; export enum Ordering { diff --git a/src/common/utils/tar.ts b/src/common/utils/tar.ts index 2433ca23ef..a575d75263 100644 --- a/src/common/utils/tar.ts +++ b/src/common/utils/tar.ts @@ -47,6 +47,8 @@ export function readFileFromTar({ tarPath, filePath, parseJson }: Re export async function listTarEntries(filePath: string): Promise { const entries: string[] = []; + // The typings of this is wrong, we do need to await + // eslint-disable-next-line @typescript-eslint/await-thenable await tar.list({ file: filePath, onentry: (entry: FileStat) => { diff --git a/src/common/utils/unique-id.injectable.ts b/src/common/utils/unique-id.injectable.ts new file mode 100644 index 0000000000..670c13f77a --- /dev/null +++ b/src/common/utils/unique-id.injectable.ts @@ -0,0 +1,13 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { uniqueId } from "lodash"; + +const uniqueIdInjectable = getInjectable({ + instantiate: () => uniqueId, + lifecycle: lifecycleEnum.singleton, +}); + +export default uniqueIdInjectable; diff --git a/src/common/utils/wait-for-path.ts b/src/common/utils/wait-for-path.ts new file mode 100644 index 0000000000..f5a068075b --- /dev/null +++ b/src/common/utils/wait-for-path.ts @@ -0,0 +1,55 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { FSWatcher } from "chokidar"; +import path from "path"; + +/** + * Wait for `filePath` and all parent directories to exist. + * @param pathname The file path to wait until it exists + * + * NOTE: There is technically a race condition in this function of the form + * "time-of-check to time-of-use" because we have to wait for each parent + * directory to exist first. + */ +export async function waitForPath(pathname: string): Promise { + const dirOfPath = path.dirname(pathname); + + if (dirOfPath === pathname) { + // The root of this filesystem, assume it exists + return; + } else { + await waitForPath(dirOfPath); + } + + return new Promise((resolve, reject) => { + const watcher = new FSWatcher({ + depth: 0, + disableGlobbing: true, + }); + const onAddOrAddDir = (filePath: string) => { + if (filePath === pathname) { + watcher.unwatch(dirOfPath); + watcher + .close() + .then(() => resolve()) + .catch(reject); + } + }; + const onError = (error: any) => { + watcher.unwatch(dirOfPath); + watcher + .close() + .then(() => reject(error)) + .catch(() => reject(error)); + }; + + watcher + .on("add", onAddOrAddDir) + .on("addDir", onAddOrAddDir) + .on("error", onError) + .add(dirOfPath); + }); +} diff --git a/src/common/vars.ts b/src/common/vars.ts index de0d1abf98..abbf09e877 100644 --- a/src/common/vars.ts +++ b/src/common/vars.ts @@ -26,6 +26,9 @@ export const productName = packageInfo.productName; export const appName = `${packageInfo.productName}${isDevelopment ? "Dev" : ""}`; export const publicPath = "/build/" as string; export const defaultTheme = "lens-dark" as string; +export const defaultFontSize = 12; +export const defaultTerminalFontFamily = "RobotoMono"; +export const defaultEditorFontFamily = "RobotoMono"; // Webpack build paths export const contextDir = process.cwd(); diff --git a/src/common/weblinks/add-weblink.injectable.ts b/src/common/weblinks/add-weblink.injectable.ts new file mode 100644 index 0000000000..34082c788d --- /dev/null +++ b/src/common/weblinks/add-weblink.injectable.ts @@ -0,0 +1,13 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import weblinksStoreInjectable from "./store.injectable"; + +const addWeblinkInjectable = getInjectable({ + instantiate: (di) => di.inject(weblinksStoreInjectable).add, + lifecycle: lifecycleEnum.singleton, +}); + +export default addWeblinkInjectable; diff --git a/src/common/weblinks/migrations-injection-token.ts b/src/common/weblinks/migrations-injection-token.ts new file mode 100644 index 0000000000..606cc84102 --- /dev/null +++ b/src/common/weblinks/migrations-injection-token.ts @@ -0,0 +1,10 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { getInjectionToken } from "@ogre-tools/injectable"; +import type { Migrations } from "conf/dist/source/types"; +import type { WeblinkStoreModel } from "./store"; + +export const weblinksStoreMigrationsInjectionToken = getInjectionToken>(); diff --git a/src/common/weblinks/remove-by-id.injectable.ts b/src/common/weblinks/remove-by-id.injectable.ts new file mode 100644 index 0000000000..21b12b1320 --- /dev/null +++ b/src/common/weblinks/remove-by-id.injectable.ts @@ -0,0 +1,13 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import weblinksStoreInjectable from "./store.injectable"; + +const removeWeblinkByIdInjectable = getInjectable({ + instantiate: (di) => di.inject(weblinksStoreInjectable).removeById, + lifecycle: lifecycleEnum.singleton, +}); + +export default removeWeblinkByIdInjectable; diff --git a/src/common/weblinks/store.injectable.ts b/src/common/weblinks/store.injectable.ts new file mode 100644 index 0000000000..93318eafe0 --- /dev/null +++ b/src/common/weblinks/store.injectable.ts @@ -0,0 +1,16 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { weblinksStoreMigrationsInjectionToken } from "./migrations-injection-token"; +import { WeblinkStore } from "./store"; + +const weblinksStoreInjectable = getInjectable({ + instantiate: (di) => new WeblinkStore({ + migrations: di.inject(weblinksStoreMigrationsInjectionToken), + }), + lifecycle: lifecycleEnum.singleton, +}); + +export default weblinksStoreInjectable; diff --git a/src/common/weblink-store.ts b/src/common/weblinks/store.ts similarity index 75% rename from src/common/weblink-store.ts rename to src/common/weblinks/store.ts index 7606d894c2..a7d9b6218f 100644 --- a/src/common/weblink-store.ts +++ b/src/common/weblinks/store.ts @@ -4,10 +4,10 @@ */ import { action, comparer, observable, makeObservable } from "mobx"; -import { BaseStore } from "./base-store"; -import migrations from "../migrations/weblinks-store"; +import { BaseStore } from "../base-store"; import * as uuid from "uuid"; -import { toJS } from "./utils"; +import { toJS } from "../utils"; +import type { Migrations } from "conf/dist/source/types"; export interface WeblinkData { id: string; @@ -26,11 +26,15 @@ export interface WeblinkStoreModel { weblinks: WeblinkData[]; } +export interface WeblinkStoreDependencies { + migrations: Migrations | undefined; +} + export class WeblinkStore extends BaseStore { readonly displayName = "WeblinkStore"; @observable weblinks: WeblinkData[] = []; - constructor() { + constructor({ migrations }: WeblinkStoreDependencies) { super({ configName: "lens-weblink-store", accessPropertiesByDotNotation: false, // To make dots safe in cluster context names @@ -48,24 +52,22 @@ export class WeblinkStore extends BaseStore { this.weblinks = data.weblinks || []; } - add(data: WeblinkCreateOptions) { + add = (data: WeblinkCreateOptions): WeblinkData =>{ const { id = uuid.v4(), name, url, } = data; - const weblink = { id, name, url }; - this.weblinks.push(weblink as WeblinkData); + this.weblinks.push(weblink); - return weblink as WeblinkData; - } + return weblink; + }; - @action - removeById(id: string) { + removeById = (id: string) => { this.weblinks = this.weblinks.filter((w) => w.id !== id); - } + }; toJSON(): WeblinkStoreModel { const model: WeblinkStoreModel = { diff --git a/src/extensions/__tests__/extension-loader.test.ts b/src/extensions/__tests__/extension-loader.test.ts index fe72bea585..5707cfe9bb 100644 --- a/src/extensions/__tests__/extension-loader.test.ts +++ b/src/extensions/__tests__/extension-loader.test.ts @@ -23,7 +23,7 @@ jest.mock( "electron", () => ({ ipcRenderer: { - invoke: jest.fn(async (channel: string) => { + invoke: jest.fn((channel: string) => { if (channel === "extensions:main") { return [ [ diff --git a/src/extensions/__tests__/lens-extension.test.ts b/src/extensions/__tests__/lens-extension.test.ts index fe46d32afc..e0a5dfe37d 100644 --- a/src/extensions/__tests__/lens-extension.test.ts +++ b/src/extensions/__tests__/lens-extension.test.ts @@ -12,7 +12,7 @@ console = new Console(stdout, stderr); let ext: LensExtension = null; describe("lens extension", () => { - beforeEach(async () => { + beforeEach(() => { ext = new LensExtension({ manifest: { name: "foo-bar", diff --git a/src/extensions/common-api/catalog.ts b/src/extensions/common-api/catalog.ts index fae083e75a..217ad46db0 100644 --- a/src/extensions/common-api/catalog.ts +++ b/src/extensions/common-api/catalog.ts @@ -3,12 +3,7 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -export { - KubernetesCluster, - kubernetesClusterCategory, - GeneralEntity, - WebLink, -} from "../../common/catalog-entities"; +export { KubernetesCluster, GeneralEntity, WebLink } from "../../common/catalog-entities"; export type { KubernetesClusterPrometheusMetrics, diff --git a/src/extensions/common-api/registrations.ts b/src/extensions/common-api/registrations.ts index 666ed01a26..6c6df915d1 100644 --- a/src/extensions/common-api/registrations.ts +++ b/src/extensions/common-api/registrations.ts @@ -4,10 +4,10 @@ */ export type { AppPreferenceRegistration, AppPreferenceComponents } from "../../renderer/components/+preferences/app-preferences/app-preference-registration"; -export type { KubeObjectDetailRegistration, KubeObjectDetailComponents } from "../registries/kube-object-detail-registry"; export type { KubeObjectMenuRegistration, KubeObjectMenuComponents } from "../registries/kube-object-menu-registry"; -export type { KubeObjectStatusRegistration } from "../registries/kube-object-status-registry"; export type { PageRegistration, RegisteredPage, PageParams, PageComponentProps, PageComponents, PageTarget } from "../registries/page-registry"; export type { ClusterPageMenuRegistration, ClusterPageMenuComponents } from "../registries/page-menu-registry"; export type { StatusBarRegistration } from "../registries/status-bar-registry"; export type { ProtocolHandlerRegistration, RouteParams as ProtocolRouteParams, RouteHandler as ProtocolRouteHandler } from "../registries/protocol-handler"; +export type { KubeObjectStatusRegistration } from "../../renderer/components/kube-object-status-icon/kube-object-status"; +export type { KubeObjectDetailRegistration, KubeObjectDetailComponents } from "../../renderer/components/kube-object-details/kube-details-items/kube-detail-items"; diff --git a/src/extensions/common-api/user-preferences.ts b/src/extensions/common-api/user-preferences.ts index 2c44f6f604..996ec67dc7 100644 --- a/src/extensions/common-api/user-preferences.ts +++ b/src/extensions/common-api/user-preferences.ts @@ -3,11 +3,14 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import { UserStore } from "../../common/user-store"; +import userPreferencesStoreInjectable from "../../common/user-preferences/store.injectable"; +import { asLegacyGlobalObjectForExtensionApi } from "../as-legacy-globals-for-extension-api/as-legacy-global-object-for-extension-api"; + +const userStore = asLegacyGlobalObjectForExtensionApi(userPreferencesStoreInjectable); /** * Get the configured kubectl binaries path. */ export function getKubectlPath(): string | undefined { - return UserStore.getInstance().kubectlBinariesPath; + return userStore.kubectlBinariesPath; } diff --git a/src/extensions/extension-discovery/extension-discovery.test.ts b/src/extensions/extension-discovery/extension-discovery.test.ts index 84737a50d2..972427eb60 100644 --- a/src/extensions/extension-discovery/extension-discovery.test.ts +++ b/src/extensions/extension-discovery/extension-discovery.test.ts @@ -14,7 +14,7 @@ import type { ExtensionDiscovery } from "../extension-discovery/extension-discov import installExtensionInjectable from "../extension-installer/install-extension/install-extension.injectable"; import directoryForUserDataInjectable - from "../../common/app-paths/directory-for-user-data/directory-for-user-data.injectable"; + from "../../common/app-paths/directory-for-user-data.injectable"; import mockFs from "mock-fs"; jest.setTimeout(60_000); diff --git a/src/extensions/extension-discovery/extension-discovery.ts b/src/extensions/extension-discovery/extension-discovery.ts index d563fcbdb5..dc1a8ab5f4 100644 --- a/src/extensions/extension-discovery/extension-discovery.ts +++ b/src/extensions/extension-discovery/extension-discovery.ts @@ -128,15 +128,15 @@ export class ExtensionDiscovery { /** * Initializes the class and setups the file watcher for added/removed local extensions. */ - async init(): Promise { + init() { if (ipcRenderer) { - await this.initRenderer(); + this.initRenderer(); } else { - await this.initMain(); + this.initMain(); } } - async initRenderer(): Promise { + initRenderer() { const onMessage = ({ isLoaded }: ExtensionDiscoveryChannelMessage) => { this.isLoaded = isLoaded; }; @@ -147,7 +147,7 @@ export class ExtensionDiscovery { }); } - async initMain(): Promise { + initMain() { ipcMainHandle(ExtensionDiscovery.extensionDiscoveryChannel, () => this.toJSON()); reaction(() => this.toJSON(), () => { @@ -479,10 +479,8 @@ export class ExtensionDiscovery { * Loads extension from absolute path, updates this.packagesJson to include it and returns the extension. * @param folderPath Folder path to extension */ - async loadExtensionFromFolder(folderPath: string, { isBundled = false }: LoadFromFolderOptions = {}): Promise { - const manifestPath = path.resolve(folderPath, manifestFilename); - - return this.getByManifest(manifestPath, { isBundled }); + loadExtensionFromFolder(folderPath: string, { isBundled = false }: LoadFromFolderOptions = {}): Promise { + return this.getByManifest(path.resolve(folderPath, manifestFilename), { isBundled }); } toJSON(): ExtensionDiscoveryChannelMessage { diff --git a/src/extensions/extension-installer/extension-package-root-directory/extension-package-root-directory.injectable.ts b/src/extensions/extension-installer/extension-package-root-directory/extension-package-root-directory.injectable.ts index 5aec23838b..36ceac0c6c 100644 --- a/src/extensions/extension-installer/extension-package-root-directory/extension-package-root-directory.injectable.ts +++ b/src/extensions/extension-installer/extension-package-root-directory/extension-package-root-directory.injectable.ts @@ -4,7 +4,7 @@ */ import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; import directoryForUserDataInjectable - from "../../../common/app-paths/directory-for-user-data/directory-for-user-data.injectable"; + from "../../../common/app-paths/directory-for-user-data.injectable"; const extensionPackageRootDirectoryInjectable = getInjectable({ instantiate: (di) => di.inject(directoryForUserDataInjectable), diff --git a/src/extensions/extension-loader/create-extension-instance/file-system-provisioner-store/directory-for-extension-data/directory-for-extension-data.injectable.ts b/src/extensions/extension-loader/create-extension-instance/file-system-provisioner-store/directory-for-extension-data.injectable.ts similarity index 78% rename from src/extensions/extension-loader/create-extension-instance/file-system-provisioner-store/directory-for-extension-data/directory-for-extension-data.injectable.ts rename to src/extensions/extension-loader/create-extension-instance/file-system-provisioner-store/directory-for-extension-data.injectable.ts index 07cff5a4f1..f98f6badad 100644 --- a/src/extensions/extension-loader/create-extension-instance/file-system-provisioner-store/directory-for-extension-data/directory-for-extension-data.injectable.ts +++ b/src/extensions/extension-loader/create-extension-instance/file-system-provisioner-store/directory-for-extension-data.injectable.ts @@ -4,7 +4,7 @@ */ import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; import path from "path"; -import directoryForUserDataInjectable from "../../../../../common/app-paths/directory-for-user-data/directory-for-user-data.injectable"; +import directoryForUserDataInjectable from "../../../../common/app-paths/directory-for-user-data.injectable"; const directoryForExtensionDataInjectable = getInjectable({ instantiate: (di) => diff --git a/src/extensions/extension-loader/create-extension-instance/file-system-provisioner-store/file-system-provisioner-store.injectable.ts b/src/extensions/extension-loader/create-extension-instance/file-system-provisioner-store/file-system-provisioner-store.injectable.ts index a61bc59b43..cfe9e6751c 100644 --- a/src/extensions/extension-loader/create-extension-instance/file-system-provisioner-store/file-system-provisioner-store.injectable.ts +++ b/src/extensions/extension-loader/create-extension-instance/file-system-provisioner-store/file-system-provisioner-store.injectable.ts @@ -4,15 +4,12 @@ */ import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; import { FileSystemProvisionerStore } from "./file-system-provisioner-store"; -import directoryForExtensionDataInjectable from "./directory-for-extension-data/directory-for-extension-data.injectable"; +import directoryForExtensionDataInjectable from "./directory-for-extension-data.injectable"; const fileSystemProvisionerStoreInjectable = getInjectable({ - instantiate: (di) => - FileSystemProvisionerStore.createInstance({ - directoryForExtensionData: di.inject( - directoryForExtensionDataInjectable, - ), - }), + instantiate: (di) => new FileSystemProvisionerStore({ + directoryForExtensionData: di.inject(directoryForExtensionDataInjectable), + }), lifecycle: lifecycleEnum.singleton, }); diff --git a/src/extensions/extension-loader/extension-loader.ts b/src/extensions/extension-loader/extension-loader.ts index afe09cce19..749c4ded4c 100644 --- a/src/extensions/extension-loader/extension-loader.ts +++ b/src/extensions/extension-loader/extension-loader.ts @@ -132,9 +132,9 @@ export class ExtensionLoader { @action async init() { if (ipcRenderer) { - await this.initRenderer(); + this.initRenderer(); } else { - await this.initMain(); + this.initMain(); } await Promise.all([this.whenLoaded]); @@ -192,7 +192,7 @@ export class ExtensionLoader { this.extensions.get(lensExtensionId).isEnabled = isEnabled; } - protected async initMain() { + protected initMain() { this.isLoaded = true; this.loadOnMain(); @@ -205,7 +205,7 @@ export class ExtensionLoader { }); } - protected async initRenderer() { + protected initRenderer() { const extensionListHandler = (extensions: [LensExtensionId, InstalledExtension][]) => { this.isLoaded = true; this.syncExtensions(extensions); @@ -249,12 +249,11 @@ export class ExtensionLoader { loadOnClusterManagerRenderer = () => { logger.debug(`${logModule}: load on main renderer (cluster manager)`); - return this.autoInitExtensions(async (extension: LensRendererExtension) => { + return this.autoInitExtensions((extension: LensRendererExtension) => { const removeItems = [ registries.GlobalPageRegistry.getInstance().add(extension.globalPages, extension), registries.EntitySettingRegistry.getInstance().add(extension.entitySettings), registries.StatusBarRegistry.getInstance().add(extension.statusBarItems), - registries.CatalogEntityDetailRegistry.getInstance().add(extension.catalogEntityDetailItems), ]; this.events.on("remove", (removedExtension: LensRendererExtension) => { @@ -265,7 +264,7 @@ export class ExtensionLoader { } }); - return removeItems; + return Promise.resolve(removeItems); }); }; @@ -282,8 +281,6 @@ export class ExtensionLoader { registries.ClusterPageRegistry.getInstance().add(extension.clusterPages, extension), registries.ClusterPageMenuRegistry.getInstance().add(extension.clusterPageMenus, extension), registries.KubeObjectMenuRegistry.getInstance().add(extension.kubeObjectMenuItems), - registries.KubeObjectDetailRegistry.getInstance().add(extension.kubeObjectDetailItems), - registries.KubeObjectStatusRegistry.getInstance().add(extension.kubeObjectStatusTexts), registries.WorkloadsOverviewDetailRegistry.getInstance().add(extension.kubeWorkloadsOverviewItems), ]; diff --git a/src/extensions/extension-packages-root/extension-packages-root.injectable.ts b/src/extensions/extension-packages-root/extension-packages-root.injectable.ts index 566437bdeb..b00da18bad 100644 --- a/src/extensions/extension-packages-root/extension-packages-root.injectable.ts +++ b/src/extensions/extension-packages-root/extension-packages-root.injectable.ts @@ -4,7 +4,7 @@ */ import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; import directoryForUserDataInjectable - from "../../common/app-paths/directory-for-user-data/directory-for-user-data.injectable"; + from "../../common/app-paths/directory-for-user-data.injectable"; const extensionPackagesRootInjectable = getInjectable({ instantiate: (di) => di.inject(directoryForUserDataInjectable), diff --git a/src/extensions/extension-store.ts b/src/extensions/extension-store.ts index 561b314e6a..addf782887 100644 --- a/src/extensions/extension-store.ts +++ b/src/extensions/extension-store.ts @@ -7,7 +7,70 @@ import { BaseStore } from "../common/base-store"; import * as path from "path"; import type { LensExtension } from "./lens-extension"; +type StaticThis = { new(...args: R): T }; + export abstract class ExtensionStore extends BaseStore { + private static instances = new WeakMap>(); + private static creating = ""; + + /** + * @deprecated You can and should just create the instance of the store yourself + * Creates the single instance of the child class if one was not already created. + * + * Multiple calls will return the same instance. + * Essentially throwing away the arguments to the subsequent calls. + * + * Note: this is a racy function, if two (or more) calls are racing to call this function + * only the first's arguments will be used. + * @param this Implicit argument that is the child class type + * @param args The constructor arguments for the child class + * @returns An instance of the child class + */ + static createInstance(this: StaticThis, ...args: R): T { + if (!ExtensionStore.instances.has(this)) { + if (ExtensionStore.creating.length > 0) { + throw new TypeError(`Cannot create a second ExtensionStore (${this.name}) while creating a first (${ExtensionStore.creating})`); + } + + try { + ExtensionStore.creating = this.name; + ExtensionStore.instances.set(this, new this(...args) as any); + } finally { + ExtensionStore.creating = ""; + } + } + + return ExtensionStore.instances.get(this) as any; + } + + /** + * @deprecated You can and should just get the instance of the store yourself + * Get the instance of the child class that was previously created. + * @param this Implicit argument that is the child class type + * @param strict If false will return `undefined` instead of throwing when an instance doesn't exist. + * Default: `true` + * @returns An instance of the child class + */ + static getInstance(this: StaticThis, strict = true): T | undefined { + if (!ExtensionStore.instances.has(this) && strict) { + throw new TypeError(`instance of ${this.name} is not created`); + } + + return ExtensionStore.instances.get(this) as any; + } + + /** + * @deprecated This function shouldn't really be used + * Delete the instance of the child class. + * + * Note: this doesn't prevent callers of `getInstance` from storing the result in a global. + * + * There is *no* way in JS or TS to prevent globals like that. + */ + static resetInstance() { + ExtensionStore.instances.delete(this); + } + readonly displayName = "ExtensionStore"; protected extension: LensExtension; diff --git a/src/extensions/extensions-store/extensions-store.injectable.ts b/src/extensions/extensions-store/extensions-store.injectable.ts index 764116d372..f885e6d4fc 100644 --- a/src/extensions/extensions-store/extensions-store.injectable.ts +++ b/src/extensions/extensions-store/extensions-store.injectable.ts @@ -6,7 +6,7 @@ import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; import { ExtensionsStore } from "./extensions-store"; const extensionsStoreInjectable = getInjectable({ - instantiate: () => ExtensionsStore.createInstance(), + instantiate: () => new ExtensionsStore(), lifecycle: lifecycleEnum.singleton, }); diff --git a/src/extensions/lens-extension.ts b/src/extensions/lens-extension.ts index c892464ab8..71b57e32bb 100644 --- a/src/extensions/lens-extension.ts +++ b/src/extensions/lens-extension.ts @@ -75,12 +75,12 @@ export class LensExtension { * Note: there is no security done on this folder, only obfuscation of the * folder name. */ - async getExtensionFileFolder(): Promise { + getExtensionFileFolder(): Promise { return this.dependencies.fileSystemProvisionerStore.requestDirectory(this.id); } @action - async enable(register: (ext: LensExtension) => Promise) { + async enable(register: (ext: LensExtension) => Disposer[] | Promise) { if (this._isEnabled) { return; } diff --git a/src/extensions/lens-main-extension.ts b/src/extensions/lens-main-extension.ts index a7c545ef11..678194fa1f 100644 --- a/src/extensions/lens-main-extension.ts +++ b/src/extensions/lens-main-extension.ts @@ -4,25 +4,35 @@ */ import { LensExtension } from "./lens-extension"; -import { WindowManager } from "../main/window-manager"; -import { catalogEntityRegistry } from "../main/catalog"; import type { CatalogEntity } from "../common/catalog"; -import type { IObservableArray } from "mobx"; +import { computed, IComputedValue, IObservableArray, observable } from "mobx"; import type { MenuRegistration } from "../main/menu/menu-registration"; import type { TrayMenuRegistration } from "../main/tray/tray-menu-registration"; +import windowManagerInjectable from "../main/windows/manager.injectable"; +import { asLegacyGlobalObjectForExtensionApi } from "./as-legacy-globals-for-extension-api/as-legacy-global-object-for-extension-api"; + +const windowManager = asLegacyGlobalObjectForExtensionApi(windowManagerInjectable); + export class LensMainExtension extends LensExtension { appMenus: MenuRegistration[] = []; trayMenus: TrayMenuRegistration[] = []; + sources = observable.map>(); - async navigate(pageId?: string, params?: Record, frameId?: number) { - return WindowManager.getInstance().navigateExtension(this.id, pageId, params, frameId); + navigate(pageId?: string, params?: Record, frameId?: number) { + return windowManager.navigateExtension(this.id, pageId, params, frameId); } + /** + * @deprecated Just call `set()` on `.sources` directly + */ addCatalogSource(id: string, source: IObservableArray) { - catalogEntityRegistry.addObservableSource(`${this.name}:${id}`, source); + this.sources.set(id, computed(() => [...source])); } + /** + * @deprecated Just call `delete()` on `.sources` directly + */ removeCatalogSource(id: string) { - catalogEntityRegistry.removeSource(`${this.name}:${id}`); + this.sources.delete(id); } } diff --git a/src/extensions/lens-renderer-extension.ts b/src/extensions/lens-renderer-extension.ts index fe0795c2c1..e80c255842 100644 --- a/src/extensions/lens-renderer-extension.ts +++ b/src/extensions/lens-renderer-extension.ts @@ -6,10 +6,9 @@ import type * as registries from "./registries"; import { Disposers, LensExtension } from "./lens-extension"; import { getExtensionPageUrl } from "./registries/page-registry"; -import type { CatalogEntity } from "../common/catalog"; +import type { CatalogEntity, CategoryFilter } from "../common/catalog"; import type { Disposer } from "../common/utils"; -import { catalogEntityRegistry, EntityFilter } from "../renderer/api/catalog-entity-registry"; -import { catalogCategoryRegistry, CategoryFilter } from "../renderer/api/catalog-category-registry"; +import type { EntityFilter } from "../renderer/catalog/entity-registry"; import type { TopBarRegistration } from "../renderer/components/layout/top-bar/top-bar-registration"; import type { KubernetesCluster } from "../common/catalog-entities"; import type { WelcomeMenuRegistration } from "../renderer/components/+welcome/welcome-menu-items/welcome-menu-registration"; @@ -17,25 +16,33 @@ import type { WelcomeBannerRegistration } from "../renderer/components/+welcome/ import type { CommandRegistration } from "../renderer/components/command-palette/registered-commands/commands"; import type { AppPreferenceRegistration } from "../renderer/components/+preferences/app-preferences/app-preference-registration"; import type { AdditionalCategoryColumnRegistration } from "../renderer/components/+catalog/custom-category-columns"; +import type { KubeObjectDetailRegistration } from "../renderer/components/kube-object-details/kube-details-items/kube-detail-items"; +import type { KubeObjectStatusRegistration } from "../renderer/components/kube-object-status-icon/kube-object-status"; +import type { CatalogEntityDetailRegistration } from "../renderer/catalog/catalog-entity-details"; +import { observable } from "mobx"; +import { once } from "lodash"; export class LensRendererExtension extends LensExtension { globalPages: registries.PageRegistration[] = []; clusterPages: registries.PageRegistration[] = []; clusterPageMenus: registries.ClusterPageMenuRegistration[] = []; - kubeObjectStatusTexts: registries.KubeObjectStatusRegistration[] = []; appPreferences: AppPreferenceRegistration[] = []; + kubeObjectStatusTexts: KubeObjectStatusRegistration[] = []; entitySettings: registries.EntitySettingRegistration[] = []; statusBarItems: registries.StatusBarRegistration[] = []; - kubeObjectDetailItems: registries.KubeObjectDetailRegistration[] = []; + kubeObjectDetailItems: KubeObjectDetailRegistration[] = []; kubeObjectMenuItems: registries.KubeObjectMenuRegistration[] = []; kubeWorkloadsOverviewItems: registries.WorkloadsOverviewDetailRegistration[] = []; commands: CommandRegistration[] = []; welcomeMenus: WelcomeMenuRegistration[] = []; welcomeBanners: WelcomeBannerRegistration[] = []; - catalogEntityDetailItems: registries.CatalogEntityDetailRegistration[] = []; + catalogEntityDetailItems: CatalogEntityDetailRegistration[] = []; topBarItems: TopBarRegistration[] = []; additionalCategoryColumns: AdditionalCategoryColumnRegistration[] = []; + @observable catalogFilters = observable.array(); + @observable catalogCategoryFilters = observable.array(); + async navigate

(pageId?: string, params?: P) { const { navigate } = await import("../renderer/navigation"); const pageUrl = getExtensionPageUrl({ @@ -53,31 +60,35 @@ export class LensRendererExtension extends LensExtension { * * The default implementation is to return `true` */ - async isEnabledForCluster(cluster: KubernetesCluster): Promise { - return (void cluster) || true; + isEnabledForCluster(cluster: KubernetesCluster): Promise { + return Promise.resolve((void cluster) || true); } /** + * @deprecated Just push to `.catalogFilters` instead * Add a filtering function for the catalog entities. This will be removed if the extension is disabled. * @param fn The function which should return a truthy value for those entities which should be kept. * @returns A function to clean up the filter */ addCatalogFilter(fn: EntityFilter): Disposer { - const dispose = catalogEntityRegistry.addCatalogFilter(fn); + const dispose = once(() => this.catalogFilters.remove(fn)); + this.catalogFilters.push(fn); this[Disposers].push(dispose); return dispose; } /** + * @deprecated Just push to `.catalogCategoryFilters` instead * Add a filtering function for the catalog categories. This will be removed if the extension is disabled. * @param fn The function which should return a truthy value for those categories which should be kept. * @returns A function to clean up the filter */ addCatalogCategoryFilter(fn: CategoryFilter): Disposer { - const dispose = catalogCategoryRegistry.addCatalogCategoryFilter(fn); + const dispose = once(() => this.catalogCategoryFilters.remove(fn)); + this.catalogCategoryFilters.push(fn); this[Disposers].push(dispose); return dispose; diff --git a/src/extensions/main-api/catalog.ts b/src/extensions/main-api/catalog.ts index 1881f9fe85..1d680dde5c 100644 --- a/src/extensions/main-api/catalog.ts +++ b/src/extensions/main-api/catalog.ts @@ -3,15 +3,99 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import type { CatalogEntity } from "../../common/catalog"; -import { catalogEntityRegistry as registry } from "../../main/catalog"; +import type { CatalogCategory, CatalogEntity, CatalogEntityData, CatalogEntityKindData, CategoryFilter, CatalogCategoryRegistry as InternalCatalogCategoryRegistry } from "../../common/catalog"; +import type { CatalogEntityRegistry as InternalCatalogEntityRegistry } from "../../main/catalog"; +import catalogCategoryRegistryInjectable from "../../main/catalog/category-registry.injectable"; +import catalogEntityRegistryInjectable from "../../main/catalog/entity-registry.injectable"; +import type { Disposer } from "../../renderer/utils"; +import { asLegacyGlobalObjectForExtensionApi } from "../as-legacy-globals-for-extension-api/as-legacy-global-object-for-extension-api"; -export { catalogCategoryRegistry as catalogCategories } from "../../common/catalog/catalog-category-registry"; -export class CatalogEntityRegistry { - getItemsForApiKind(apiVersion: string, kind: string): T[] { - return registry.getItemsForApiKind(apiVersion, kind); +interface CatalogEntityRegistryDependencies { + readonly internalRegistry: InternalCatalogEntityRegistry; +} + +export type { CatalogEntityRegistry }; + +class CatalogEntityRegistry { + /** + * @internal + */ + constructor(protected readonly dependencies: CatalogEntityRegistryDependencies) {} + + /** + * @deprecated use cast instead of unused generic type argument + */ + getItemsForApiKind(apiVersion: string, kind: string): T[]; + getItemsForApiKind(apiVersion: string, kind: string): CatalogEntity[] { + return this.dependencies.internalRegistry.getItemsForApiKind(apiVersion, kind); } } -export const catalogEntities = new CatalogEntityRegistry(); +interface CatalogCategoryRegistryDependencies { + readonly internalRegistry: InternalCatalogCategoryRegistry; +} + +export type { CatalogCategoryRegistry }; + + +class CatalogCategoryRegistry { + constructor(protected readonly dependencies: CatalogCategoryRegistryDependencies) {} + + add(category: CatalogCategory): Disposer { + return this.dependencies.internalRegistry.add(category); + } + + get items() { + return this.dependencies.internalRegistry.items; + } + + get filteredItems() { + return this.dependencies.internalRegistry.filteredItems.get(); + } + + /** + * @deprecated use cast instead of unused generic type argument + */ + getForGroupKind(group: string, kind: string): T | undefined; + getForGroupKind(group: string, kind: string): CatalogCategory | undefined { + return this.dependencies.internalRegistry.getForGroupKind(group, kind); + } + + getEntityForData(data: CatalogEntityData & CatalogEntityKindData): CatalogEntity | null { + return this.dependencies.internalRegistry.getEntityForData(data); + } + + /** + * @deprecated use cast instead of unused generic type argument + */ + getCategoryForEntity({ kind, apiVersion }: CatalogEntityData & CatalogEntityKindData): T; + /** + * @throws if category is not found + */ + getCategoryForEntity(entity: CatalogEntityData & CatalogEntityKindData): CatalogCategory { + return this.dependencies.internalRegistry.getCategoryForEntity(entity); + } + + getByName(name: string): CatalogCategory | undefined { + return this.dependencies.internalRegistry.getByName(name); + } + + /** + * Add a new filter to the set of category filters + * @param fn The function that should return a truthy value if that category should be displayed + * @returns A function to remove that filter + */ + addCatalogCategoryFilter(fn: CategoryFilter): Disposer { + return this.dependencies.internalRegistry.addCatalogCategoryFilter(fn); + } +} + +export const catalogEntities = new CatalogEntityRegistry({ + internalRegistry: asLegacyGlobalObjectForExtensionApi(catalogEntityRegistryInjectable), +}); + +export const catalogCategories = new CatalogCategoryRegistry({ + internalRegistry: asLegacyGlobalObjectForExtensionApi(catalogCategoryRegistryInjectable), +}); + diff --git a/src/extensions/main-api/k8s-api.ts b/src/extensions/main-api/k8s-api.ts index 31fd2ea14d..c484a20e11 100644 --- a/src/extensions/main-api/k8s-api.ts +++ b/src/extensions/main-api/k8s-api.ts @@ -3,43 +3,43 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -export { isAllowedResource } from "../../common/utils/allowed-resource"; export { ResourceStack } from "../../common/k8s/resource-stack"; -export { apiManager } from "../../common/k8s-api/api-manager"; export { KubeApi, forCluster, forRemoteCluster } from "../../common/k8s-api/kube-api"; export { KubeObject, KubeStatus } from "../../common/k8s-api/kube-object"; export { KubeObjectStore } from "../../common/k8s-api/kube-object.store"; -export { Pod, podsApi, PodsApi } from "../../common/k8s-api/endpoints/pods.api"; -export { Node, nodesApi, NodesApi } from "../../common/k8s-api/endpoints/nodes.api"; -export { Deployment, deploymentApi, DeploymentApi } from "../../common/k8s-api/endpoints/deployment.api"; -export { DaemonSet, daemonSetApi } from "../../common/k8s-api/endpoints/daemon-set.api"; -export { StatefulSet, statefulSetApi } from "../../common/k8s-api/endpoints/stateful-set.api"; -export { Job, jobApi } from "../../common/k8s-api/endpoints/job.api"; -export { CronJob, cronJobApi } from "../../common/k8s-api/endpoints/cron-job.api"; -export { ConfigMap, configMapApi } from "../../common/k8s-api/endpoints/configmap.api"; -export { Secret, secretsApi } from "../../common/k8s-api/endpoints/secret.api"; -export { ReplicaSet, replicaSetApi } from "../../common/k8s-api/endpoints/replica-set.api"; -export { ResourceQuota, resourceQuotaApi } from "../../common/k8s-api/endpoints/resource-quota.api"; -export { LimitRange, limitRangeApi } from "../../common/k8s-api/endpoints/limit-range.api"; -export { HorizontalPodAutoscaler, hpaApi } from "../../common/k8s-api/endpoints/hpa.api"; -export { PodDisruptionBudget, pdbApi } from "../../common/k8s-api/endpoints/poddisruptionbudget.api"; -export { Service, serviceApi } from "../../common/k8s-api/endpoints/service.api"; -export { Endpoint, endpointApi } from "../../common/k8s-api/endpoints/endpoint.api"; -export { Ingress, ingressApi, IngressApi } from "../../common/k8s-api/endpoints/ingress.api"; -export { NetworkPolicy, networkPolicyApi } from "../../common/k8s-api/endpoints/network-policy.api"; -export { PersistentVolume, persistentVolumeApi } from "../../common/k8s-api/endpoints/persistent-volume.api"; -export { PersistentVolumeClaim, pvcApi, PersistentVolumeClaimsApi } from "../../common/k8s-api/endpoints/persistent-volume-claims.api"; -export { StorageClass, storageClassApi } from "../../common/k8s-api/endpoints/storage-class.api"; -export { Namespace, namespacesApi } from "../../common/k8s-api/endpoints/namespaces.api"; -export { KubeEvent, eventApi } from "../../common/k8s-api/endpoints/events.api"; -export { ServiceAccount, serviceAccountsApi } from "../../common/k8s-api/endpoints/service-accounts.api"; -export { Role, roleApi } from "../../common/k8s-api/endpoints/role.api"; -export { RoleBinding, roleBindingApi } from "../../common/k8s-api/endpoints/role-binding.api"; -export { ClusterRole, clusterRoleApi } from "../../common/k8s-api/endpoints/cluster-role.api"; -export { ClusterRoleBinding, clusterRoleBindingApi } from "../../common/k8s-api/endpoints/cluster-role-binding.api"; -export { CustomResourceDefinition, crdApi } from "../../common/k8s-api/endpoints/crd.api"; +export { + Pod, PodApi as PodsApi, + Node, NodeApi as NodesApi, + Deployment, DeploymentApi, + DaemonSet, DaemonSetApi, + StatefulSet, StatefulSetApi, + Job, JobApi, + CronJob, CronJobApi, + ConfigMap, ConfigMapApi, + Secret, SecretApi, + ReplicaSet, ReplicaSetApi, + ResourceQuota, ResourceQuotaApi, + LimitRange, LimitRangeApi, + HorizontalPodAutoscaler, HorizontalPodAutoscalerApi, + PodDisruptionBudget, PodDisruptionBudgetApi, + Service, ServiceApi, + Endpoint, EndpointApi, + Ingress, IngressApi, + NetworkPolicy, NetworkPolicyApi, + PersistentVolume, PersistentVolumeApi, + PersistentVolumeClaim, PersistentVolumeClaimApi as PersistentVolumeClaimsApi, + StorageClass, StorageClassApi, + Namespace, NamespaceApi, + Event as KubeEvent, EventApi, + ServiceAccount, ServiceAccountApi, + Role, RoleApi, + RoleBinding, RoleBindingApi, + ClusterRole, ClusterRoleApi, + ClusterRoleBinding, ClusterRoleBindingApi, + CustomResourceDefinition, CustomResourceDefinitionApi, +} from "../../common/k8s-api/endpoints"; // types export type { ILocalKubeApiConfig, IRemoteKubeApiConfig, IKubeApiCluster } from "../../common/k8s-api/kube-api"; -export type { IPodContainer, IPodContainerStatus } from "../../common/k8s-api/endpoints/pods.api"; +export type { IPodContainer, IPodContainerStatus } from "../../common/k8s-api/endpoints/pod.api"; export type { ISecretRef } from "../../common/k8s-api/endpoints/secret.api"; diff --git a/src/extensions/main-api/navigation.ts b/src/extensions/main-api/navigation.ts index 178f3d6d16..a2c523bdfd 100644 --- a/src/extensions/main-api/navigation.ts +++ b/src/extensions/main-api/navigation.ts @@ -3,8 +3,9 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import { WindowManager } from "../../main/window-manager"; +import windowManagerInjectable from "../../main/windows/manager.injectable"; +import { asLegacyGlobalObjectForExtensionApi } from "../as-legacy-globals-for-extension-api/as-legacy-global-object-for-extension-api"; export function navigate(url: string) { - return WindowManager.getInstance().navigate(url); + asLegacyGlobalObjectForExtensionApi(windowManagerInjectable).navigate(url); } diff --git a/src/extensions/registries/__tests__/page-registry.test.ts b/src/extensions/registries/__tests__/page-registry.test.ts index 1f8d1578eb..dfee03277b 100644 --- a/src/extensions/registries/__tests__/page-registry.test.ts +++ b/src/extensions/registries/__tests__/page-registry.test.ts @@ -10,8 +10,6 @@ import React from "react"; import fse from "fs-extra"; import { Console } from "console"; import { stderr, stdout } from "process"; -import { ThemeStore } from "../../../renderer/theme.store"; -import { UserStore } from "../../../common/user-store"; import { getDisForUnitTesting } from "../../../test-utils/get-dis-for-unit-testing"; import mockFs from "mock-fs"; @@ -55,8 +53,6 @@ describe("page registry tests", () => { isEnabled: true, isCompatible: true, }); - UserStore.createInstance(); - ThemeStore.createInstance(); ClusterPageRegistry.createInstance(); GlobalPageRegistry.createInstance().add({ id: "page-with-params", @@ -92,8 +88,6 @@ describe("page registry tests", () => { afterEach(() => { GlobalPageRegistry.resetInstance(); ClusterPageRegistry.resetInstance(); - ThemeStore.resetInstance(); - UserStore.resetInstance(); fse.remove("tmp"); mockFs.restore(); }); diff --git a/src/extensions/registries/index.ts b/src/extensions/registries/index.ts index 477f406b2c..21f53a358c 100644 --- a/src/extensions/registries/index.ts +++ b/src/extensions/registries/index.ts @@ -8,10 +8,7 @@ export * from "./page-registry"; export * from "./page-menu-registry"; export * from "./status-bar-registry"; -export * from "./kube-object-detail-registry"; export * from "./kube-object-menu-registry"; -export * from "./kube-object-status-registry"; export * from "./entity-setting-registry"; -export * from "./catalog-entity-detail-registry"; export * from "./workloads-overview-detail-registry"; export * from "./protocol-handler"; diff --git a/src/extensions/registries/kube-object-detail-registry.ts b/src/extensions/registries/kube-object-detail-registry.ts deleted file mode 100644 index 964d575b52..0000000000 --- a/src/extensions/registries/kube-object-detail-registry.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import type React from "react"; -import type { KubeObjectDetailsProps } from "../renderer-api/components"; -import type { KubeObject } from "../renderer-api/k8s-api"; -import { BaseRegistry } from "./base-registry"; - -export interface KubeObjectDetailComponents { - Details: React.ComponentType>; -} - -export interface KubeObjectDetailRegistration { - kind: string; - apiVersions: string[]; - components: KubeObjectDetailComponents; - priority?: number; -} - -export class KubeObjectDetailRegistry extends BaseRegistry { - getItemsForKind(kind: string, apiVersion: string) { - const items = this.getItems().filter((item) => { - return item.kind === kind && item.apiVersions.includes(apiVersion); - }); - - return items.sort((a, b) => (b.priority ?? 50) - (a.priority ?? 50)); - } -} diff --git a/src/extensions/registries/kube-object-status-registry.ts b/src/extensions/registries/kube-object-status-registry.ts deleted file mode 100644 index 0121e9a39e..0000000000 --- a/src/extensions/registries/kube-object-status-registry.ts +++ /dev/null @@ -1,29 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import type { KubeObject, KubeObjectStatus } from "../renderer-api/k8s-api"; -import { BaseRegistry } from "./base-registry"; - -export interface KubeObjectStatusRegistration { - kind: string; - apiVersions: string[]; - resolve: (object: KubeObject) => KubeObjectStatus; -} - -export class KubeObjectStatusRegistry extends BaseRegistry { - getItemsForKind(kind: string, apiVersion: string) { - return this.getItems() - .filter((item) => ( - item.kind === kind - && item.apiVersions.includes(apiVersion) - )); - } - - getItemsForObject(src: KubeObject) { - return this.getItemsForKind(src.kind, src.apiVersion) - .map(item => item.resolve(src)) - .filter(Boolean); - } -} diff --git a/src/extensions/renderer-api/catalog.ts b/src/extensions/renderer-api/catalog.ts index 34f6fcd0bb..7710f8f0c9 100644 --- a/src/extensions/renderer-api/catalog.ts +++ b/src/extensions/renderer-api/catalog.ts @@ -3,35 +3,54 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ - -import type { CatalogCategory, CatalogEntity } from "../../common/catalog"; -import { catalogEntityRegistry as registry } from "../../renderer/api/catalog-entity-registry"; -import type { CatalogEntityOnBeforeRun } from "../../renderer/api/catalog-entity-registry"; +import { asLegacyGlobalObjectForExtensionApi } from "../as-legacy-globals-for-extension-api/as-legacy-global-object-for-extension-api"; +import type { CatalogCategory, CatalogEntity, CatalogCategoryRegistry as InternalCatalogCategoryRegistry, CatalogEntityData, CatalogEntityKindData, CategoryFilter } from "../../common/catalog"; +import type { CatalogEntityRegistry as InternalCatalogEntityRegistry, CatalogEntityOnBeforeRun } from "../../renderer/catalog/entity-registry"; import type { Disposer } from "../../common/utils"; -export { catalogCategoryRegistry as catalogCategories } from "../../common/catalog/catalog-category-registry"; +import catalogEntityRegistryInjectable from "../../renderer/catalog/entity-registry.injectable"; +import catalogCategoryRegistryInjectable from "../../renderer/catalog/category-registry.injectable"; + +interface CatalogEntityRegistryDependencies { + readonly internalRegistry: InternalCatalogEntityRegistry; +} + +export type { CatalogEntityRegistry }; + +class CatalogEntityRegistry { + /** + * @internal + */ + constructor(protected readonly dependencies: CatalogEntityRegistryDependencies) {} -export class CatalogEntityRegistry { /** * Currently active/visible entity */ get activeEntity() { - return registry.activeEntity; + return this.dependencies.internalRegistry.activeEntity; } get entities(): Map { - return registry.entities; + return this.dependencies.internalRegistry.entities; } getById(id: string) { return this.entities.get(id); } - getItemsForApiKind(apiVersion: string, kind: string): T[] { - return registry.getItemsForApiKind(apiVersion, kind); + /** + * @deprecated use cast and not the unused generic type param + */ + getItemsForApiKind(apiVersion: string, kind: string): T[]; + getItemsForApiKind(apiVersion: string, kind: string): CatalogEntity[] { + return this.dependencies.internalRegistry.getItemsForApiKind(apiVersion, kind); } - getItemsForCategory(category: CatalogCategory): T[] { - return registry.getItemsForCategory(category); + /** + * @deprecated use cast and not the unused generic type param + */ + getItemsForCategory(category: CatalogCategory): T[]; + getItemsForCategory(category: CatalogCategory): CatalogEntity[] { + return this.dependencies.internalRegistry.getItemsForCategory(category); } /** @@ -43,8 +62,72 @@ export class CatalogEntityRegistry { * @returns A function to remove that hook */ addOnBeforeRun(onBeforeRun: CatalogEntityOnBeforeRun): Disposer { - return registry.addOnBeforeRun(onBeforeRun); + return this.dependencies.internalRegistry.addOnBeforeRun(onBeforeRun); } } -export const catalogEntities = new CatalogEntityRegistry(); +interface CatalogCategoryRegistryDependencies { + readonly internalRegistry: InternalCatalogCategoryRegistry; +} + +export type { CatalogCategoryRegistry }; + +class CatalogCategoryRegistry { + constructor(protected readonly dependencies: CatalogCategoryRegistryDependencies) {} + + add(category: CatalogCategory): Disposer { + return this.dependencies.internalRegistry.add(category); + } + + get items() { + return this.dependencies.internalRegistry.items; + } + + get filteredItems() { + return this.dependencies.internalRegistry.filteredItems.get(); + } + + /** + * @deprecated use cast instead of unused generic type argument + */ + getForGroupKind(group: string, kind: string): T | undefined; + getForGroupKind(group: string, kind: string): CatalogCategory | undefined { + return this.dependencies.internalRegistry.getForGroupKind(group, kind); + } + + getEntityForData(data: CatalogEntityData & CatalogEntityKindData): CatalogEntity | null { + return this.dependencies.internalRegistry.getEntityForData(data); + } + + /** + * @deprecated use cast instead of unused generic type argument + */ + getCategoryForEntity({ kind, apiVersion }: CatalogEntityData & CatalogEntityKindData): T; + /** + * @throws if category is not found + */ + getCategoryForEntity(entity: CatalogEntityData & CatalogEntityKindData): CatalogCategory { + return this.dependencies.internalRegistry.getCategoryForEntity(entity); + } + + getByName(name: string): CatalogCategory | undefined { + return this.dependencies.internalRegistry.getByName(name); + } + + /** + * Add a new filter to the set of category filters + * @param fn The function that should return a truthy value if that category should be displayed + * @returns A function to remove that filter + */ + addCatalogCategoryFilter(fn: CategoryFilter): Disposer { + return this.dependencies.internalRegistry.addCatalogCategoryFilter(fn); + } +} + +export const catalogEntities = new CatalogEntityRegistry({ + internalRegistry: asLegacyGlobalObjectForExtensionApi(catalogEntityRegistryInjectable), +}); + +export const catalogCategories = new CatalogCategoryRegistry({ + internalRegistry: asLegacyGlobalObjectForExtensionApi(catalogCategoryRegistryInjectable), +}); diff --git a/src/extensions/renderer-api/components.ts b/src/extensions/renderer-api/components.ts index 79372c71dd..ad3a64bcaa 100644 --- a/src/extensions/renderer-api/components.ts +++ b/src/extensions/renderer-api/components.ts @@ -2,15 +2,18 @@ * Copyright (c) OpenLens Authors. All rights reserved. * Licensed under MIT License. See LICENSE in root directory for more information. */ -import { asLegacyGlobalFunctionForExtensionApi } from "../as-legacy-globals-for-extension-api/as-legacy-global-function-for-extension-api"; -import createTerminalTabInjectable from "../../renderer/components/dock/create-terminal-tab/create-terminal-tab.injectable"; -import terminalStoreInjectable from "../../renderer/components/dock/terminal-store/terminal-store.injectable"; +import terminalStoreInjectable from "../../renderer/components/dock/terminal/store.injectable"; import { asLegacyGlobalObjectForExtensionApi } from "../as-legacy-globals-for-extension-api/as-legacy-global-object-for-extension-api"; -import logTabStoreInjectable from "../../renderer/components/dock/log-tab-store/log-tab-store.injectable"; import { asLegacyGlobalSingletonForExtensionApi } from "../as-legacy-globals-for-extension-api/as-legacy-global-singleton-for-extension-api"; -import { TerminalStore as TerminalStoreClass } from "../../renderer/components/dock/terminal-store/terminal.store"; - +import { TerminalStore as TerminalStoreClass } from "../../renderer/components/dock/terminal/store"; import commandOverlayInjectable from "../../renderer/components/command-palette/command-overlay.injectable"; +import newTerminalTabInjectable from "../../renderer/components/dock/terminal/create-tab.injectable"; +import { ConfirmDialog as _ConfirmDialog } from "../../renderer/components/confirm-dialog"; +import openConfirmDialogInjectable from "../../renderer/components/confirm-dialog/dialog-open.injectable"; +import confirmWithDialogInjectable from "../../renderer/components/confirm-dialog/dialog-confirm.injectable"; +import { asLegacyGlobalFunctionForExtensionApi } from "../as-legacy-globals-for-extension-api/as-legacy-global-function-for-extension-api"; +import logTabStoreInjectable from "../../renderer/components/dock/logs/tab-store.injectable"; + // layouts export * from "../../renderer/components/layout/main-layout"; @@ -36,6 +39,12 @@ export type { AdditionalCategoryColumnRegistration, } from "../../renderer/components/+catalog/custom-category-columns"; +export type { ConfirmDialogBooleanParams, ConfirmDialogParams, ConfirmDialogProps } from "../../renderer/components/confirm-dialog"; +export const ConfirmDialog = Object.assign(_ConfirmDialog, { + open: asLegacyGlobalFunctionForExtensionApi(openConfirmDialogInjectable), + confirm: asLegacyGlobalFunctionForExtensionApi(confirmWithDialogInjectable), +}); + // other components export * from "../../renderer/components/icon"; export * from "../../renderer/components/tooltip"; @@ -44,14 +53,13 @@ export * from "../../renderer/components/table"; export * from "../../renderer/components/badge"; export * from "../../renderer/components/drawer"; export * from "../../renderer/components/dialog"; -export * from "../../renderer/components/confirm-dialog"; export * from "../../renderer/components/line-progress"; export * from "../../renderer/components/menu"; export * from "../../renderer/components/notifications"; export * from "../../renderer/components/spinner"; export * from "../../renderer/components/stepper"; export * from "../../renderer/components/wizard"; -export * from "../../renderer/components/+workloads-pods/pod-details-list"; +export * from "../../renderer/components/+pods/details-list"; export * from "../../renderer/components/+namespaces/namespace-select"; export * from "../../renderer/components/+namespaces/namespace-select-filter"; export * from "../../renderer/components/layout/sub-title"; @@ -70,7 +78,7 @@ export * from "../../renderer/components/+events/kube-event-details"; // specific exports export * from "../../renderer/components/status-brick"; -export const createTerminalTab = asLegacyGlobalFunctionForExtensionApi(createTerminalTabInjectable); +export const createTerminalTab = asLegacyGlobalFunctionForExtensionApi(newTerminalTabInjectable); export const TerminalStore = asLegacyGlobalSingletonForExtensionApi(TerminalStoreClass, terminalStoreInjectable); export const terminalStore = asLegacyGlobalObjectForExtensionApi(terminalStoreInjectable); export const logTabStore = asLegacyGlobalObjectForExtensionApi(logTabStoreInjectable); diff --git a/src/extensions/renderer-api/k8s-api.ts b/src/extensions/renderer-api/k8s-api.ts index 266d4759f5..252e32326e 100644 --- a/src/extensions/renderer-api/k8s-api.ts +++ b/src/extensions/renderer-api/k8s-api.ts @@ -3,43 +3,108 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -export { isAllowedResource } from "../../common/utils/allowed-resource"; +import apiManagerInjectable from "../../common/k8s-api/api-manager.injectable"; +import configMapApiInjectable from "../../common/k8s-api/endpoints/configmap.api.injectable"; +import cronJobApiInjectable from "../../common/k8s-api/endpoints/cron-job.api.injectable"; +import daemonSetApiInjectable from "../../common/k8s-api/endpoints/daemon-set.api.injectable"; +import deploymentApiInjectable from "../../common/k8s-api/endpoints/deployment.api.injectable"; +import horizontalPodAutoscalerApiInjectable from "../../common/k8s-api/endpoints/horizontal-pod-autoscaler.api.injectable"; +import jobApiInjectable from "../../common/k8s-api/endpoints/job.api.injectable"; +import limitRangeApiInjectable from "../../common/k8s-api/endpoints/limit-range.api.injectable"; +import nodeApiInjectable from "../../common/k8s-api/endpoints/node.api.injectable"; +import persistentVolumeClaimApiInjectable from "../../common/k8s-api/endpoints/persistent-volume-claim.api.injectable"; +import persistentVolumeApiInjectable from "../../common/k8s-api/endpoints/persistent-volume.api.injectable"; +import podApiInjectable from "../../common/k8s-api/endpoints/pod.api.injectable"; +import replicaSetApiInjectable from "../../common/k8s-api/endpoints/replica-set.api.injectable"; +import resourceQuotaApiInjectable from "../../common/k8s-api/endpoints/resource-quota.api.injectable"; +import secretApiInjectable from "../../common/k8s-api/endpoints/secret.api.injectable"; +import serviceApiInjectable from "../../common/k8s-api/endpoints/service.api.injectable"; +import statefulSetApiInjectable from "../../common/k8s-api/endpoints/stateful-set.api.injectable"; +import horizontalPodAutoscalerStoreInjectable from "../../renderer/components/+autoscalers/store.injectable"; +import limitRangeStoreInjectable from "../../renderer/components/+limit-ranges/store.injectable"; +import resourceQuotaStoreInjectable from "../../renderer/components/+resource-quotas/store.injectable"; +import secretStoreInjectable from "../../renderer/components/+secrets/store.injectable"; +import eventStoreInjectable from "../../renderer/components/+events/store.injectable"; +import namespaceStoreInjectable from "../../renderer/components/+namespaces/store.injectable"; +import serviceAccountStoreInjectable from "../../renderer/components/+service-accounts/store.injectable"; +import cronJobStoreInjectable from "../../renderer/components/+cronjobs/store.injectable"; +import jobStoreInjectable from "../../renderer/components/+jobs/store.injectable"; +import replicaSetStoreInjectable from "../../renderer/components/+replica-sets/store.injectable"; +import { asLegacyGlobalObjectForExtensionApi } from "../as-legacy-globals-for-extension-api/as-legacy-global-object-for-extension-api"; +import nodeStoreInjectable from "../../renderer/components/+nodes/store.injectable"; +import podStoreInjectable from "../../renderer/components/+pods/store.injectable"; +import deploymentStoreInjectable from "../../renderer/components/+deployments/store.injectable"; +import daemonSetStoreInjectable from "../../renderer/components/+daemonsets/store.injectable"; +import statefulSetStoreInjectable from "../../renderer/components/+stateful-sets/store.injectable"; +import configMapStoreInjectable from "../../renderer/components/+config-maps/store.injectable"; +import persistentVolumeClaimStoreInjectable from "../../renderer/components/+persistent-volume-claims/store.injectable"; +import persistentVolumeStoreInjectable from "../../renderer/components/+persistent-volumes/store.injectable"; +import podDisruptionBudgetApiInjectable from "../../common/k8s-api/endpoints/pod-disruption-budget.api.injectable"; +import podDisruptionBudgetStoreInjectable from "../../renderer/components/+pod-disruption-budgets/store.injectable"; +import endpointApiInjectable from "../../common/k8s-api/endpoints/endpoint.api.injectable"; +import ingressApiInjectable from "../../common/k8s-api/endpoints/ingress.api.injectable"; +import networkPolicyApiInjectable from "../../common/k8s-api/endpoints/network-policy.api.injectable"; +import storageClassApiInjectable from "../../common/k8s-api/endpoints/storage-class.api.injectable"; +import namespaceApiInjectable from "../../common/k8s-api/endpoints/namespace.api.injectable"; +import eventApiInjectable from "../../common/k8s-api/endpoints/event.api.injectable"; +import serviceAccountApiInjectable from "../../common/k8s-api/endpoints/service-account.api.injectable"; +import endpointStoreInjectable from "../../renderer/components/+endpoints/store.injectable"; +import ingressStoreInjectable from "../../renderer/components/+ingresses/store.injectables"; +import networkPolicyStoreInjectable from "../../renderer/components/+network-policies/store.injectable"; +import storageClassStoreInjectable from "../../renderer/components/+storage-classes/store.injectable"; +import roleApiInjectable from "../../common/k8s-api/endpoints/role.api.injectable"; +import roleStoreInjectable from "../../renderer/components/+roles/store.injectable"; +import roleBindingApiInjectable from "../../common/k8s-api/endpoints/role-binding.api.injectable"; +import clusterRoleApiInjectable from "../../common/k8s-api/endpoints/cluster-role.api.injectable"; +import clusterRoleBindingApiInjectable from "../../common/k8s-api/endpoints/cluster-role-binding.api.injectable"; +import customResourceDefinitionApiInjectable from "../../common/k8s-api/endpoints/custom-resource-definition.api.injectable"; +import roleBindingStoreInjectable from "../../renderer/components/+role-bindings/store.injectable"; +import clusterRoleStoreInjectable from "../../renderer/components/+cluster-roles/store.injectable"; +import clusterRoleBindingStoreInjectable from "../../renderer/components/+cluster-role-bindings/store.injectable"; +import customResourceDefinitionStoreInjectable from "../../renderer/components/+custom-resource/store.injectable"; +import { asLegacyGlobalFunctionForExtensionApi } from "../as-legacy-globals-for-extension-api/as-legacy-global-function-for-extension-api"; +import isAllowedResourceInjectable from "../../renderer/utils/allowed-resource.injectable"; + +export const isAllowedResource = asLegacyGlobalFunctionForExtensionApi(isAllowedResourceInjectable); + export { ResourceStack } from "../../common/k8s/resource-stack"; -export { apiManager } from "../../common/k8s-api/api-manager"; export { KubeObjectStore } from "../../common/k8s-api/kube-object.store"; export { KubeApi, forCluster, forRemoteCluster } from "../../common/k8s-api/kube-api"; export { KubeObject, KubeStatus } from "../../common/k8s-api/kube-object"; -export { Pod, podsApi, PodsApi } from "../../common/k8s-api/endpoints"; -export { Node, nodesApi, NodesApi } from "../../common/k8s-api/endpoints"; -export { Deployment, deploymentApi, DeploymentApi } from "../../common/k8s-api/endpoints"; -export { DaemonSet, daemonSetApi } from "../../common/k8s-api/endpoints"; -export { StatefulSet, statefulSetApi } from "../../common/k8s-api/endpoints"; -export { Job, jobApi } from "../../common/k8s-api/endpoints"; -export { CronJob, cronJobApi } from "../../common/k8s-api/endpoints"; -export { ConfigMap, configMapApi } from "../../common/k8s-api/endpoints"; -export { Secret, secretsApi } from "../../common/k8s-api/endpoints"; -export { ReplicaSet, replicaSetApi } from "../../common/k8s-api/endpoints"; -export { ResourceQuota, resourceQuotaApi } from "../../common/k8s-api/endpoints"; -export { LimitRange, limitRangeApi } from "../../common/k8s-api/endpoints"; -export { HorizontalPodAutoscaler, hpaApi } from "../../common/k8s-api/endpoints"; -export { PodDisruptionBudget, pdbApi } from "../../common/k8s-api/endpoints"; -export { Service, serviceApi } from "../../common/k8s-api/endpoints"; -export { Endpoint, endpointApi } from "../../common/k8s-api/endpoints"; -export { Ingress, ingressApi, IngressApi } from "../../common/k8s-api/endpoints"; -export { NetworkPolicy, networkPolicyApi } from "../../common/k8s-api/endpoints"; -export { PersistentVolume, persistentVolumeApi } from "../../common/k8s-api/endpoints"; -export { PersistentVolumeClaim, pvcApi, PersistentVolumeClaimsApi } from "../../common/k8s-api/endpoints"; -export { StorageClass, storageClassApi } from "../../common/k8s-api/endpoints"; -export { Namespace, namespacesApi } from "../../common/k8s-api/endpoints"; -export { KubeEvent, eventApi } from "../../common/k8s-api/endpoints"; -export { ServiceAccount, serviceAccountsApi } from "../../common/k8s-api/endpoints"; -export { Role, roleApi } from "../../common/k8s-api/endpoints"; -export { RoleBinding, roleBindingApi } from "../../common/k8s-api/endpoints"; -export { ClusterRole, clusterRoleApi } from "../../common/k8s-api/endpoints"; -export { ClusterRoleBinding, clusterRoleBindingApi } from "../../common/k8s-api/endpoints"; -export { CustomResourceDefinition, crdApi } from "../../common/k8s-api/endpoints"; export { KubeObjectStatusLevel } from "./kube-object-status"; export { KubeJsonApi } from "../../common/k8s-api/kube-json-api"; +export { + Pod, PodApi as PodsApi, + Node, NodeApi as NodesApi, + Deployment, DeploymentApi, + DaemonSet, DaemonSetApi, + StatefulSet, StatefulSetApi, + Job, JobApi, + CronJob, CronJobApi, + ConfigMap, ConfigMapApi, + Secret, SecretApi, + ReplicaSet, ReplicaSetApi, + ResourceQuota, ResourceQuotaApi, + LimitRange, LimitRangeApi, + HorizontalPodAutoscaler, HorizontalPodAutoscalerApi, + PodDisruptionBudget, PodDisruptionBudgetApi, + Service, ServiceApi, + Endpoint, EndpointApi, + Ingress, IngressApi, + NetworkPolicy, NetworkPolicyApi, + PersistentVolume, PersistentVolumeApi, + PersistentVolumeClaim, PersistentVolumeClaimApi as PersistentVolumeClaimsApi, + StorageClass, StorageClassApi, + Namespace, NamespaceApi, + Event as KubeEvent, EventApi, + ServiceAccount, ServiceAccountApi, + Role, RoleApi, + RoleBinding, RoleBindingApi, + ClusterRole, ClusterRoleApi, + ClusterRoleBinding, ClusterRoleBindingApi, + CustomResourceDefinition, CustomResourceDefinitionApi, +} from "../../common/k8s-api/endpoints"; +export type { ApiManager } from "../../common/k8s-api/api-manager"; // types export type { ILocalKubeApiConfig, IRemoteKubeApiConfig, IKubeApiCluster } from "../../common/k8s-api/kube-api"; @@ -48,31 +113,119 @@ export type { ISecretRef } from "../../common/k8s-api/endpoints"; export type { KubeObjectStatus } from "./kube-object-status"; // stores -export type { EventStore } from "../../renderer/components/+events/event.store"; -export type { PodsStore } from "../../renderer/components/+workloads-pods/pods.store"; -export type { NodesStore } from "../../renderer/components/+nodes/nodes.store"; -export type { DeploymentStore } from "../../renderer/components/+workloads-deployments/deployments.store"; -export type { DaemonSetStore } from "../../renderer/components/+workloads-daemonsets/daemonsets.store"; -export type { StatefulSetStore } from "../../renderer/components/+workloads-statefulsets/statefulset.store"; -export type { JobStore } from "../../renderer/components/+workloads-jobs/job.store"; -export type { CronJobStore } from "../../renderer/components/+workloads-cronjobs/cronjob.store"; -export type { ConfigMapsStore } from "../../renderer/components/+config-maps/config-maps.store"; -export type { SecretsStore } from "../../renderer/components/+config-secrets/secrets.store"; -export type { ReplicaSetStore } from "../../renderer/components/+workloads-replicasets/replicasets.store"; -export type { ResourceQuotasStore } from "../../renderer/components/+config-resource-quotas/resource-quotas.store"; -export type { LimitRangesStore } from "../../renderer/components/+config-limit-ranges/limit-ranges.store"; -export type { HPAStore } from "../../renderer/components/+config-autoscalers/hpa.store"; -export type { PodDisruptionBudgetsStore } from "../../renderer/components/+config-pod-disruption-budgets/pod-disruption-budgets.store"; -export type { ServiceStore } from "../../renderer/components/+network-services/services.store"; -export type { EndpointStore } from "../../renderer/components/+network-endpoints/endpoints.store"; -export type { IngressStore } from "../../renderer/components/+network-ingresses/ingress.store"; -export type { NetworkPolicyStore } from "../../renderer/components/+network-policies/network-policy.store"; -export type { PersistentVolumesStore } from "../../renderer/components/+storage-volumes/volumes.store"; -export type { VolumeClaimStore } from "../../renderer/components/+storage-volume-claims/volume-claim.store"; -export type { StorageClassStore } from "../../renderer/components/+storage-classes/storage-class.store"; -export type { NamespaceStore } from "../../renderer/components/+namespaces/namespace-store/namespace.store"; -export type { ServiceAccountsStore } from "../../renderer/components/+user-management/+service-accounts/store"; -export type { RolesStore } from "../../renderer/components/+user-management/+roles/store"; -export type { RoleBindingsStore } from "../../renderer/components/+user-management/+role-bindings/store"; -export type { CRDStore } from "../../renderer/components/+custom-resources/crd.store"; -export type { CRDResourceStore } from "../../renderer/components/+custom-resources/crd-resource.store"; +export type { EventStore } from "../../renderer/components/+events/store"; +export type { PodStore as PodsStore } from "../../renderer/components/+pods/store"; +export type { NodeStore as NodesStore } from "../../renderer/components/+nodes/store"; +export type { DeploymentStore } from "../../renderer/components/+deployments/store"; +export type { DaemonSetStore } from "../../renderer/components/+daemonsets/store"; +export type { StatefulSetStore } from "../../renderer/components/+stateful-sets/store"; +export type { JobStore } from "../../renderer/components/+jobs/store"; +export type { CronJobStore } from "../../renderer/components/+cronjobs/store"; +export type { ConfigMapStore as ConfigMapsStore } from "../../renderer/components/+config-maps/store"; +export type { SecretStore as SecretsStore } from "../../renderer/components/+secrets/store"; +export type { ReplicaSetStore } from "../../renderer/components/+replica-sets/store"; +export type { ResourceQuotaStore as ResourceQuotasStore } from "../../renderer/components/+resource-quotas/store"; +export type { LimitRangeStore as LimitRangesStore } from "../../renderer/components/+limit-ranges/store"; +export type { HorizontalPodAutoscalerStore as HPAStore } from "../../renderer/components/+autoscalers/store"; +export type { PodDisruptionBudgetStore as PodDisruptionBudgetsStore } from "../../renderer/components/+pod-disruption-budgets/store"; +export type { ServiceStore } from "../../renderer/components/+services/store"; +export type { EndpointStore } from "../../renderer/components/+endpoints/store"; +export type { IngressStore } from "../../renderer/components/+ingresses/store"; +export type { NetworkPolicyStore } from "../../renderer/components/+network-policies/store"; +export type { PersistentVolumeStore as PersistentVolumesStore } from "../../renderer/components/+persistent-volumes/store"; +export type { PersistentVolumeClaimStore as VolumeClaimStore } from "../../renderer/components/+persistent-volume-claims/store"; +export type { StorageClassStore } from "../../renderer/components/+storage-classes/store"; +export type { NamespaceStore } from "../../renderer/components/+namespaces/store"; +export type { ServiceAccountStore as ServiceAccountsStore } from "../../renderer/components/+service-accounts/store"; +export type { RoleStore as RolesStore } from "../../renderer/components/+roles/store"; +export type { RoleBindingStore as RoleBindingsStore } from "../../renderer/components/+role-bindings/store"; +export type { CustomResourceDefinitionStore as CRDStore } from "../../renderer/components/+custom-resource/store"; +export type { CRDResourceStore } from "../../renderer/components/+custom-resource/resource.store"; + +export const apiManager = asLegacyGlobalObjectForExtensionApi(apiManagerInjectable); + +export const nodesApi = asLegacyGlobalObjectForExtensionApi(nodeApiInjectable); +export const nodeStore = asLegacyGlobalObjectForExtensionApi(nodeStoreInjectable); + +export const podsApi = asLegacyGlobalObjectForExtensionApi(podApiInjectable); +export const podStore = asLegacyGlobalObjectForExtensionApi(podStoreInjectable); + +export const serviceApi = asLegacyGlobalObjectForExtensionApi(serviceApiInjectable); + +export const deploymentApi = asLegacyGlobalObjectForExtensionApi(deploymentApiInjectable); +export const deploymentStore = asLegacyGlobalObjectForExtensionApi(deploymentStoreInjectable); + +export const daemonSetApi = asLegacyGlobalObjectForExtensionApi(daemonSetApiInjectable); +export const daemonSetStore = asLegacyGlobalObjectForExtensionApi(daemonSetStoreInjectable); + +export const statefulSetApi = asLegacyGlobalObjectForExtensionApi(statefulSetApiInjectable); +export const statefulSetStore = asLegacyGlobalObjectForExtensionApi(statefulSetStoreInjectable); + +export const jobApi = asLegacyGlobalObjectForExtensionApi(jobApiInjectable); +export const jobStore = asLegacyGlobalObjectForExtensionApi(jobStoreInjectable); + +export const cronJobApi = asLegacyGlobalObjectForExtensionApi(cronJobApiInjectable); +export const cronJobStore = asLegacyGlobalObjectForExtensionApi(cronJobStoreInjectable); + +export const configMapApi = asLegacyGlobalObjectForExtensionApi(configMapApiInjectable); +export const configMapStore = asLegacyGlobalObjectForExtensionApi(configMapStoreInjectable); + +export const pvcApi = asLegacyGlobalObjectForExtensionApi(persistentVolumeClaimApiInjectable); +export const persistentVolumeClaimStore = asLegacyGlobalObjectForExtensionApi(persistentVolumeClaimStoreInjectable); + +export const persistentVolumeApi = asLegacyGlobalObjectForExtensionApi(persistentVolumeApiInjectable); +export const persistentVolumeStore = asLegacyGlobalObjectForExtensionApi(persistentVolumeStoreInjectable); + +export const secretApi = asLegacyGlobalObjectForExtensionApi(secretApiInjectable); +export const secretStore = asLegacyGlobalObjectForExtensionApi(secretStoreInjectable); + +export const replicaSetApi = asLegacyGlobalObjectForExtensionApi(replicaSetApiInjectable); +export const replicaSetStore = asLegacyGlobalObjectForExtensionApi(replicaSetStoreInjectable); + +export const resourceQuotaApi = asLegacyGlobalObjectForExtensionApi(resourceQuotaApiInjectable); +export const resourceQuotaStore = asLegacyGlobalObjectForExtensionApi(resourceQuotaStoreInjectable); + +export const limitRangeApi = asLegacyGlobalObjectForExtensionApi(limitRangeApiInjectable); +export const limitRangeStore = asLegacyGlobalObjectForExtensionApi(limitRangeStoreInjectable); + +export const hpaApi = asLegacyGlobalObjectForExtensionApi(horizontalPodAutoscalerApiInjectable); +export const horizontalPodAutoscalerStore = asLegacyGlobalObjectForExtensionApi(horizontalPodAutoscalerStoreInjectable); + +export const pdbApi = asLegacyGlobalObjectForExtensionApi(podDisruptionBudgetApiInjectable); +export const podDisruptionBudgetStore = asLegacyGlobalObjectForExtensionApi(podDisruptionBudgetStoreInjectable); + +export const endpointApi = asLegacyGlobalObjectForExtensionApi(endpointApiInjectable); +export const endpointStore = asLegacyGlobalObjectForExtensionApi(endpointStoreInjectable); + +export const ingressApi = asLegacyGlobalObjectForExtensionApi(ingressApiInjectable); +export const ingressStore = asLegacyGlobalObjectForExtensionApi(ingressStoreInjectable); + +export const networkPolicyApi = asLegacyGlobalObjectForExtensionApi(networkPolicyApiInjectable); +export const networkPolicyStore = asLegacyGlobalObjectForExtensionApi(networkPolicyStoreInjectable); + +export const storageClassApi = asLegacyGlobalObjectForExtensionApi(storageClassApiInjectable); +export const storageClassStore = asLegacyGlobalObjectForExtensionApi(storageClassStoreInjectable); + +export const namespacesApi = asLegacyGlobalObjectForExtensionApi(namespaceApiInjectable); +export const namespaceStore = asLegacyGlobalObjectForExtensionApi(namespaceStoreInjectable); + +export const eventApi = asLegacyGlobalObjectForExtensionApi(eventApiInjectable); +export const eventStore = asLegacyGlobalObjectForExtensionApi(eventStoreInjectable); + +export const serviceAccountsApi = asLegacyGlobalObjectForExtensionApi(serviceAccountApiInjectable); +export const serviceAccountStore = asLegacyGlobalObjectForExtensionApi(serviceAccountStoreInjectable); + +export const roleApi = asLegacyGlobalObjectForExtensionApi(roleApiInjectable); +export const roleStore = asLegacyGlobalObjectForExtensionApi(roleStoreInjectable); + +export const roleBindingApi = asLegacyGlobalObjectForExtensionApi(roleBindingApiInjectable); +export const roleBindingStore = asLegacyGlobalObjectForExtensionApi(roleBindingStoreInjectable); + +export const clusterRoleApi = asLegacyGlobalObjectForExtensionApi(clusterRoleApiInjectable); +export const clusterRoleStore = asLegacyGlobalObjectForExtensionApi(clusterRoleStoreInjectable); + +export const clusterRoleBindingApi = asLegacyGlobalObjectForExtensionApi(clusterRoleBindingApiInjectable); +export const clusterRoleBindingStore = asLegacyGlobalObjectForExtensionApi(clusterRoleBindingStoreInjectable); + +export const crdApi = asLegacyGlobalObjectForExtensionApi(customResourceDefinitionApiInjectable); +export const customResourceDefinitionStore = asLegacyGlobalObjectForExtensionApi(customResourceDefinitionStoreInjectable); diff --git a/src/extensions/renderer-api/theming.ts b/src/extensions/renderer-api/theming.ts index 76796ab7bd..6a8bf85f6d 100644 --- a/src/extensions/renderer-api/theming.ts +++ b/src/extensions/renderer-api/theming.ts @@ -3,8 +3,11 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import { ThemeStore } from "../../renderer/theme.store"; +import activeThemeInjectable from "../../renderer/themes/active-theme.injectable"; +import { asLegacyGlobalObjectForExtensionApi } from "../as-legacy-globals-for-extension-api/as-legacy-global-object-for-extension-api"; + +const activeTheme = asLegacyGlobalObjectForExtensionApi(activeThemeInjectable); export function getActiveTheme() { - return ThemeStore.getInstance().activeTheme; + return activeTheme.get(); } diff --git a/src/main/__test__/context-handler.test.ts b/src/main/__test__/context-handler.test.ts index 191a129dbe..a171eaafaa 100644 --- a/src/main/__test__/context-handler.test.ts +++ b/src/main/__test__/context-handler.test.ts @@ -3,7 +3,6 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import { UserStore } from "../../common/user-store"; import type { ContextHandler } from "../context-handler/context-handler"; import { PrometheusProvider, PrometheusProviderRegistry, PrometheusService } from "../prometheus"; import mockFs from "mock-fs"; @@ -46,19 +45,19 @@ class TestProvider extends PrometheusProvider { throw new Error("getQuery is not implemented."); } - async getPrometheusService(): Promise { + getPrometheusService(): Promise { switch (this.alwaysFail) { case ServiceResult.Success: - return { + return Promise.resolve({ id: this.id, namespace: "default", port: 7000, service: "", - }; + }); case ServiceResult.Failure: throw new Error("does fail"); case ServiceResult.Undefined: - return undefined; + return Promise.resolve(undefined); } } } @@ -89,7 +88,6 @@ describe("ContextHandler", () => { afterEach(() => { PrometheusProviderRegistry.resetInstance(); - UserStore.resetInstance(); mockFs.restore(); }); @@ -99,7 +97,7 @@ describe("ContextHandler", () => { [0, 1], [0, 2], [0, 3], - ])("should throw from %d success(es) after %d failure(s)", async (successes, failures) => { + ])("should throw from %d success(es) after %d failure(s)", (successes, failures) => { const reg = PrometheusProviderRegistry.getInstance(); let count = 0; diff --git a/src/main/__test__/kube-auth-proxy.test.ts b/src/main/__test__/kube-auth-proxy.test.ts index 7a3f4ea082..cca13f676c 100644 --- a/src/main/__test__/kube-auth-proxy.test.ts +++ b/src/main/__test__/kube-auth-proxy.test.ts @@ -42,7 +42,6 @@ import { bundledKubectlPath, Kubectl } from "../kubectl/kubectl"; import { mock, MockProxy } from "jest-mock-extended"; import { waitUntilUsed } from "tcp-port-used"; import { EventEmitter, Readable } from "stream"; -import { UserStore } from "../../common/user-store"; import { Console } from "console"; import { stdout, stderr } from "process"; import mockFs from "mock-fs"; @@ -96,18 +95,14 @@ describe("kube auth proxy tests", () => { await di.runSetups(); createCluster = di.inject(createClusterInjectionToken); - createKubeAuthProxy = di.inject(createKubeAuthProxyInjectable); - - UserStore.createInstance(); }); afterEach(() => { - UserStore.resetInstance(); mockFs.restore(); }); - it("calling exit multiple times shouldn't throw", async () => { + it("calling exit multiple times shouldn't throw", () => { const cluster = createCluster({ id: "foobar", kubeConfigPath: "minikube-config.yml", @@ -126,7 +121,7 @@ describe("kube auth proxy tests", () => { let listeners: EventEmitter; let proxy: KubeAuthProxy; - beforeEach(async () => { + beforeEach(() => { mockedCP = mock(); listeners = new EventEmitter(); diff --git a/src/main/__test__/kubeconfig-manager.test.ts b/src/main/__test__/kubeconfig-manager.test.ts index 53808c6ce9..22b1b49361 100644 --- a/src/main/__test__/kubeconfig-manager.test.ts +++ b/src/main/__test__/kubeconfig-manager.test.ts @@ -30,7 +30,7 @@ jest.mock("winston", () => ({ })); import { getDiForUnitTesting } from "../getDiForUnitTesting"; -import { KubeconfigManager } from "../kubeconfig-manager/kubeconfig-manager"; +import type { KubeconfigManager } from "../kubeconfig-manager/kubeconfig-manager"; import mockFs from "mock-fs"; import type { Cluster } from "../../common/cluster/cluster"; import fse from "fs-extra"; @@ -39,7 +39,9 @@ import { Console } from "console"; import * as path from "path"; import createKubeconfigManagerInjectable from "../kubeconfig-manager/create-kubeconfig-manager.injectable"; import { createClusterInjectionToken } from "../../common/cluster/create-cluster-injection-token"; -import directoryForTempInjectable from "../../common/app-paths/directory-for-temp/directory-for-temp.injectable"; +import directoryForTempInjectable from "../../common/app-paths/directory-for-temp.injectable"; +import getProxyPortInjectable from "../lens-proxy/get-proxy-port.injectable"; +import { computed } from "mobx"; console = new Console(process.stdout, process.stderr); // fix mockFS @@ -78,6 +80,8 @@ describe("kubeconfig manager tests", () => { await di.runSetups(); + di.override(getProxyPortInjectable, () => computed(() => 9191)); + const createCluster = di.inject(createClusterInjectionToken); createKubeconfigManager = di.inject(createKubeconfigManagerInjectable); @@ -91,8 +95,6 @@ describe("kubeconfig manager tests", () => { cluster.contextHandler = { ensureServer: () => Promise.resolve(), } as any; - - jest.spyOn(KubeconfigManager.prototype, "resolveProxyUrl", "get").mockReturnValue("http://127.0.0.1:9191/foo"); }); afterEach(() => { diff --git a/src/main/__test__/lens-proxy.test.ts b/src/main/__test__/lens-proxy.test.ts index a80d983dec..20af162f17 100644 --- a/src/main/__test__/lens-proxy.test.ts +++ b/src/main/__test__/lens-proxy.test.ts @@ -3,7 +3,7 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import { isLongRunningRequest } from "../lens-proxy"; +import { isLongRunningRequest } from "../lens-proxy/lens-proxy"; describe("isLongRunningRequest", () => { it("returns true on watches", () => { diff --git a/src/main/__test__/router.test.ts b/src/main/__test__/router.test.ts index 134de337a7..8a67437255 100644 --- a/src/main/__test__/router.test.ts +++ b/src/main/__test__/router.test.ts @@ -3,7 +3,7 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import { Router } from "../router"; +import { Router } from "../router/router"; jest.mock("electron", () => ({ app: { diff --git a/src/main/app-updater/check-for-updates.injectable.ts b/src/main/app-updater/check-for-updates.injectable.ts new file mode 100644 index 0000000000..560500d8d7 --- /dev/null +++ b/src/main/app-updater/check-for-updates.injectable.ts @@ -0,0 +1,39 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { autoUpdater } from "electron-updater"; +import { broadcastMessage, AutoUpdateChecking, AutoUpdateLogPrefix } from "../../common/ipc"; +import type { UserPreferencesStore } from "../../common/user-preferences"; +import logger from "../logger"; +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { bind } from "../../common/utils"; +import userPreferencesStoreInjectable from "../../common/user-preferences/store.injectable"; + +interface Dependencies { + userStore: UserPreferencesStore; +} + +async function checkForUpdates({ userStore }: Dependencies): Promise { + try { + logger.info(`📡 Checking for app updates`); + + autoUpdater.channel = userStore.updateChannel; + autoUpdater.allowDowngrade = userStore.isAllowedToDowngrade; + broadcastMessage(AutoUpdateChecking); + await autoUpdater.checkForUpdates(); + } catch (error) { + logger.error(`${AutoUpdateLogPrefix}: failed with an error`, error); + } +} + +const checkForUpdatesInjectable = getInjectable({ + instantiate: (di) => bind(checkForUpdates, null, { + userStore: di.inject(userPreferencesStoreInjectable), + }), + lifecycle: lifecycleEnum.singleton, +}); + +export default checkForUpdatesInjectable; + diff --git a/src/main/app-updater.ts b/src/main/app-updater/start-update-checking.injectable.ts similarity index 79% rename from src/main/app-updater.ts rename to src/main/app-updater/start-update-checking.injectable.ts index e63da9453e..5dcf82be90 100644 --- a/src/main/app-updater.ts +++ b/src/main/app-updater/start-update-checking.injectable.ts @@ -4,14 +4,14 @@ */ import { autoUpdater, UpdateInfo } from "electron-updater"; -import logger from "./logger"; -import { isLinux, isMac, isPublishConfigured, isTestEnv } from "../common/vars"; -import { delay } from "../common/utils"; -import { areArgsUpdateAvailableToBackchannel, AutoUpdateChecking, AutoUpdateLogPrefix, AutoUpdateNoUpdateAvailable, broadcastMessage, onceCorrect, UpdateAvailableChannel, UpdateAvailableToBackchannel } from "../common/ipc"; +import logger from "../logger"; +import { isLinux, isMac, isPublishConfigured, isTestEnv } from "../../common/vars"; +import { delay, bind } from "../../common/utils"; +import { areArgsUpdateAvailableToBackchannel, AutoUpdateLogPrefix, AutoUpdateNoUpdateAvailable, broadcastMessage, onceCorrect, UpdateAvailableChannel, UpdateAvailableToBackchannel } from "../../common/ipc"; import { once } from "lodash"; import { ipcMain, autoUpdater as electronAutoUpdater } from "electron"; -import { nextUpdateChannel } from "./utils/update-channel"; -import { UserStore } from "../common/user-store"; +import { nextUpdateChannel } from "../utils/update-channel"; +import type { UserPreferencesStore } from "../../common/user-preferences/store"; let installVersion: null | string = null; @@ -58,17 +58,20 @@ autoUpdater.logger = { debug: message => logger.debug(`[AUTO-UPDATE]: electron-updater:`, message), }; +interface Dependencies { + userStore: UserPreferencesStore; + checkForUpdates: () => Promise; +} + /** * starts the automatic update checking * @param interval milliseconds between interval to check on, defaults to 24h */ -export const startUpdateChecking = once(function (interval = 1000 * 60 * 60 * 24): void { +const startUpdateChecking = once(function ({ userStore, checkForUpdates }: Dependencies, interval = 1000 * 60 * 60 * 24): void { if (!isAutoUpdateEnabled() || isTestEnv) { return; } - const userStore = UserStore.getInstance(); - autoUpdater.autoDownload = false; autoUpdater.autoInstallOnAppQuit = false; autoUpdater.channel = userStore.updateChannel; @@ -139,17 +142,16 @@ export const startUpdateChecking = once(function (interval = 1000 * 60 * 60 * 24 helper(); }); -export async function checkForUpdates(): Promise { - const userStore = UserStore.getInstance(); +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import checkForUpdatesInjectable from "./check-for-updates.injectable"; +import userPreferencesStoreInjectable from "../../common/user-preferences/store.injectable"; - try { - logger.info(`📡 Checking for app updates`); +const startUpdateCheckingInjectable = getInjectable({ + instantiate: (di) => bind(startUpdateChecking, null, { + checkForUpdates: di.inject(checkForUpdatesInjectable), + userStore: di.inject(userPreferencesStoreInjectable), + }), + lifecycle: lifecycleEnum.singleton, +}); - autoUpdater.channel = userStore.updateChannel; - autoUpdater.allowDowngrade = userStore.isAllowedToDowngrade; - broadcastMessage(AutoUpdateChecking); - await autoUpdater.checkForUpdates(); - } catch (error) { - logger.error(`${AutoUpdateLogPrefix}: failed with an error`, error); - } -} +export default startUpdateCheckingInjectable; diff --git a/src/main/catalog-sources/general.ts b/src/main/catalog-sources/general.ts index 4738f6ac78..bb64ea2ee0 100644 --- a/src/main/catalog-sources/general.ts +++ b/src/main/catalog-sources/general.ts @@ -6,7 +6,6 @@ import { observable } from "mobx"; import { GeneralEntity } from "../../common/catalog-entities/general"; import { catalogURL, preferencesURL, welcomeURL } from "../../common/routes"; -import { catalogEntityRegistry } from "../catalog"; export const catalogEntity = new GeneralEntity({ metadata: { @@ -65,12 +64,8 @@ const welcomePageEntity = new GeneralEntity({ }, }); -const generalEntities = observable([ +export const generalEntities = observable([ catalogEntity, preferencesEntity, welcomePageEntity, ]); - -export function syncGeneralEntities() { - catalogEntityRegistry.addObservableSource("lens:general", generalEntities); -} diff --git a/src/main/catalog-sources/index.ts b/src/main/catalog-sources/index.ts deleted file mode 100644 index c66bbd5a8f..0000000000 --- a/src/main/catalog-sources/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -export { syncWeblinks } from "./weblinks"; -export { syncGeneralEntities } from "./general"; diff --git a/src/main/catalog-sources/kubeconfig-sync-manager/kubeconfig-sync-manager.injectable.ts b/src/main/catalog-sources/kubeconfig-sync-manager/kubeconfig-sync-manager.injectable.ts deleted file mode 100644 index 6560e3854d..0000000000 --- a/src/main/catalog-sources/kubeconfig-sync-manager/kubeconfig-sync-manager.injectable.ts +++ /dev/null @@ -1,19 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ -import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; -import directoryForKubeConfigsInjectable from "../../../common/app-paths/directory-for-kube-configs/directory-for-kube-configs.injectable"; -import { KubeconfigSyncManager } from "./kubeconfig-sync-manager"; -import { createClusterInjectionToken } from "../../../common/cluster/create-cluster-injection-token"; - -const kubeconfigSyncManagerInjectable = getInjectable({ - instantiate: (di) => new KubeconfigSyncManager({ - directoryForKubeConfigs: di.inject(directoryForKubeConfigsInjectable), - createCluster: di.inject(createClusterInjectionToken), - }), - - lifecycle: lifecycleEnum.singleton, -}); - -export default kubeconfigSyncManagerInjectable; diff --git a/src/main/catalog-sources/kubeconfig-sync-manager/kubeconfig-sync-manager.ts b/src/main/catalog-sources/kubeconfig-sync-manager/kubeconfig-sync-manager.ts deleted file mode 100644 index 2986448236..0000000000 --- a/src/main/catalog-sources/kubeconfig-sync-manager/kubeconfig-sync-manager.ts +++ /dev/null @@ -1,376 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import { action, observable, IComputedValue, computed, ObservableMap, runInAction, makeObservable, observe } from "mobx"; -import type { CatalogEntity } from "../../../common/catalog"; -import { catalogEntityRegistry } from "../../catalog"; -import { FSWatcher, watch } from "chokidar"; -import fs from "fs"; -import path from "path"; -import type stream from "stream"; -import { bytesToUnits, Disposer, ExtendedObservableMap, iter, noop } from "../../../common/utils"; -import logger from "../../logger"; -import type { KubeConfig } from "@kubernetes/client-node"; -import { loadConfigFromString, splitConfig } from "../../../common/kube-helpers"; -import { catalogEntityFromCluster, ClusterManager } from "../../cluster-manager"; -import { UserStore } from "../../../common/user-store"; -import { ClusterStore } from "../../../common/cluster-store/cluster-store"; -import { createHash } from "crypto"; -import { homedir } from "os"; -import globToRegExp from "glob-to-regexp"; -import { inspect } from "util"; -import type { ClusterModel, UpdateClusterModel } from "../../../common/cluster-types"; -import type { Cluster } from "../../../common/cluster/cluster"; - -const logPrefix = "[KUBECONFIG-SYNC]:"; - -/** - * This is the list of globs of which files are ignored when under a folder sync - */ -const ignoreGlobs = [ - "*.lock", // kubectl lock files - "*.swp", // vim swap files - ".DS_Store", // macOS specific -].map(rawGlob => ({ - rawGlob, - matcher: globToRegExp(rawGlob), -})); - -/** - * This should be much larger than any kubeconfig text file - * - * Even if you have a cert-file, key-file, and client-cert files that is only - * 12kb of extra data (at 4096 bytes each) which allows for around 150 entries. - */ -const folderSyncMaxAllowedFileReadSize = 2 * 1024 * 1024; // 2 MiB -const fileSyncMaxAllowedFileReadSize = 16 * folderSyncMaxAllowedFileReadSize; // 32 MiB - -interface Dependencies { - directoryForKubeConfigs: string - createCluster: (model: ClusterModel) => Cluster -} - -const kubeConfigSyncName = "lens:kube-sync"; - -export class KubeconfigSyncManager { - protected sources = observable.map, Disposer]>(); - protected syncing = false; - protected syncListDisposer?: Disposer; - - constructor(private dependencies: Dependencies) { - makeObservable(this); - } - - @action - startSync(): void { - if (this.syncing) { - return; - } - - this.syncing = true; - - logger.info(`${logPrefix} starting requested syncs`); - - catalogEntityRegistry.addComputedSource(kubeConfigSyncName, computed(() => ( - Array.from(iter.flatMap( - this.sources.values(), - ([entities]) => entities.get(), - )) - ))); - - // This must be done so that c&p-ed clusters are visible - this.startNewSync(this.dependencies.directoryForKubeConfigs); - - for (const filePath of UserStore.getInstance().syncKubeconfigEntries.keys()) { - this.startNewSync(filePath); - } - - this.syncListDisposer = observe(UserStore.getInstance().syncKubeconfigEntries, change => { - switch (change.type) { - case "add": - this.startNewSync(change.name); - break; - case "delete": - this.stopOldSync(change.name); - break; - } - }); - } - - @action - stopSync() { - this.syncListDisposer?.(); - - for (const filePath of this.sources.keys()) { - this.stopOldSync(filePath); - } - - catalogEntityRegistry.removeSource(kubeConfigSyncName); - this.syncing = false; - } - - @action - protected startNewSync(filePath: string): void { - if (this.sources.has(filePath)) { - // don't start a new sync if we already have one - return void logger.debug(`${logPrefix} already syncing file/folder`, { filePath }); - } - - this.sources.set( - filePath, - watchFileChanges(filePath, this.dependencies), - ); - - logger.info(`${logPrefix} starting sync of file/folder`, { filePath }); - logger.debug(`${logPrefix} ${this.sources.size} files/folders watched`, { files: Array.from(this.sources.keys()) }); - } - - @action - protected stopOldSync(filePath: string): void { - if (!this.sources.delete(filePath)) { - // already stopped - return void logger.debug(`${logPrefix} no syncing file/folder to stop`, { filePath }); - } - - logger.info(`${logPrefix} stopping sync of file/folder`, { filePath }); - logger.debug(`${logPrefix} ${this.sources.size} files/folders watched`, { files: Array.from(this.sources.keys()) }); - } -} - -// exported for testing -export function configToModels(rootConfig: KubeConfig, filePath: string): UpdateClusterModel[] { - const validConfigs = []; - - for (const { config, error } of splitConfig(rootConfig)) { - if (error) { - logger.debug(`${logPrefix} context failed validation: ${error}`, { context: config.currentContext, filePath }); - } else { - validConfigs.push({ - kubeConfigPath: filePath, - contextName: config.currentContext, - }); - } - } - - return validConfigs; -} - -type RootSourceValue = [Cluster, CatalogEntity]; -type RootSource = ObservableMap; - -// exported for testing -export const computeDiff = ({ directoryForKubeConfigs, createCluster }: Dependencies) => (contents: string, source: RootSource, filePath: string): void => { - runInAction(() => { - try { - const { config, error } = loadConfigFromString(contents); - - if (error) { - logger.warn(`${logPrefix} encountered errors while loading config: ${error.message}`, { filePath, details: error.details }); - } - - const rawModels = configToModels(config, filePath); - const models = new Map(rawModels.map(m => [m.contextName, m])); - - logger.debug(`${logPrefix} File now has ${models.size} entries`, { filePath }); - - for (const [contextName, value] of source) { - const model = models.get(contextName); - - // remove and disconnect clusters that were removed from the config - if (!model) { - // remove from the deleting set, so that if a new context of the same name is added, it isn't marked as deleting - ClusterManager.getInstance().deleting.delete(value[0].id); - - value[0].disconnect(); - source.delete(contextName); - logger.debug(`${logPrefix} Removed old cluster from sync`, { filePath, contextName }); - continue; - } - - // TODO: For the update check we need to make sure that the config itself hasn't changed. - // Probably should make it so that cluster keeps a copy of the config in its memory and - // diff against that - - // or update the model and mark it as not needed to be added - value[0].updateModel(model); - models.delete(contextName); - logger.debug(`${logPrefix} Updated old cluster from sync`, { filePath, contextName }); - } - - for (const [contextName, model] of models) { - // add new clusters to the source - try { - const clusterId = createHash("md5").update(`${filePath}:${contextName}`).digest("hex"); - - const cluster = ClusterStore.getInstance().getById(clusterId) || createCluster({ ...model, id: clusterId }); - - if (!cluster.apiUrl) { - throw new Error("Cluster constructor failed, see above error"); - } - - const entity = catalogEntityFromCluster(cluster); - - if (!filePath.startsWith(directoryForKubeConfigs)) { - entity.metadata.labels.file = filePath.replace(homedir(), "~"); - } - source.set(contextName, [cluster, entity]); - - logger.debug(`${logPrefix} Added new cluster from sync`, { filePath, contextName }); - } catch (error) { - logger.warn(`${logPrefix} Failed to create cluster from model: ${error}`, { filePath, contextName }); - } - } - } catch (error) { - console.log(error); - logger.warn(`${logPrefix} Failed to compute diff: ${error}`, { filePath }); - source.clear(); // clear source if we have failed so as to not show outdated information - } - }); -}; - -interface DiffChangedConfigArgs { - filePath: string; - source: RootSource; - stats: fs.Stats; - maxAllowedFileReadSize: number; -} - -const diffChangedConfigFor = (dependencies: Dependencies) => ({ filePath, source, stats, maxAllowedFileReadSize }: DiffChangedConfigArgs): Disposer => { - logger.debug(`${logPrefix} file changed`, { filePath }); - - if (stats.size >= maxAllowedFileReadSize) { - logger.warn(`${logPrefix} skipping ${filePath}: size=${bytesToUnits(stats.size)} is larger than maxSize=${bytesToUnits(maxAllowedFileReadSize)}`); - source.clear(); - - return noop; - } - - // TODO: replace with an AbortController with fs.readFile when we upgrade to Node 16 (after it comes out) - const fileReader = fs.createReadStream(filePath, { - mode: fs.constants.O_RDONLY, - }); - const readStream: stream.Readable = fileReader; - const decoder = new TextDecoder("utf-8", { fatal: true }); - let fileString = ""; - let closed = false; - - const cleanup = () => { - closed = true; - fileReader.close(); // This may not close the stream. - // Artificially marking end-of-stream, as if the underlying resource had - // indicated end-of-file by itself, allows the stream to close. - // This does not cancel pending read operations, and if there is such an - // operation, the process may still not be able to exit successfully - // until it finishes. - fileReader.push(null); - fileReader.read(0); - readStream.removeAllListeners(); - }; - - readStream - .on("data", (chunk: Buffer) => { - try { - fileString += decoder.decode(chunk, { stream: true }); - } catch (error) { - logger.warn(`${logPrefix} skipping ${filePath}: ${error}`); - source.clear(); - cleanup(); - } - }) - .on("close", () => cleanup()) - .on("error", error => { - cleanup(); - logger.warn(`${logPrefix} failed to read file: ${error}`, { filePath }); - }) - .on("end", () => { - if (!closed) { - computeDiff(dependencies)(fileString, source, filePath); - } - }); - - return cleanup; -}; - -const watchFileChanges = (filePath: string, dependencies: Dependencies): [IComputedValue, Disposer] => { - const rootSource = new ExtendedObservableMap>(); - const derivedSource = computed(() => Array.from(iter.flatMap(rootSource.values(), from => iter.map(from.values(), child => child[1])))); - - let watcher: FSWatcher; - - (async () => { - try { - const stat = await fs.promises.stat(filePath); - const isFolderSync = stat.isDirectory(); - const cleanupFns = new Map(); - const maxAllowedFileReadSize = isFolderSync - ? folderSyncMaxAllowedFileReadSize - : fileSyncMaxAllowedFileReadSize; - - watcher = watch(filePath, { - followSymlinks: true, - depth: isFolderSync ? 0 : 1, // DIRs works with 0 but files need 1 (bug: https://github.com/paulmillr/chokidar/issues/1095) - disableGlobbing: true, - ignorePermissionErrors: true, - usePolling: false, - awaitWriteFinish: { - pollInterval: 100, - stabilityThreshold: 1000, - }, - atomic: 150, // for "atomic writes" - }); - - const diffChangedConfig = diffChangedConfigFor(dependencies); - - watcher - .on("change", (childFilePath, stats) => { - const cleanup = cleanupFns.get(childFilePath); - - if (!cleanup) { - // file was previously ignored, do nothing - return void logger.debug(`${logPrefix} ${inspect(childFilePath)} that should have been previously ignored has changed. Doing nothing`); - } - - cleanup(); - cleanupFns.set(childFilePath, diffChangedConfig({ - filePath: childFilePath, - source: rootSource.getOrInsert(childFilePath, observable.map), - stats, - maxAllowedFileReadSize, - })); - }) - .on("add", (childFilePath, stats) => { - if (isFolderSync) { - const fileName = path.basename(childFilePath); - - for (const ignoreGlob of ignoreGlobs) { - if (ignoreGlob.matcher.test(fileName)) { - return void logger.info(`${logPrefix} ignoring ${inspect(childFilePath)} due to ignore glob: ${ignoreGlob.rawGlob}`); - } - } - } - - cleanupFns.set(childFilePath, diffChangedConfig({ - filePath: childFilePath, - source: rootSource.getOrInsert(childFilePath, observable.map), - stats, - maxAllowedFileReadSize, - })); - }) - .on("unlink", (childFilePath) => { - cleanupFns.get(childFilePath)?.(); - cleanupFns.delete(childFilePath); - rootSource.delete(childFilePath); - }) - .on("error", error => logger.error(`${logPrefix} watching file/folder failed: ${error}`, { filePath })); - } catch (error) { - console.log(error.stack); - logger.warn(`${logPrefix} failed to start watching changes: ${error}`); - } - })(); - - return [derivedSource, () => { - watcher?.close(); - }]; -}; diff --git a/src/main/catalog-sources/__test__/kubeconfig-sync.test.ts b/src/main/catalog-sources/kubeconfig-sync/__test__/kubeconfig-sync.test.ts similarity index 79% rename from src/main/catalog-sources/__test__/kubeconfig-sync.test.ts rename to src/main/catalog-sources/kubeconfig-sync/__test__/kubeconfig-sync.test.ts index f04458c3c8..ccb85b3f2d 100644 --- a/src/main/catalog-sources/__test__/kubeconfig-sync.test.ts +++ b/src/main/catalog-sources/kubeconfig-sync/__test__/kubeconfig-sync.test.ts @@ -4,18 +4,20 @@ */ import { ObservableMap } from "mobx"; -import type { CatalogEntity } from "../../../common/catalog"; -import { loadFromOptions } from "../../../common/kube-helpers"; -import type { Cluster } from "../../../common/cluster/cluster"; -import { computeDiff as computeDiffFor, configToModels } from "../kubeconfig-sync-manager/kubeconfig-sync-manager"; +import type { CatalogEntity } from "../../../../common/catalog"; +import { loadFromOptions } from "../../../../common/kube-helpers"; +import type { Cluster } from "../../../../common/cluster/cluster"; import mockFs from "mock-fs"; import fs from "fs"; -import { ClusterManager } from "../../cluster-manager"; -import clusterStoreInjectable from "../../../common/cluster-store/cluster-store.injectable"; -import { getDiForUnitTesting } from "../../getDiForUnitTesting"; -import { createClusterInjectionToken } from "../../../common/cluster/create-cluster-injection-token"; -import directoryForKubeConfigsInjectable - from "../../../common/app-paths/directory-for-kube-configs/directory-for-kube-configs.injectable"; +import { getDiForUnitTesting } from "../../../getDiForUnitTesting"; +import type { ComputeDiffArguments } from "../compute-diff.injectable"; +import computeDiffInjectable from "../compute-diff.injectable"; +import type { KubeConfig } from "@kubernetes/client-node"; +import { noop } from "lodash"; +import type { UpdateClusterModel } from "../../../../common/cluster-types"; +import configToModelsInjectable from "../config-to-models.injectable"; +import removeFromDeletingInjectable from "../../../cluster-manager/remove-from-deleting.injectable"; +import kubeconfigSyncLoggerInjectable from "../logger.injectable"; jest.mock("electron", () => ({ @@ -35,7 +37,8 @@ jest.mock("electron", () => ({ })); describe("kubeconfig-sync.source tests", () => { - let computeDiff: ReturnType; + let computeDiff: (args: ComputeDiffArguments) => void; + let configToModels: (rootConfig: KubeConfig, filePath: string) => UpdateClusterModel[]; beforeEach(async () => { const di = getDiForUnitTesting({ doGeneralOverrides: true }); @@ -44,19 +47,15 @@ describe("kubeconfig-sync.source tests", () => { await di.runSetups(); - computeDiff = computeDiffFor({ - directoryForKubeConfigs: di.inject(directoryForKubeConfigsInjectable), - createCluster: di.inject(createClusterInjectionToken), - }); + di.override(kubeconfigSyncLoggerInjectable, () => createNullLogger()); + di.override(removeFromDeletingInjectable, () => noop); - di.inject(clusterStoreInjectable); - - ClusterManager.createInstance(); + computeDiff = di.inject(computeDiffInjectable); + configToModels = di.inject(configToModelsInjectable); }); afterEach(() => { mockFs.restore(); - ClusterManager.resetInstance(); }); describe("configsToModels", () => { @@ -103,7 +102,7 @@ describe("kubeconfig-sync.source tests", () => { const rootSource = new ObservableMap(); const filePath = "/bar"; - computeDiff(contents, rootSource, filePath); + computeDiff({ contents, source: rootSource, filePath }); expect(rootSource.size).toBe(0); }); @@ -140,7 +139,7 @@ describe("kubeconfig-sync.source tests", () => { fs.writeFileSync(filePath, contents); - computeDiff(contents, rootSource, filePath); + computeDiff({ contents, source: rootSource, filePath }); expect(rootSource.size).toBe(1); @@ -183,7 +182,7 @@ describe("kubeconfig-sync.source tests", () => { fs.writeFileSync(filePath, contents); - computeDiff(contents, rootSource, filePath); + computeDiff({ contents, source: rootSource, filePath }); expect(rootSource.size).toBe(1); @@ -192,7 +191,7 @@ describe("kubeconfig-sync.source tests", () => { expect(c.kubeConfigPath).toBe("/bar"); expect(c.contextName).toBe("context-name"); - computeDiff("{}", rootSource, filePath); + computeDiff({ contents: "{}", source: rootSource, filePath }); expect(rootSource.size).toBe(0); }); @@ -237,7 +236,7 @@ describe("kubeconfig-sync.source tests", () => { fs.writeFileSync(filePath, contents); - computeDiff(contents, rootSource, filePath); + computeDiff({ contents, source: rootSource, filePath }); expect(rootSource.size).toBe(2); @@ -277,7 +276,7 @@ describe("kubeconfig-sync.source tests", () => { currentContext: "foobar", }); - computeDiff(newContents, rootSource, filePath); + computeDiff({ contents: newContents, source: rootSource, filePath }); expect(rootSource.size).toBe(1); @@ -290,3 +289,8 @@ describe("kubeconfig-sync.source tests", () => { }); }); }); + +function createNullLogger(): import("../../../../common/logger").LensLogger { + throw new Error("Function not implemented."); +} + diff --git a/src/main/catalog-sources/kubeconfig-sync/compute-diff.injectable.ts b/src/main/catalog-sources/kubeconfig-sync/compute-diff.injectable.ts new file mode 100644 index 0000000000..6675199526 --- /dev/null +++ b/src/main/catalog-sources/kubeconfig-sync/compute-diff.injectable.ts @@ -0,0 +1,122 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import type { KubeConfig } from "@kubernetes/client-node"; +import { createHash } from "crypto"; +import { action, ObservableMap } from "mobx"; +import { homedir } from "os"; +import type { CatalogEntity } from "../../../common/catalog"; +import type { ClusterModel, UpdateClusterModel } from "../../../common/cluster-types"; +import type { Cluster } from "../../../common/cluster/cluster"; +import { loadConfigFromString } from "../../../common/kube-helpers"; +import type { LensLogger } from "../../../common/logger"; +import { catalogEntityFromCluster } from "../../cluster-manager/cluster-manager"; +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { bind } from "../../../common/utils"; +import directoryForKubeConfigsInjectable from "../../../common/app-paths/directory-for-kube-configs.injectable"; +import kubeconfigSyncLoggerInjectable from "./logger.injectable"; +import configToModelsInjectable from "./config-to-models.injectable"; +import createClusterInjectable from "../../create-cluster/create-cluster.injectable"; +import getClusterByIdInjectable from "../../../common/cluster-store/get-cluster-by-id.injectable"; +import removeFromDeletingInjectable from "../../cluster-manager/remove-from-deleting.injectable"; + +export interface ComputeDiffArguments { + contents: string; + source: ObservableMap; + filePath: string; +} + +export interface ComputeDiffDependencies { + removeFromDeleting: (clusterId: string) => void; + getClusterById: (clusterId: string) => Cluster; + createCluster: (model: ClusterModel) => Cluster; + directoryForKubeConfigs: string; + logger: LensLogger; + configToModels: (rootConfig: KubeConfig, filePath: string) => UpdateClusterModel[]; +} + +const computeDiff = action((deps: ComputeDiffDependencies, args: ComputeDiffArguments): void => { + const { contents, source, filePath } = args; + const { removeFromDeleting, getClusterById, createCluster, directoryForKubeConfigs, configToModels, logger } = deps; + + try { + const { config, error } = loadConfigFromString(contents); + + if (error) { + logger.warn(`encountered errors while loading config: ${error.message}`, { filePath, details: error.details }); + } + + const rawModels = configToModels(config, filePath); + const models = new Map(rawModels.map(m => [m.contextName, m])); + + logger.debug(`File now has ${models.size} entries`, { filePath }); + + for (const [contextName, value] of source) { + const model = models.get(contextName); + + // remove and disconnect clusters that were removed from the config + if (!model) { + // remove from the deleting set, so that if a new context of the same name is added, it isn't marked as deleting + removeFromDeleting(value[0].id); + + value[0].disconnect(); + source.delete(contextName); + logger.debug(`Removed old cluster from sync`, { filePath, contextName }); + continue; + } + + // TODO: For the update check we need to make sure that the config itself hasn't changed. + // Probably should make it so that cluster keeps a copy of the config in its memory and + // diff against that + + // or update the model and mark it as not needed to be added + value[0].updateModel(model); + models.delete(contextName); + logger.debug(`Updated old cluster from sync`, { filePath, contextName }); + } + + for (const [contextName, model] of models) { + // add new clusters to the source + try { + const clusterId = createHash("md5").update(`${filePath}:${contextName}`).digest("hex"); + + const cluster = getClusterById(clusterId) || createCluster({ ...model, id: clusterId }); + + if (!cluster.apiUrl) { + throw new Error("Cluster constructor failed, see above error"); + } + + const entity = catalogEntityFromCluster(cluster); + + if (!filePath.startsWith(directoryForKubeConfigs)) { + entity.metadata.labels.file = filePath.replace(homedir(), "~"); + } + source.set(contextName, [cluster, entity]); + + logger.debug(`Added new cluster from sync`, { filePath, contextName }); + } catch (error) { + logger.warn(`Failed to create cluster from model: ${error}`, { filePath, contextName }); + } + } + } catch (error) { + console.log(error); + logger.warn(`Failed to compute diff: ${error}`, { filePath }); + source.clear(); // clear source if we have failed so as to not show outdated information + } +}); + +const computeDiffInjectable = getInjectable({ + instantiate: (di) => bind(computeDiff, null, { + directoryForKubeConfigs: di.inject(directoryForKubeConfigsInjectable), + logger: di.inject(kubeconfigSyncLoggerInjectable), + configToModels: di.inject(configToModelsInjectable), + createCluster: di.inject(createClusterInjectable), + getClusterById: di.inject(getClusterByIdInjectable), + removeFromDeleting: di.inject(removeFromDeletingInjectable), + }), + lifecycle: lifecycleEnum.singleton, +}); + +export default computeDiffInjectable; + diff --git a/src/main/catalog-sources/kubeconfig-sync/config-to-models.injectable.ts b/src/main/catalog-sources/kubeconfig-sync/config-to-models.injectable.ts new file mode 100644 index 0000000000..8f94b6b771 --- /dev/null +++ b/src/main/catalog-sources/kubeconfig-sync/config-to-models.injectable.ts @@ -0,0 +1,43 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import type { KubeConfig } from "@kubernetes/client-node"; +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import type { UpdateClusterModel } from "../../../common/cluster-types"; +import { splitConfig } from "../../../common/kube-helpers"; +import type { LensLogger } from "../../../common/logger"; +import { bind } from "../../../common/utils"; +import kubeconfigSyncLoggerInjectable from "./logger.injectable"; + +interface Dependencies { + logger: LensLogger; +} + +function configToModels(deps: Dependencies, rootConfig: KubeConfig, filePath: string): UpdateClusterModel[] { + const { logger } = deps; + const validConfigs = []; + + for (const { config, error } of splitConfig(rootConfig)) { + if (error) { + logger.debug(`context failed validation: ${error}`, { context: config.currentContext, filePath }); + } else { + validConfigs.push({ + kubeConfigPath: filePath, + contextName: config.currentContext, + }); + } + } + + return validConfigs; +} + + +const configToModelsInjectable = getInjectable({ + instantiate: (di) => bind(configToModels, null, { + logger: di.inject(kubeconfigSyncLoggerInjectable), + }), + lifecycle: lifecycleEnum.singleton, +}); + +export default configToModelsInjectable; diff --git a/src/main/catalog-sources/kubeconfig-sync/diff-changed-config.injectable.ts b/src/main/catalog-sources/kubeconfig-sync/diff-changed-config.injectable.ts new file mode 100644 index 0000000000..17cecb6871 --- /dev/null +++ b/src/main/catalog-sources/kubeconfig-sync/diff-changed-config.injectable.ts @@ -0,0 +1,100 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import fs from "fs"; +import { noop } from "lodash"; +import type { ObservableMap } from "mobx"; +import type stream from "stream"; +import type { CatalogEntity } from "../../../common/catalog"; +import type { Cluster } from "../../../common/cluster/cluster"; +import fsInjectable from "../../../common/fs/fs.injectable"; +import type { LensLogger } from "../../../common/logger"; +import { type Disposer, bytesToUnits, bind } from "../../../common/utils"; +import type { ComputeDiffArguments } from "./compute-diff.injectable"; +import computeDiffInjectable from "./compute-diff.injectable"; +import kubeconfigSyncLoggerInjectable from "./logger.injectable"; + +export interface DiffChangedConfigArgs { + filePath: string; + source: ObservableMap; + stats: fs.Stats; + maxAllowedFileReadSize: number; +} + +export interface DiffChangedConfigDependencies { + readonly logger: LensLogger; + fsCreateReadStream: (filePath: string, opts?: { mode?: number }) => fs.ReadStream; + computeDiff: (args: ComputeDiffArguments) => void; +} + +function diffChangedConfig(deps: DiffChangedConfigDependencies, args: DiffChangedConfigArgs): Disposer { + const { fsCreateReadStream, logger, computeDiff } = deps; + const { filePath, source, stats, maxAllowedFileReadSize } = args; + + logger.debug(`file changed`, { filePath }); + + if (stats.size >= maxAllowedFileReadSize) { + logger.warn(`skipping ${filePath}: size=${bytesToUnits(stats.size)} is larger than maxSize=${bytesToUnits(maxAllowedFileReadSize)}`); + source.clear(); + + return noop; + } + + // TODO: replace with an AbortController with fs.readFile when we upgrade to Node 16 (after it comes out) + const fileReader = fsCreateReadStream(filePath, { + mode: fs.constants.O_RDONLY, + }); + const readStream: stream.Readable = fileReader; + const decoder = new TextDecoder("utf-8", { fatal: true }); + let fileString = ""; + let closed = false; + + const cleanup = () => { + closed = true; + fileReader.close(); // This may not close the stream. + // Artificially marking end-of-stream, as if the underlying resource had + // indicated end-of-file by itself, allows the stream to close. + // This does not cancel pending read operations, and if there is such an + // operation, the process may still not be able to exit successfully + // until it finishes. + fileReader.push(null); + fileReader.read(0); + readStream.removeAllListeners(); + }; + + readStream + .on("data", (chunk: Buffer) => { + try { + fileString += decoder.decode(chunk, { stream: true }); + } catch (error) { + logger.warn(`skipping ${filePath}: ${error}`); + source.clear(); + cleanup(); + } + }) + .on("close", () => cleanup()) + .on("error", error => { + cleanup(); + logger.warn(`failed to read file: ${error}`, { filePath }); + }) + .on("end", () => { + if (!closed) { + computeDiff({ contents: fileString, source, filePath }); + } + }); + + return cleanup; +} + +const diffChangedConfigInjectable = getInjectable({ + instantiate: (di) => bind(diffChangedConfig, null, { + computeDiff: di.inject(computeDiffInjectable), + fsCreateReadStream: di.inject(fsInjectable).createReadStream, + logger: di.inject(kubeconfigSyncLoggerInjectable), + }), + lifecycle: lifecycleEnum.singleton, +}); + +export default diffChangedConfigInjectable; diff --git a/src/main/catalog-sources/kubeconfig-sync/logger.injectable.ts b/src/main/catalog-sources/kubeconfig-sync/logger.injectable.ts new file mode 100644 index 0000000000..ee4071def4 --- /dev/null +++ b/src/main/catalog-sources/kubeconfig-sync/logger.injectable.ts @@ -0,0 +1,13 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import createPrefixedLoggerInjectable from "../../../common/logger/create-prefixed-logger.injectable"; + +const kubeconfigSyncLoggerInjectable = getInjectable({ + instantiate: (di) => di.inject(createPrefixedLoggerInjectable)("[KUBECONFIG-SYNC]:"), + lifecycle: lifecycleEnum.singleton, +}); + +export default kubeconfigSyncLoggerInjectable; diff --git a/src/main/catalog-sources/kubeconfig-sync/manager.injectable.ts b/src/main/catalog-sources/kubeconfig-sync/manager.injectable.ts new file mode 100644 index 0000000000..bd7c001c73 --- /dev/null +++ b/src/main/catalog-sources/kubeconfig-sync/manager.injectable.ts @@ -0,0 +1,24 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import directoryForKubeConfigsInjectable from "../../../common/app-paths/directory-for-kube-configs.injectable"; +import kubeconfigSyncEntriesInjectable from "../../../common/user-preferences/kubeconfig-sync-entries.injectable"; +import addComputedEntitySourceInjectable from "../../catalog/add-computed-entity-source.injectable"; +import kubeconfigSyncLoggerInjectable from "./logger.injectable"; +import { KubeconfigSyncManager } from "./manager"; +import watchFileChangesInjectable from "./watch-file-changes.injectable"; + +const kubeconfigSyncManagerInjectable = getInjectable({ + instantiate: (di) => new KubeconfigSyncManager({ + addComputedEntitySource: di.inject(addComputedEntitySourceInjectable), + directoryForKubeConfigs: di.inject(directoryForKubeConfigsInjectable), + kubeconfigSyncEntries: di.inject(kubeconfigSyncEntriesInjectable), + watchFileChanges: di.inject(watchFileChangesInjectable), + logger: di.inject(kubeconfigSyncLoggerInjectable), + }), + lifecycle: lifecycleEnum.singleton, +}); + +export default kubeconfigSyncManagerInjectable; diff --git a/src/main/catalog-sources/kubeconfig-sync/manager.ts b/src/main/catalog-sources/kubeconfig-sync/manager.ts new file mode 100644 index 0000000000..e10a237fc5 --- /dev/null +++ b/src/main/catalog-sources/kubeconfig-sync/manager.ts @@ -0,0 +1,99 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { action, observable, IComputedValue, computed, ObservableMap, makeObservable, observe } from "mobx"; +import type { CatalogEntity } from "../../../common/catalog"; +import { Disposer, iter } from "../../../common/utils"; +import type { KubeconfigSyncValue } from "../../../common/user-preferences"; +import type { LensLogger } from "../../../common/logger"; + +export interface KubeconfigSyncManagerDependencies { + addComputedEntitySource: (source: IComputedValue) => Disposer; + watchFileChanges: (filePath: string) => [IComputedValue, Disposer]; + readonly directoryForKubeConfigs: string; + readonly kubeconfigSyncEntries: ObservableMap; + readonly logger: LensLogger; +} + +export class KubeconfigSyncManager { + protected sources = observable.map, Disposer]>(); + protected syncing = false; + protected syncListDisposer?: Disposer; + protected stopEntitySync?: Disposer; + + constructor(protected readonly dependencies: KubeconfigSyncManagerDependencies) { + makeObservable(this); + } + + @action + startSync(): void { + if (this.syncing) { + return; + } + + this.syncing = true; + + this.dependencies.logger.info(`starting requested syncs`); + + this.stopEntitySync = this.dependencies.addComputedEntitySource(computed(() => ( + Array.from(iter.flatMap( + this.sources.values(), + ([entities]) => entities.get(), + )) + ))); + + // This must be done so that c&p-ed clusters are visible + this.startNewSync(this.dependencies.directoryForKubeConfigs); + + for (const filePath of this.dependencies.kubeconfigSyncEntries.keys()) { + this.startNewSync(filePath); + } + + this.syncListDisposer = observe(this.dependencies.kubeconfigSyncEntries, change => { + switch (change.type) { + case "add": + this.startNewSync(change.name); + break; + case "delete": + this.stopOldSync(change.name); + break; + } + }); + } + + @action + stopSync() { + this.stopEntitySync?.(); + this.syncListDisposer?.(); + + for (const filePath of this.sources.keys()) { + this.stopOldSync(filePath); + } + + this.syncing = false; + } + + @action + protected startNewSync(filePath: string): void { + if (this.sources.has(filePath)) { + // don't start a new sync if we already have one + return void this.dependencies.logger.debug(`already syncing file/folder`, { filePath }); + } + + this.sources.set(filePath, this.dependencies.watchFileChanges(filePath)); + this.dependencies.logger.info(`starting sync of file/folder`, { filePath }); + this.dependencies.logger.debug(`${this.sources.size} files/folders watched`, { files: Array.from(this.sources.keys()) }); + } + + @action + protected stopOldSync(filePath: string): void { + if (!this.sources.delete(filePath)) { + // already stopped + return void this.dependencies.logger.debug(`no syncing file/folder to stop`, { filePath }); + } + + this.dependencies.logger.info(`stopping sync of file/folder`, { filePath }); + this.dependencies.logger.debug(`${this.sources.size} files/folders watched`, { files: Array.from(this.sources.keys()) }); + } +} diff --git a/src/main/catalog-sources/kubeconfig-sync/watch-file-changes.injectable.ts b/src/main/catalog-sources/kubeconfig-sync/watch-file-changes.injectable.ts new file mode 100644 index 0000000000..6d39972d51 --- /dev/null +++ b/src/main/catalog-sources/kubeconfig-sync/watch-file-changes.injectable.ts @@ -0,0 +1,144 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { type IComputedValue, ObservableMap, computed, observable } from "mobx"; +import { inspect } from "util"; +import type { CatalogEntity } from "../../../common/catalog"; +import type { Cluster } from "../../../common/cluster/cluster"; +import { type Disposer, iter, bind, getOrInsert } from "../../../renderer/utils"; +import type fs from "fs"; +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import type { FSWatcher, WatchOptions } from "chokidar"; +import type { LensLogger } from "../../../common/logger"; +import globToRegExp from "glob-to-regexp"; +import path from "path"; +import type { DiffChangedConfigArgs } from "./diff-changed-config.injectable"; +import diffChangedConfigInjectable from "./diff-changed-config.injectable"; +import fsInjectable from "../../../common/fs/fs.injectable"; +import kubeconfigSyncLoggerInjectable from "./logger.injectable"; +import watchFilePathInjectable from "../../../common/fs/watch-file-path.injectable"; + +/** + * This is the list of globs of which files are ignored when under a folder sync + */ +const ignoreGlobs = [ + "*.lock", // kubectl lock files + "*.swp", // vim swap files + ".DS_Store", // macOS specific +].map(rawGlob => ({ + rawGlob, + matcher: globToRegExp(rawGlob), +})); + +/** + * This should be much larger than any kubeconfig text file + * + * Even if you have a cert-file, key-file, and client-cert files that is only + * 12kb of extra data (at 4096 bytes each) which allows for around 150 entries. + */ +const folderSyncMaxAllowedFileReadSize = 2 * 1024 * 1024; // 2 MiB +const fileSyncMaxAllowedFileReadSize = 16 * folderSyncMaxAllowedFileReadSize; // 32 MiB + +export interface WatchFileChangesDependencies { + fsStat: (filePath: string) => Promise; + watchFilePath: (filePath: string, options?: WatchOptions) => FSWatcher; + diffChangedConfig: (args: DiffChangedConfigArgs) => Disposer; + readonly logger: LensLogger; +} + +// Exported for testing +function watchFileChanges(deps: WatchFileChangesDependencies, filePath: string): [IComputedValue, Disposer] { + const { fsStat, watchFilePath, logger, diffChangedConfig } = deps; + const rootSource = new ObservableMap>(); + const derivedSource = computed(() => Array.from(iter.flatMap(rootSource.values(), from => iter.map(from.values(), child => child[1])))); + + let watcher: FSWatcher; + + (async () => { + try { + const stat = await fsStat(filePath); + const isFolderSync = stat.isDirectory(); + const cleanupFns = new Map(); + const maxAllowedFileReadSize = isFolderSync + ? folderSyncMaxAllowedFileReadSize + : fileSyncMaxAllowedFileReadSize; + + watcher = watchFilePath(filePath, { + followSymlinks: true, + depth: isFolderSync ? 0 : 1, // DIRs works with 0 but files need 1 (bug: https://github.com/paulmillr/chokidar/issues/1095) + disableGlobbing: true, + ignorePermissionErrors: true, + usePolling: false, + awaitWriteFinish: { + pollInterval: 100, + stabilityThreshold: 1000, + }, + atomic: 150, // for "atomic writes" + }); + + watcher + .on("change", (childFilePath, stats) => { + const cleanup = cleanupFns.get(childFilePath); + + if (!cleanup) { + // file was previously ignored, do nothing + return void logger.debug(`${inspect(childFilePath)} that should have been previously ignored has changed. Doing nothing`); + } + + cleanup(); + cleanupFns.set(childFilePath, diffChangedConfig({ + filePath: childFilePath, + source: getOrInsert(rootSource, childFilePath, observable.map()), + stats, + maxAllowedFileReadSize, + })); + }) + .on("add", (childFilePath, stats) => { + if (isFolderSync) { + const fileName = path.basename(childFilePath); + + for (const ignoreGlob of ignoreGlobs) { + if (ignoreGlob.matcher.test(fileName)) { + return void logger.info(`ignoring ${inspect(childFilePath)} due to ignore glob: ${ignoreGlob.rawGlob}`); + } + } + } + + cleanupFns.set(childFilePath, diffChangedConfig({ + filePath: childFilePath, + source: getOrInsert(rootSource, childFilePath, observable.map()), + stats, + maxAllowedFileReadSize, + })); + }) + .on("unlink", (childFilePath) => { + cleanupFns.get(childFilePath)?.(); + cleanupFns.delete(childFilePath); + rootSource.delete(childFilePath); + }) + .on("error", error => logger.error(`watching file/folder failed: ${error}`, { filePath })); + } catch (error) { + console.log(error.stack); + logger.warn(`failed to start watching changes: ${error}`); + } + })(); + + return [derivedSource, () => { + watcher?.close(); + }]; +} + +const watchFileChangesInjectable = getInjectable({ + instantiate: (di) => bind(watchFileChanges, null, { + diffChangedConfig: di.inject(diffChangedConfigInjectable), + fsStat: di.inject(fsInjectable).stat, + logger: di.inject(kubeconfigSyncLoggerInjectable), + watchFilePath: di.inject(watchFilePathInjectable), + }), + lifecycle: lifecycleEnum.singleton, +}); + +export default watchFileChangesInjectable; + diff --git a/src/main/catalog-sources/weblinks.ts b/src/main/catalog-sources/weblinks-source.injectable.ts similarity index 75% rename from src/main/catalog-sources/weblinks.ts rename to src/main/catalog-sources/weblinks-source.injectable.ts index 71979332db..e6007f3289 100644 --- a/src/main/catalog-sources/weblinks.ts +++ b/src/main/catalog-sources/weblinks-source.injectable.ts @@ -4,12 +4,13 @@ */ import { computed, observable, reaction } from "mobx"; -import { WeblinkStore } from "../../common/weblink-store"; +import type { WeblinkStore } from "../../common/weblinks/store"; import { WebLink } from "../../common/catalog-entities"; -import { catalogEntityRegistry } from "../catalog"; import got from "got"; -import type { Disposer } from "../../common/utils"; +import { bind, Disposer } from "../../common/utils"; import { random } from "lodash"; +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import weblinksStoreInjectable from "../../common/weblinks/store.injectable"; async function validateLink(link: WebLink) { try { @@ -28,9 +29,11 @@ async function validateLink(link: WebLink) { } } +interface Dependencies { + weblinkStore: WeblinkStore; +} -export function syncWeblinks() { - const weblinkStore = WeblinkStore.getInstance(); +function getWeblinksSource({ weblinkStore }: Dependencies) { const webLinkEntities = observable.map(); function periodicallyCheckLink(link: WebLink): Disposer { @@ -86,5 +89,15 @@ export function syncWeblinks() { } }, { fireImmediately: true }); - catalogEntityRegistry.addComputedSource("weblinks", computed(() => Array.from(webLinkEntities.values(), ([link]) => link))); + return computed(() => Array.from(webLinkEntities.values(), ([link]) => link)); } + +const getWeblinksSourceInjectable = getInjectable({ + instantiate: (di) => bind(getWeblinksSource, null, { + weblinkStore: di.inject(weblinksStoreInjectable), + }), + lifecycle: lifecycleEnum.singleton, +}); + +export default getWeblinksSourceInjectable; + diff --git a/src/main/catalog/__tests__/catalog-entity-registry.test.ts b/src/main/catalog/__tests__/catalog-entity-registry.test.ts index 29409a9ecf..89e5661ff2 100644 --- a/src/main/catalog/__tests__/catalog-entity-registry.test.ts +++ b/src/main/catalog/__tests__/catalog-entity-registry.test.ts @@ -5,70 +5,61 @@ import { observable, reaction } from "mobx"; import { WebLink, WebLinkSpec, WebLinkStatus } from "../../../common/catalog-entities"; -import { catalogCategoryRegistry, CatalogEntity, CatalogEntityMetadata } from "../../../common/catalog"; -import { CatalogEntityRegistry } from "../catalog-entity-registry"; +import { CatalogEntity, CatalogEntityMetadata } from "../../../common/catalog"; +import type { CatalogEntityRegistry } from "../catalog-entity-registry"; +import type { ConfigurableDependencyInjectionContainer } from "@ogre-tools/injectable"; +import { getDiForUnitTesting } from "../../getDiForUnitTesting"; +import catalogEntityRegistryInjectable from "../entity-registry.injectable"; class InvalidEntity extends CatalogEntity { public readonly apiVersion = "entity.k8slens.dev/v1alpha1"; public readonly kind = "Invalid"; - - async onRun() { - return; - } - - public onSettingsOpen(): void { - return; - } - - public onDetailsOpen(): void { - return; - } - - public onContextMenuOpen(): void { - return; - } } +const entity = new WebLink({ + metadata: { + uid: "test", + name: "test-link", + source: "test", + labels: {}, + }, + spec: { + url: "https://k8slens.dev", + }, + status: { + phase: "available", + }, +}); +const invalidEntity = new InvalidEntity({ + metadata: { + uid: "invalid", + name: "test-link", + source: "test", + labels: {}, + }, + spec: { + url: "https://k8slens.dev", + }, + status: { + phase: "available", + }, +}); + describe("CatalogEntityRegistry", () => { + let di: ConfigurableDependencyInjectionContainer; let registry: CatalogEntityRegistry; - const entity = new WebLink({ - metadata: { - uid: "test", - name: "test-link", - source: "test", - labels: {}, - }, - spec: { - url: "https://k8slens.dev", - }, - status: { - phase: "available", - }, - }); - const invalidEntity = new InvalidEntity({ - metadata: { - uid: "invalid", - name: "test-link", - source: "test", - labels: {}, - }, - spec: { - url: "https://k8slens.dev", - }, - status: { - phase: "available", - }, - }); beforeEach(() => { - registry = new CatalogEntityRegistry(catalogCategoryRegistry); + di = getDiForUnitTesting(); + + registry = di.inject(catalogEntityRegistryInjectable); }); describe("addSource", () => { it ("allows to add an observable source", () => { const source = observable.array([]); - registry.addObservableSource("test", source); + registry.addObservableSource(source); expect(registry.items.length).toEqual(0); source.push(entity); @@ -79,7 +70,7 @@ describe("CatalogEntityRegistry", () => { it ("added source change triggers reaction", (done) => { const source = observable.array([]); - registry.addObservableSource("test", source); + registry.addObservableSource(source); reaction(() => registry.items, () => { done(); }); @@ -91,10 +82,10 @@ describe("CatalogEntityRegistry", () => { describe("removeSource", () => { it ("removes source", () => { const source = observable.array([]); + const remove = registry.addObservableSource(source); - registry.addObservableSource("test", source); source.push(entity); - registry.removeSource("test"); + remove(); expect(registry.items.length).toEqual(0); }); @@ -106,15 +97,15 @@ describe("CatalogEntityRegistry", () => { const source = observable.array([entity]); - registry.addObservableSource("test", source); + registry.addObservableSource(source); expect(registry.items.length).toBe(1); }); - it("does not return items without matching category", () => { + it("throws if you try to add an entity that doesn't have a matching category", () => { const source = observable.array([invalidEntity]); - registry.addObservableSource("test", source); - expect(registry.items.length).toBe(0); + registry.addObservableSource(source); + expect(() => registry.items).toThrowError("Unable to find a category for group=entity.k8slens.dev kind=Invalid"); }); }); }); diff --git a/src/main/catalog/add-computed-entity-source.injectable.ts b/src/main/catalog/add-computed-entity-source.injectable.ts new file mode 100644 index 0000000000..602debacda --- /dev/null +++ b/src/main/catalog/add-computed-entity-source.injectable.ts @@ -0,0 +1,13 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import catalogEntityRegistryInjectable from "./entity-registry.injectable"; + +const addComputedEntitySourceInjectable = getInjectable({ + instantiate: (di) => di.inject(catalogEntityRegistryInjectable).addComputedSource, + lifecycle: lifecycleEnum.singleton, +}); + +export default addComputedEntitySourceInjectable; diff --git a/src/main/catalog/catalog-entity-registry.ts b/src/main/catalog/catalog-entity-registry.ts index 65f60d8e4e..e580bd83b7 100644 --- a/src/main/catalog/catalog-entity-registry.ts +++ b/src/main/catalog/catalog-entity-registry.ts @@ -3,49 +3,54 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import { action, computed, IComputedValue, IObservableArray, makeObservable, observable } from "mobx"; -import { CatalogCategoryRegistry, catalogCategoryRegistry, CatalogEntity, CatalogEntityConstructor } from "../../common/catalog"; -import { iter } from "../../common/utils"; +import { once } from "lodash"; +import { action, computed, IComputedValue, IObservableArray, observable } from "mobx"; +import type { CatalogCategory, CatalogEntity, CatalogEntityConstructor } from "../../common/catalog"; +import { Disposer, iter } from "../../common/utils"; + +export interface CatalogEntityRegistryDependencies { + readonly getCategoryForEntity: (entity: CatalogEntity) => CatalogCategory; + readonly extensionSourcedEntities: IComputedValue; +} export class CatalogEntityRegistry { - protected sources = observable.map>(); + protected localSources = observable.set>(); - constructor(private categoryRegistry: CatalogCategoryRegistry) { - makeObservable(this); + constructor(protected readonly dependencies: CatalogEntityRegistryDependencies) { } - @action addObservableSource(id: string, source: IObservableArray) { - this.sources.set(id, computed(() => source)); + addObservableSource = action((source: IObservableArray): Disposer => { + return this.addComputedSource(computed(() => [...source])); + }); + + addComputedSource = action((source: IComputedValue) => { + this.localSources.add(source); + + return once(() => this.localSources.delete(source)); + }); + + private get combinedItems(): CatalogEntity[] { + return [ + ...iter.flatMap(this.localSources.values(), source => source.get()), + ...this.dependencies.extensionSourcedEntities.get(), + ]; } - @action addComputedSource(id: string, source: IComputedValue) { - this.sources.set(id, source); + #items = computed(() => this.combinedItems.filter(entity => this.dependencies.getCategoryForEntity(entity))); + + get items(): CatalogEntity[] { + return this.#items.get(); } - @action removeSource(id: string) { - this.sources.delete(id); + getById(id: string): CatalogEntity | undefined { + return this.items.find((entity) => entity.metadata.uid === id) as CatalogEntity | undefined; } - @computed get items(): CatalogEntity[] { - return Array.from( - iter.filter( - iter.flatMap(this.sources.values(), source => source.get()), - entity => this.categoryRegistry.getCategoryForEntity(entity), - ), - ); - } - - getById(id: string): T | undefined { - return this.items.find((entity) => entity.metadata.uid === id) as T | undefined; - } - - getItemsForApiKind(apiVersion: string, kind: string): T[] { - return this.items.filter((item) => item.apiVersion === apiVersion && item.kind === kind) as T[]; + getItemsForApiKind(apiVersion: string, kind: string): CatalogEntity[] { + return this.items.filter((item) => item.apiVersion === apiVersion && item.kind === kind) as CatalogEntity[]; } getItemsByEntityClass(constructor: CatalogEntityConstructor): T[] { return this.items.filter((item) => item instanceof constructor) as T[]; } } - -export const catalogEntityRegistry = new CatalogEntityRegistry(catalogCategoryRegistry); diff --git a/src/main/catalog/category-registry.injectable.ts b/src/main/catalog/category-registry.injectable.ts new file mode 100644 index 0000000000..23418b381d --- /dev/null +++ b/src/main/catalog/category-registry.injectable.ts @@ -0,0 +1,22 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { CatalogCategoryRegistry } from "../../common/catalog"; +import { GeneralCategory, KubernetesClusterCategory, WebLinkCategory } from "../../common/catalog-entities"; + +const catalogCategoryRegistryInjectable = getInjectable({ + instantiate: () => { + const registry = new CatalogCategoryRegistry(); + + registry.add(new KubernetesClusterCategory()); + registry.add(new GeneralCategory()); + registry.add(new WebLinkCategory()); + + return registry; + }, + lifecycle: lifecycleEnum.singleton, +}); + +export default catalogCategoryRegistryInjectable; diff --git a/src/main/catalog/entity-registry.injectable.ts b/src/main/catalog/entity-registry.injectable.ts new file mode 100644 index 0000000000..ac794aadeb --- /dev/null +++ b/src/main/catalog/entity-registry.injectable.ts @@ -0,0 +1,18 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { CatalogEntityRegistry } from "./catalog-entity-registry"; +import extensionSourcedEntitiesInjectable from "./extension-sourced-entities.injectable"; +import getCategoryForEntityInjectable from "./get-category-for-entity.injectable"; + +const catalogEntityRegistryInjectable = getInjectable({ + instantiate: (di) => new CatalogEntityRegistry({ + getCategoryForEntity: di.inject(getCategoryForEntityInjectable), + extensionSourcedEntities: di.inject(extensionSourcedEntitiesInjectable), + }), + lifecycle: lifecycleEnum.singleton, +}); + +export default catalogEntityRegistryInjectable; diff --git a/src/main/catalog/extension-sourced-entities.injectable.ts b/src/main/catalog/extension-sourced-entities.injectable.ts new file mode 100644 index 0000000000..ba5163d82c --- /dev/null +++ b/src/main/catalog/extension-sourced-entities.injectable.ts @@ -0,0 +1,38 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { computed, IComputedValue } from "mobx"; +import type { CatalogEntity } from "../../common/catalog"; +import type { LensMainExtension } from "../../extensions/lens-main-extension"; +import mainExtensionsInjectable from "../../extensions/main-extensions.injectable"; +import { iter } from "../../renderer/utils"; + +interface Dependencies { + extensions: IComputedValue; +} + +const extensionSourcedEntitiesInjectable = getInjectable({ + instantiate: (di) => getExtensionSourcesComputed({ + extensions: di.inject(mainExtensionsInjectable), + }), + lifecycle: lifecycleEnum.singleton, +}); + +export default extensionSourcedEntitiesInjectable; + +function getExtensionSourcesComputed({ extensions }: Dependencies): IComputedValue { + return computed(() => ( + Array.from( + iter.flatMap( + iter.flatMap( + extensions.get(), + ext => ext.sources.values(), + ), + source => source.get(), + ), + ) + )); +} + diff --git a/src/main/catalog/get-category-for-entity.injectable.ts b/src/main/catalog/get-category-for-entity.injectable.ts new file mode 100644 index 0000000000..4833f60a0c --- /dev/null +++ b/src/main/catalog/get-category-for-entity.injectable.ts @@ -0,0 +1,13 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import catalogCategoryRegistryInjectable from "./category-registry.injectable"; + +const getCategoryForEntityInjectable = getInjectable({ + instantiate: (di) => di.inject(catalogCategoryRegistryInjectable).getCategoryForEntity, + lifecycle: lifecycleEnum.singleton, +}); + +export default getCategoryForEntityInjectable; diff --git a/src/main/cluster-detectors/base-cluster-detector.ts b/src/main/cluster-detectors/base-cluster-detector.ts index 6fd8fc4839..f6353e24a2 100644 --- a/src/main/cluster-detectors/base-cluster-detector.ts +++ b/src/main/cluster-detectors/base-cluster-detector.ts @@ -5,24 +5,23 @@ import type { RequestPromiseOptions } from "request-promise-native"; import type { Cluster } from "../../common/cluster/cluster"; -import { k8sRequest } from "../k8s-request"; -export type ClusterDetectionResult = { +export interface ClusterDetectionResult { value: string | number | boolean accuracy: number -}; +} -export class BaseClusterDetector { - key: string; +export interface BaseClusterDetectorDependencies { + k8sRequest: (cluster: Cluster, path: string, options: RequestPromiseOptions) => Promise; +} - constructor(public cluster: Cluster) { +export abstract class BaseClusterDetector { + constructor(public cluster: Cluster, protected readonly dependencies: BaseClusterDetectorDependencies) { } - detect(): Promise { - return null; - } + abstract detect(): Promise; - protected async k8sRequest(path: string, options: RequestPromiseOptions = {}): Promise { - return k8sRequest(this.cluster, path, options); + protected k8sRequest(path: string, options: RequestPromiseOptions = {}): Promise { + return this.dependencies.k8sRequest(this.cluster, path, options); } } diff --git a/src/main/cluster-detectors/cluster-id-detector.ts b/src/main/cluster-detectors/cluster-id-detector.ts index 8f1486fcb1..68fc730e7a 100644 --- a/src/main/cluster-detectors/cluster-id-detector.ts +++ b/src/main/cluster-detectors/cluster-id-detector.ts @@ -5,11 +5,8 @@ import { BaseClusterDetector } from "./base-cluster-detector"; import { createHash } from "crypto"; -import { ClusterMetadataKey } from "../../common/cluster-types"; export class ClusterIdDetector extends BaseClusterDetector { - key = ClusterMetadataKey.CLUSTER_ID; - public async detect() { let id: string; diff --git a/src/main/cluster-detectors/detect-metadata-for-cluster.injectable.ts b/src/main/cluster-detectors/detect-metadata-for-cluster.injectable.ts new file mode 100644 index 0000000000..c19f905de5 --- /dev/null +++ b/src/main/cluster-detectors/detect-metadata-for-cluster.injectable.ts @@ -0,0 +1,13 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import detectorRegistryInjectable from "./detector-registry.injectable"; + +const detectMetadataForClusterInjectable = getInjectable({ + instantiate: (di) => di.inject(detectorRegistryInjectable).detectForCluster, + lifecycle: lifecycleEnum.singleton, +}); + +export default detectMetadataForClusterInjectable; diff --git a/src/main/cluster-detectors/detect-specific-for-cluster.injectable.ts b/src/main/cluster-detectors/detect-specific-for-cluster.injectable.ts new file mode 100644 index 0000000000..39056b8618 --- /dev/null +++ b/src/main/cluster-detectors/detect-specific-for-cluster.injectable.ts @@ -0,0 +1,18 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { bind } from "../../common/utils"; +import detectorRegistryInjectable from "./detector-registry.injectable"; + +interface Args { + key: string; +} + +const detectSpecificForClusterInjectable = getInjectable({ + instantiate: (di, { key }: Args) => bind(di.inject(detectorRegistryInjectable).detectSpecificForCluster, null, key), + lifecycle: lifecycleEnum.transient, +}); + +export default detectSpecificForClusterInjectable; diff --git a/src/main/cluster-detectors/detector-registry.injectable.ts b/src/main/cluster-detectors/detector-registry.injectable.ts new file mode 100644 index 0000000000..fd7c2385ef --- /dev/null +++ b/src/main/cluster-detectors/detector-registry.injectable.ts @@ -0,0 +1,32 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { ClusterMetadataKey } from "../../common/cluster-types"; +import k8sRequestInjectable from "../k8s-api/k8s-request.injectable"; +import { ClusterIdDetector } from "./cluster-id-detector"; +import { DetectorRegistry } from "./detector-registry"; +import { DistributionDetector } from "./distribution-detector"; +import { LastSeenDetector } from "./last-seen-detector"; +import { NodesCountDetector } from "./nodes-count-detector"; +import { VersionDetector } from "./version-detector"; + +const detectorRegistryInjectable = getInjectable({ + instantiate: (di) => { + const registry = new DetectorRegistry({ + k8sRequest: di.inject(k8sRequestInjectable), + }); + + registry.add(ClusterMetadataKey.CLUSTER_ID, ClusterIdDetector); + registry.add(ClusterMetadataKey.LAST_SEEN, LastSeenDetector); + registry.add(ClusterMetadataKey.VERSION, VersionDetector); + registry.add(ClusterMetadataKey.DISTRIBUTION, DistributionDetector); + registry.add(ClusterMetadataKey.NODES_COUNT, NodesCountDetector); + + return registry; + }, + lifecycle: lifecycleEnum.singleton, +}); + +export default detectorRegistryInjectable; diff --git a/src/main/cluster-detectors/detector-registry.ts b/src/main/cluster-detectors/detector-registry.ts index e2741c1353..3ee16f240d 100644 --- a/src/main/cluster-detectors/detector-registry.ts +++ b/src/main/cluster-detectors/detector-registry.ts @@ -5,37 +5,42 @@ import { observable } from "mobx"; import type { ClusterMetadata } from "../../common/cluster-types"; -import { Singleton } from "../../common/utils"; import type { Cluster } from "../../common/cluster/cluster"; -import type { BaseClusterDetector, ClusterDetectionResult } from "./base-cluster-detector"; +import type { BaseClusterDetector, BaseClusterDetectorDependencies, ClusterDetectionResult } from "./base-cluster-detector"; -export class DetectorRegistry extends Singleton { - registry = observable.array([], { deep: false }); +export interface DetectorRegistryDependencies extends BaseClusterDetectorDependencies { +} - add(detectorClass: typeof BaseClusterDetector): this { - this.registry.push(detectorClass); +export class DetectorRegistry { + registry = observable.map BaseClusterDetector>([], { deep: false }); - return this; + constructor(protected readonly dependencies: DetectorRegistryDependencies) {} + + add(key: string, detectorClass: new (cluster: Cluster, deps: BaseClusterDetectorDependencies) => BaseClusterDetector) { + this.registry.set(key, detectorClass); } - async detectForCluster(cluster: Cluster): Promise { + detectForCluster = async (cluster: Cluster): Promise => { const results: { [key: string]: ClusterDetectionResult } = {}; - for (const detectorClass of this.registry) { - const detector = new detectorClass(cluster); + for (const [key, detectorClass] of this.registry) { + const detector = new detectorClass(cluster, this.dependencies); try { const data = await detector.detect(); - if (!data) continue; - const existingValue = results[detector.key]; + if (data) { + const existingValue = results[key]; - if (existingValue && existingValue.accuracy > data.accuracy) continue; // previous value exists and is more accurate - results[detector.key] = data; + if (!existingValue || existingValue.accuracy <= data.accuracy) { + results[key] = data; + } + } } catch (e) { // detector raised error, do nothing } } + const metadata: ClusterMetadata = {}; for (const [key, result] of Object.entries(results)) { @@ -43,5 +48,11 @@ export class DetectorRegistry extends Singleton { } return metadata; - } + }; + + detectSpecificForCluster = (key: string, cluster: Cluster) => { + const detector = new (this.registry.get(key))(cluster, this.dependencies); + + return detector.detect(); + }; } diff --git a/src/main/cluster-detectors/version-detector.ts b/src/main/cluster-detectors/version-detector.ts index 50b55b433d..ad06aa47b2 100644 --- a/src/main/cluster-detectors/version-detector.ts +++ b/src/main/cluster-detectors/version-detector.ts @@ -3,14 +3,14 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import { BaseClusterDetector } from "./base-cluster-detector"; +import { BaseClusterDetector, ClusterDetectionResult } from "./base-cluster-detector"; import { ClusterMetadataKey } from "../../common/cluster-types"; export class VersionDetector extends BaseClusterDetector { key = ClusterMetadataKey.VERSION; value: string; - public async detect() { + public async detect(): Promise { const version = await this.getKubernetesVersion(); return { value: version, accuracy: 100 }; diff --git a/src/main/cluster-manager/cluster-manager.injectable.ts b/src/main/cluster-manager/cluster-manager.injectable.ts new file mode 100644 index 0000000000..fdd6f7038c --- /dev/null +++ b/src/main/cluster-manager/cluster-manager.injectable.ts @@ -0,0 +1,18 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import clusterStoreInjectable from "../../common/cluster-store/store.injectable"; +import catalogEntityRegistryInjectable from "../catalog/entity-registry.injectable"; +import { ClusterManager } from "./cluster-manager"; + +const clusterManagerInjectable = getInjectable({ + instantiate: (di) => new ClusterManager({ + clusterStore: di.inject(clusterStoreInjectable), + entityRegistry: di.inject(catalogEntityRegistryInjectable), + }), + lifecycle: lifecycleEnum.singleton, +}); + +export default clusterManagerInjectable; diff --git a/src/main/cluster-manager.ts b/src/main/cluster-manager/cluster-manager.ts similarity index 75% rename from src/main/cluster-manager.ts rename to src/main/cluster-manager/cluster-manager.ts index b7e17ff762..7e70c6b25a 100644 --- a/src/main/cluster-manager.ts +++ b/src/main/cluster-manager/cluster-manager.ts @@ -3,57 +3,56 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import "../common/cluster-ipc"; +import "../../common/cluster-ipc"; import type http from "http"; import { action, makeObservable, observable, observe, reaction, toJS } from "mobx"; -import { Cluster } from "../common/cluster/cluster"; -import logger from "./logger"; -import { apiKubePrefix } from "../common/vars"; -import { getClusterIdFromHost, Singleton } from "../common/utils"; -import { catalogEntityRegistry } from "./catalog"; -import { KubernetesCluster, KubernetesClusterPrometheusMetrics, LensKubernetesClusterStatus } from "../common/catalog-entities/kubernetes-cluster"; -import { ipcMainOn } from "../common/ipc"; -import { once } from "lodash"; -import { ClusterStore } from "../common/cluster-store/cluster-store"; -import type { ClusterId } from "../common/cluster-types"; +import { Cluster } from "../../common/cluster/cluster"; +import logger from "../logger"; +import { apiKubePrefix } from "../../common/vars"; +import { getClusterIdFromHost } from "../../common/utils"; +import type { CatalogEntityRegistry } from "../catalog"; +import { KubernetesCluster, KubernetesClusterPrometheusMetrics, LensKubernetesClusterStatus } from "../../common/catalog-entities/kubernetes-cluster"; +import { ipcMainOn } from "../../common/ipc"; +import type { ClusterStore } from "../../common/cluster-store/store"; +import type { ClusterId } from "../../common/cluster-types"; const logPrefix = "[CLUSTER-MANAGER]:"; - const lensSpecificClusterStatuses: Set = new Set(Object.values(LensKubernetesClusterStatus)); -export class ClusterManager extends Singleton { - private store = ClusterStore.getInstance(); +export interface ClusterManagerDependencies { + readonly entityRegistry: CatalogEntityRegistry; + readonly clusterStore: ClusterStore; +} + +export class ClusterManager { deleting = observable.set(); @observable visibleCluster: ClusterId | undefined = undefined; - constructor() { - super(); + constructor(protected readonly dependencies: ClusterManagerDependencies) { makeObservable(this); - } - init = once(() => { // reacting to every cluster's state change and total amount of items reaction( - () => this.store.clustersList.map(c => c.getState()), - () => this.updateCatalog(this.store.clustersList), + () => this.dependencies.clusterStore.clustersList.map(c => c.getState()), + () => this.updateCatalog(this.dependencies.clusterStore.clustersList), { fireImmediately: false }, ); // reacting to every cluster's preferences change and total amount of items reaction( - () => this.store.clustersList.map(c => toJS(c.preferences)), - () => this.updateCatalog(this.store.clustersList), + () => this.dependencies.clusterStore.clustersList.map(c => toJS(c.preferences)), + () => this.updateCatalog(this.dependencies.clusterStore.clustersList), { fireImmediately: false }, ); reaction( - () => catalogEntityRegistry.getItemsByEntityClass(KubernetesCluster), + () => this.dependencies.entityRegistry.getItemsByEntityClass(KubernetesCluster), entities => this.syncClustersFromCatalog(entities), ); reaction(() => [ - catalogEntityRegistry.getItemsByEntityClass(KubernetesCluster), + this.dependencies.entityRegistry.getItemsByEntityClass(KubernetesCluster), this.visibleCluster, ] as const, ([entities, visibleCluster]) => { for (const entity of entities) { @@ -67,13 +66,17 @@ export class ClusterManager extends Singleton { observe(this.deleting, change => { if (change.type === "add") { - this.updateEntityStatus(catalogEntityRegistry.getById(change.newValue)); + this.updateEntityStatus(this.dependencies.entityRegistry.getById(change.newValue) as KubernetesCluster); } }); ipcMainOn("network:offline", this.onNetworkOffline); ipcMainOn("network:online", this.onNetworkOnline); - }); + } + + public removeFromDeleting = (clusterId: ClusterId): void => { + this.deleting.delete(clusterId); + }; @action protected updateCatalog(clusters: Cluster[]) { @@ -85,13 +88,13 @@ export class ClusterManager extends Singleton { } protected updateEntityFromCluster(cluster: Cluster) { - const index = catalogEntityRegistry.items.findIndex((entity) => entity.metadata.uid === cluster.id); + const index = this.dependencies.entityRegistry.items.findIndex((entity) => entity.metadata.uid === cluster.id); if (index === -1) { return; } - const entity = catalogEntityRegistry.items[index] as KubernetesCluster; + const entity = this.dependencies.entityRegistry.items[index] as KubernetesCluster; this.updateEntityStatus(entity, cluster); @@ -132,7 +135,7 @@ export class ClusterManager extends Singleton { cluster.preferences.icon = undefined; } - catalogEntityRegistry.items.splice(index, 1, entity); + this.dependencies.entityRegistry.items.splice(index, 1, entity); } @action @@ -169,7 +172,7 @@ export class ClusterManager extends Singleton { @action protected syncClustersFromCatalog(entities: KubernetesCluster[]) { for (const entity of entities) { - const cluster = this.store.getById(entity.metadata.uid); + const cluster = this.dependencies.clusterStore.getById(entity.metadata.uid); if (!cluster) { const model = { @@ -184,7 +187,7 @@ export class ClusterManager extends Singleton { * Add the bare minimum of data to ClusterStore. And especially no * preferences, as those might be configured by the entity's source */ - this.store.addCluster(model); + this.dependencies.clusterStore.addCluster(model); } catch (error) { if (error.code === "ENOENT" && error.path === entity.spec.kubeconfigPath) { logger.warn(`${logPrefix} kubeconfig file disappeared`, model); @@ -223,7 +226,7 @@ export class ClusterManager extends Singleton { protected onNetworkOffline = () => { logger.info(`${logPrefix} network is offline`); - this.store.clustersList.forEach((cluster) => { + this.dependencies.clusterStore.clustersList.forEach((cluster) => { if (!cluster.disconnected) { cluster.online = false; cluster.accessible = false; @@ -234,7 +237,7 @@ export class ClusterManager extends Singleton { protected onNetworkOnline = () => { logger.info(`${logPrefix} network is online`); - this.store.clustersList.forEach((cluster) => { + this.dependencies.clusterStore.clustersList.forEach((cluster) => { if (!cluster.disconnected) { cluster.refreshConnectionStatus().catch((e) => e); } @@ -242,16 +245,16 @@ export class ClusterManager extends Singleton { }; stop() { - this.store.clusters.forEach((cluster: Cluster) => { + this.dependencies.clusterStore.clusters.forEach((cluster: Cluster) => { cluster.disconnect(); }); } - getClusterForRequest(req: http.IncomingMessage): Cluster { + getClusterForRequest = (req: http.IncomingMessage): Cluster => { // lens-server is connecting to 127.0.0.1:/ if (req.headers.host.startsWith("127.0.0.1")) { const clusterId = req.url.split("/")[1]; - const cluster = this.store.getById(clusterId); + const cluster = this.dependencies.clusterStore.getById(clusterId); if (cluster) { // we need to swap path prefix so that request is proxied to kube api @@ -261,8 +264,8 @@ export class ClusterManager extends Singleton { return cluster; } - return this.store.getById(getClusterIdFromHost(req.headers.host)); - } + return this.dependencies.clusterStore.getById(getClusterIdFromHost(req.headers.host)); + }; } export function catalogEntityFromCluster(cluster: Cluster) { diff --git a/src/main/cluster-manager/get-cluster-for-request.injectable.ts b/src/main/cluster-manager/get-cluster-for-request.injectable.ts new file mode 100644 index 0000000000..610345de7f --- /dev/null +++ b/src/main/cluster-manager/get-cluster-for-request.injectable.ts @@ -0,0 +1,13 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import clusterManagerInjectable from "./cluster-manager.injectable"; + +const getClusterForRequestInjectable = getInjectable({ + instantiate: (di) => di.inject(clusterManagerInjectable).getClusterForRequest, + lifecycle: lifecycleEnum.singleton, +}); + +export default getClusterForRequestInjectable; diff --git a/src/main/cluster-manager/remove-from-deleting.injectable.ts b/src/main/cluster-manager/remove-from-deleting.injectable.ts new file mode 100644 index 0000000000..e751237e12 --- /dev/null +++ b/src/main/cluster-manager/remove-from-deleting.injectable.ts @@ -0,0 +1,13 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import clusterManagerInjectable from "./cluster-manager.injectable"; + +const removeFromDeletingInjectable = getInjectable({ + instantiate: (di) => di.inject(clusterManagerInjectable).removeFromDeleting, + lifecycle: lifecycleEnum.singleton, +}); + +export default removeFromDeletingInjectable; diff --git a/src/main/context-handler/context-handler.ts b/src/main/context-handler/context-handler.ts index 7d5c4b5281..55ecc04d11 100644 --- a/src/main/context-handler/context-handler.ts +++ b/src/main/context-handler/context-handler.ts @@ -36,6 +36,10 @@ export class ContextHandler { protected prometheusProvider?: string; protected prometheus?: PrometheusServicePreferences; + static create(...args: ConstructorParameters) { + return new ContextHandler(...args); + } + constructor(private dependencies: Dependencies, protected cluster: Cluster) { this.clusterUrl = url.parse(cluster.apiUrl); this.setupPrometheus(cluster.preferences); diff --git a/src/main/context-handler/create-context-handler.injectable.ts b/src/main/context-handler/create-context-handler.injectable.ts index e6a7df9b8a..eaf8afba11 100644 --- a/src/main/context-handler/create-context-handler.injectable.ts +++ b/src/main/context-handler/create-context-handler.injectable.ts @@ -3,18 +3,14 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; -import type { Cluster } from "../../common/cluster/cluster"; import { ContextHandler } from "./context-handler"; import createKubeAuthProxyInjectable from "../kube-auth-proxy/create-kube-auth-proxy.injectable"; +import { bind } from "../../common/utils"; const createContextHandlerInjectable = getInjectable({ - instantiate: (di) => { - const dependencies = { - createKubeAuthProxy: di.inject(createKubeAuthProxyInjectable), - }; - - return (cluster: Cluster) => new ContextHandler(dependencies, cluster); - }, + instantiate: (di) => bind(ContextHandler.create, null, { + createKubeAuthProxy: di.inject(createKubeAuthProxyInjectable), + }), lifecycle: lifecycleEnum.singleton, }); diff --git a/src/main/create-cluster/create-cluster.injectable.ts b/src/main/create-cluster/create-cluster.injectable.ts index de8f7a2e92..537d6dad82 100644 --- a/src/main/create-cluster/create-cluster.injectable.ts +++ b/src/main/create-cluster/create-cluster.injectable.ts @@ -3,25 +3,28 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; -import type { ClusterModel } from "../../common/cluster-types"; +import { ClusterMetadataKey } from "../../common/cluster-types"; import { Cluster } from "../../common/cluster/cluster"; -import directoryForKubeConfigsInjectable from "../../common/app-paths/directory-for-kube-configs/directory-for-kube-configs.injectable"; +import directoryForKubeConfigsInjectable from "../../common/app-paths/directory-for-kube-configs.injectable"; import createKubeconfigManagerInjectable from "../kubeconfig-manager/create-kubeconfig-manager.injectable"; import createKubectlInjectable from "../kubectl/create-kubectl.injectable"; import createContextHandlerInjectable from "../context-handler/create-context-handler.injectable"; import { createClusterInjectionToken } from "../../common/cluster/create-cluster-injection-token"; +import detectMetadataForClusterInjectable from "../cluster-detectors/detect-metadata-for-cluster.injectable"; +import detectSpecificForClusterInjectable from "../cluster-detectors/detect-specific-for-cluster.injectable"; +import { bind } from "../../common/utils"; const createClusterInjectable = getInjectable({ - instantiate: (di) => { - const dependencies = { - directoryForKubeConfigs: di.inject(directoryForKubeConfigsInjectable), - createKubeconfigManager: di.inject(createKubeconfigManagerInjectable), - createKubectl: di.inject(createKubectlInjectable), - createContextHandler: di.inject(createContextHandlerInjectable), - }; - - return (model: ClusterModel) => new Cluster(dependencies, model); - }, + instantiate: (di) => bind(Cluster.create, null, { + directoryForKubeConfigs: di.inject(directoryForKubeConfigsInjectable), + createKubeconfigManager: di.inject(createKubeconfigManagerInjectable), + createKubectl: di.inject(createKubectlInjectable), + createContextHandler: di.inject(createContextHandlerInjectable), + detectMetadataForCluster: di.inject(detectMetadataForClusterInjectable), + detectVersion: di.inject(detectSpecificForClusterInjectable, { + key: ClusterMetadataKey.VERSION, + }), + }), injectionToken: createClusterInjectionToken, diff --git a/src/main/exit-app.injectable.ts b/src/main/exit-app.injectable.ts new file mode 100644 index 0000000000..d4ae11df7a --- /dev/null +++ b/src/main/exit-app.injectable.ts @@ -0,0 +1,40 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { app } from "electron"; +import type { WindowManager } from "./windows/manager"; +import { appEventBus } from "../common/app-event-bus/event-bus"; +import type { ClusterManager } from "./cluster-manager/cluster-manager"; +import logger from "./logger"; +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { bind } from "../common/utils"; +import clusterManagerInjectable from "./cluster-manager/cluster-manager.injectable"; +import windowManagerInjectable from "./windows/manager.injectable"; + +export interface ExitAppDependencies { + clusterManager: ClusterManager; + windowManager: WindowManager; +} + +function exitApp({ clusterManager, windowManager }: ExitAppDependencies) { + appEventBus.emit({ name: "service", action: "close" }); + windowManager.hide(); + clusterManager.stop(); + logger.info("SERVICE:QUIT"); + setTimeout(() => { + app.exit(); + }, 1000); +} + +const exitAppInjectable = getInjectable({ + instantiate: (di) => bind(exitApp, null, { + clusterManager: di.inject(clusterManagerInjectable), + windowManager: di.inject(windowManagerInjectable), + }), + lifecycle: lifecycleEnum.singleton, +}); + +export default exitAppInjectable; + diff --git a/src/main/exit-app.ts b/src/main/exit-app.ts deleted file mode 100644 index c9a5bc5bf1..0000000000 --- a/src/main/exit-app.ts +++ /dev/null @@ -1,23 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import { app } from "electron"; -import { WindowManager } from "./window-manager"; -import { appEventBus } from "../common/app-event-bus/event-bus"; -import { ClusterManager } from "./cluster-manager"; -import logger from "./logger"; - -export function exitApp() { - const windowManager = WindowManager.getInstance(false); - const clusterManager = ClusterManager.getInstance(false); - - appEventBus.emit({ name: "service", action: "close" }); - windowManager?.hide(); - clusterManager?.stop(); - logger.info("SERVICE:QUIT"); - setTimeout(() => { - app.exit(); - }, 1000); -} diff --git a/src/main/getDi.ts b/src/main/getDi.ts index aa96e2ad17..b52e84718a 100644 --- a/src/main/getDi.ts +++ b/src/main/getDi.ts @@ -6,23 +6,14 @@ import { createContainer } from "@ogre-tools/injectable"; import { setLegacyGlobalDiForExtensionApi } from "../extensions/as-legacy-globals-for-extension-api/legacy-global-di-for-extension-api"; -export const getDi = () => { +export function getDi() { const di = createContainer( - getRequireContextForMainCode, - getRequireContextForCommonExtensionCode, - getRequireContextForCommonCode, + () => require.context("./", true, /\.injectable\.(ts|tsx)$/), + () => require.context("../common", true, /\.injectable\.(ts|tsx)$/), + () => require.context("../extensions", true, /\.injectable\.(ts|tsx)$/), ); setLegacyGlobalDiForExtensionApi(di); return di; -}; - -const getRequireContextForMainCode = () => - require.context("./", true, /\.injectable\.(ts|tsx)$/); - -const getRequireContextForCommonExtensionCode = () => - require.context("../extensions", true, /\.injectable\.(ts|tsx)$/); - -const getRequireContextForCommonCode = () => - require.context("../common", true, /\.injectable\.(ts|tsx)$/); +} diff --git a/src/main/helm/__mocks__/helm-chart-manager.ts b/src/main/helm/__mocks__/helm-chart-manager.ts index 858d6c24a4..0f25b90028 100644 --- a/src/main/helm/__mocks__/helm-chart-manager.ts +++ b/src/main/helm/__mocks__/helm-chart-manager.ts @@ -3,10 +3,11 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ +import type { RawHelmChart } from "../../../common/k8s-api/endpoints"; import { sortCharts } from "../../../common/utils"; import type { HelmRepo } from "../helm-repo-manager"; -const charts = new Map([ +const charts: Map> = new Map([ ["stable", { "invalid-semver": sortCharts([ { @@ -147,7 +148,7 @@ export class HelmChartManager { return new this(repo); } - public async charts(): Promise { + public charts() { return charts.get(this.repo.name) ?? {}; } } diff --git a/src/main/helm/__tests__/helm-service.test.ts b/src/main/helm/__tests__/helm-service.test.ts index 42c548c4be..056769f193 100644 --- a/src/main/helm/__tests__/helm-service.test.ts +++ b/src/main/helm/__tests__/helm-service.test.ts @@ -18,7 +18,7 @@ describe("Helm Service tests", () => { it("list charts with deprecated entries", async () => { mockHelmRepoManager.mockReturnValue({ init: jest.fn(), - repositories: jest.fn().mockImplementation(async () => { + repositories: jest.fn().mockImplementation(() => { return [ { name: "stable", url: "stableurl" }, { name: "experiment", url: "experimenturl" }, @@ -128,7 +128,7 @@ describe("Helm Service tests", () => { it("list charts sorted by version in descending order", async () => { mockHelmRepoManager.mockReturnValue({ init: jest.fn(), - repositories: jest.fn().mockImplementation(async () => { + repositories: jest.fn().mockImplementation(() => { return [ { name: "bitnami", url: "bitnamiurl" }, ]; diff --git a/src/main/helm/helm-chart-manager.ts b/src/main/helm/helm-chart-manager.ts index fecf7b822c..c477f20a46 100644 --- a/src/main/helm/helm-chart-manager.ts +++ b/src/main/helm/helm-chart-manager.ts @@ -10,7 +10,7 @@ import type { HelmRepo } from "./helm-repo-manager"; import logger from "../logger"; import { promiseExecFile } from "../../common/utils/promise-exec"; import { helmCli } from "./helm-cli"; -import type { RepoHelmChartList } from "../../common/k8s-api/endpoints/helm-charts.api"; +import type { RepoHelmChartList } from "../../common/k8s-api/endpoints/helm-chart.api"; import { iter, sortCharts } from "../../common/utils"; interface ChartCacheEntry { @@ -66,11 +66,11 @@ export class HelmChartManager { } } - public async getReadme(name: string, version?: string) { + public getReadme(name: string, version?: string) { return this.executeCommand(["show", "readme"], name, version); } - public async getValues(name: string, version?: string) { + public getValues(name: string, version?: string) { return this.executeCommand(["show", "values"], name, version); } diff --git a/src/main/helm/helm-release-manager.ts b/src/main/helm/helm-release-manager.ts index 879bfed8ec..bd459b602b 100644 --- a/src/main/helm/helm-release-manager.ts +++ b/src/main/helm/helm-release-manager.ts @@ -133,7 +133,7 @@ export async function getRelease(name: string, namespace: string, kubeconfigPath return release; } -export async function deleteRelease(name: string, namespace: string, kubeconfigPath: string) { +export function deleteRelease(name: string, namespace: string, kubeconfigPath: string) { return execHelm([ "delete", name, @@ -148,7 +148,7 @@ interface GetValuesOptions { kubeconfigPath: string; } -export async function getValues(name: string, { namespace, all = false, kubeconfigPath }: GetValuesOptions) { +export function getValues(name: string, { namespace, all = false, kubeconfigPath }: GetValuesOptions) { const args = [ "get", "values", diff --git a/src/main/helm/helm-repo-manager.ts b/src/main/helm/helm-repo-manager.ts index 9ca2ae05d2..5880d45ac0 100644 --- a/src/main/helm/helm-repo-manager.ts +++ b/src/main/helm/helm-repo-manager.ts @@ -8,8 +8,6 @@ import { BaseEncodingOptions, readFile } from "fs-extra"; import { promiseExecFile } from "../../common/utils/promise-exec"; import { helmCli } from "./helm-cli"; import { Singleton } from "../../common/utils/singleton"; -import { customRequestPromise } from "../../common/request"; -import orderBy from "lodash/orderBy"; import logger from "../logger"; import type { ExecFileOptions } from "child_process"; @@ -51,17 +49,6 @@ export class HelmRepoManager extends Singleton { protected helmEnv: HelmEnv; protected initialized: boolean; - public static async loadAvailableRepos(): Promise { - const res = await customRequestPromise({ - uri: "https://github.com/lensapp/artifact-hub-repositories/releases/download/latest/repositories.json", - json: true, - resolveWithFullResponse: true, - timeout: 10000, - }); - - return orderBy(res.body, repo => repo.name); - } - private async init() { helmCli.setLogger(logger); await helmCli.ensureBinary(); @@ -137,14 +124,14 @@ export class HelmRepoManager extends Singleton { } } - public static async update() { + public static update() { return execHelm([ "repo", "update", ]); } - public static async addRepo({ name, url }: HelmRepo) { + public static addRepo({ name, url }: HelmRepo) { logger.info(`[HELM]: adding repo "${name}" from ${url}`); return execHelm([ @@ -155,7 +142,7 @@ export class HelmRepoManager extends Singleton { ]); } - public static async addCustomRepo({ name, url, insecureSkipTlsVerify, username, password, caFile, keyFile, certFile }: HelmRepo) { + public static addCustomRepo({ name, url, insecureSkipTlsVerify, username, password, caFile, keyFile, certFile }: HelmRepo) { logger.info(`[HELM]: adding repo ${name} from ${url}`); const args = [ "repo", @@ -191,7 +178,7 @@ export class HelmRepoManager extends Singleton { return execHelm(args); } - public static async removeRepo({ name, url }: HelmRepo): Promise { + public static removeRepo({ name, url }: HelmRepo): Promise { logger.info(`[HELM]: removing repo ${name} (${url})`); return execHelm([ diff --git a/src/main/helm/load-available-repos.injectable.ts b/src/main/helm/load-available-repos.injectable.ts new file mode 100644 index 0000000000..fed719e803 --- /dev/null +++ b/src/main/helm/load-available-repos.injectable.ts @@ -0,0 +1,36 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { orderBy } from "lodash"; +import type { Options } from "request-promise-native"; +import type { HelmRepo } from "./helm-repo-manager"; +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { bind } from "../../common/utils"; +import customRequestPromiseInjectable from "../../common/request-promise.injectable.ts"; + +interface Dependencies { + customRequestPromise: (opts: Options) => Promise; +} + +async function loadAvailableHelmRepos({ customRequestPromise }: Dependencies): Promise { + const res = await customRequestPromise({ + uri: "https://github.com/lensapp/artifact-hub-repositories/releases/download/latest/repositories.json", + json: true, + resolveWithFullResponse: true, + timeout: 10000, + }); + + return orderBy(res.body, repo => repo.name); +} + +const loadAvailableHelmReposInjectable = getInjectable({ + instantiate: (di) => bind(loadAvailableHelmRepos, null, { + customRequestPromise: di.inject(customRequestPromiseInjectable), + }), + lifecycle: lifecycleEnum.singleton, +}); + +export default loadAvailableHelmReposInjectable; + diff --git a/src/main/index.ts b/src/main/index.ts index 663e7d8f53..fbfe2f3c4e 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -12,9 +12,6 @@ import * as LensExtensionsCommonApi from "../extensions/common-api"; import * as LensExtensionsMainApi from "../extensions/main-api"; import { app, autoUpdater, dialog, powerMonitor } from "electron"; import { appName, isIntegrationTesting, isMac, isWindows, productName } from "../common/vars"; -import { LensProxy } from "./lens-proxy"; -import { WindowManager } from "./window-manager"; -import { ClusterManager } from "./cluster-manager"; import { shellSync } from "./shell-sync"; import { mangleProxyEnv } from "./proxy-env"; import { registerFileProtocol } from "../common/register-protocol"; @@ -25,49 +22,51 @@ import type { LensExtensionId } from "../extensions/lens-extension"; import { installDeveloperTools } from "./developer-tools"; import { disposer, getAppVersion, getAppVersionFromProxyServer } from "../common/utils"; import { bindBroadcastHandlers, ipcMainOn } from "../common/ipc"; -import { startUpdateChecking } from "./app-updater"; import { IpcRendererNavigationEvents } from "../renderer/navigation/events"; import { pushCatalogToRenderer } from "./catalog-pusher"; -import { catalogEntityRegistry } from "./catalog"; import { HelmRepoManager } from "./helm/helm-repo-manager"; -import { syncGeneralEntities, syncWeblinks } from "./catalog-sources"; import configurePackages from "../common/configure-packages"; import { PrometheusProviderRegistry } from "./prometheus"; -import * as initializers from "./initializers"; -import { HotbarStore } from "../common/hotbar-store"; -import { WeblinkStore } from "../common/weblink-store"; -import { SentryInit } from "../common/sentry"; import { ensureDir } from "fs-extra"; -import { initMenu } from "./menu/menu"; -import { kubeApiRequest } from "./proxy-functions"; -import { initTray } from "./tray/tray"; -import { ShellSession } from "./shell-session/shell-session"; +import { ShellSession } from "./shell-sessions/shell-session"; import { getDi } from "./getDi"; import extensionLoaderInjectable from "../extensions/extension-loader/extension-loader.injectable"; -import lensProtocolRouterMainInjectable from "./protocol-handler/lens-protocol-router-main/lens-protocol-router-main.injectable"; +import lensProtocolRouterMainInjectable from "./protocol-handler/router.injectable"; import extensionDiscoveryInjectable from "../extensions/extension-discovery/extension-discovery.injectable"; -import directoryForExesInjectable from "../common/app-paths/directory-for-exes/directory-for-exes.injectable"; -import initIpcMainHandlersInjectable from "./initializers/init-ipc-main-handlers/init-ipc-main-handlers.injectable"; -import electronMenuItemsInjectable from "./menu/electron-menu-items.injectable"; -import directoryForKubeConfigsInjectable from "../common/app-paths/directory-for-kube-configs/directory-for-kube-configs.injectable"; -import kubeconfigSyncManagerInjectable from "./catalog-sources/kubeconfig-sync-manager/kubeconfig-sync-manager.injectable"; -import clusterStoreInjectable from "../common/cluster-store/cluster-store.injectable"; -import routerInjectable from "./router/router.injectable"; -import shellApiRequestInjectable from "./proxy-functions/shell-api-request/shell-api-request.injectable"; -import userStoreInjectable from "../common/user-store/user-store.injectable"; -import trayMenuItemsInjectable from "./tray/tray-menu-items.injectable"; - -const di = getDi(); +import directoryForExesInjectable from "../common/app-paths/directory-for-exes.injectable"; +import initIpcMainHandlersInjectable from "./initializers/init-ipc-main-handlers.injectable"; +import directoryForKubeConfigsInjectable from "../common/app-paths/directory-for-kube-configs.injectable"; +import clusterStoreInjectable from "../common/cluster-store/store.injectable"; +import userPreferencesStoreInjectable from "../common/user-preferences/store.injectable"; +import kubeconfigSyncManagerInjectable from "./catalog-sources/kubeconfig-sync/manager.injectable"; +import lensProxyInjectableInjectable from "./lens-proxy/lens-proxy.injectable"; +import { initPrometheusProviderRegistry } from "./initializers/metrics-providers"; +import catalogEntityRegistryInjectable from "./catalog/entity-registry.injectable"; +import { generalEntities } from "./catalog-sources/general"; +import getProxyPortInjectable from "./lens-proxy/get-proxy-port.injectable"; +import clusterManagerInjectable from "./cluster-manager/cluster-manager.injectable"; +import initAppMenuInjectable from "./menu/init-app-menu.injectable"; +import windowManagerInjectable from "./windows/manager.injectable"; +import initTrayIconInjectable from "./tray/init-tray-icon.injectable"; +import getWeblinksSourceInjectable from "./catalog-sources/weblinks-source.injectable"; +import initializeSentryReportingInjectable from "../common/sentry.injectable"; +import startUpdateCheckingInjectable from "./app-updater/start-update-checking.injectable"; app.setName(appName); -di.runSetups().then(() => { - injectSystemCAs(); +async function main() { + console.log("1"); + const di = getDi(); + console.log("2"); const onCloseCleanup = disposer(); const onQuitCleanup = disposer(); - SentryInit(); + await di.runSetups(); + console.log("3"); + + injectSystemCAs(); + di.inject(initializeSentryReportingInjectable)(); logger.info(`📟 Setting ${productName} as protocol client for lens://`); @@ -81,6 +80,8 @@ di.runSetups().then(() => { app.disableHardwareAcceleration(); } + console.log("4"); + logger.debug("[APP-MAIN] initializing remote"); initializeRemote(); @@ -91,6 +92,8 @@ di.runSetups().then(() => { const initIpcMainHandlers = di.inject(initIpcMainHandlersInjectable); + console.log("5"); + logger.debug("[APP-MAIN] initializing ipc main handlers"); initIpcMainHandlers(); @@ -121,11 +124,14 @@ di.runSetups().then(() => { } } - WindowManager.getInstance(false)?.ensureMainWindow(); + di.inject(windowManagerInjectable).ensureMainWindow(); }); app.on("ready", async () => { + console.log("4"); + const directoryForExes = di.inject(directoryForExesInjectable); + const catalogEntityRegistry = di.inject(catalogEntityRegistryInjectable); logger.info(`🚀 Starting ${productName} from "${directoryForExes}"`); logger.info("🐚 Syncing shell environment"); @@ -138,18 +144,21 @@ di.runSetups().then(() => { registerFileProtocol("static", __static); PrometheusProviderRegistry.createInstance(); - initializers.initPrometheusProviderRegistry(); + initPrometheusProviderRegistry(); /** * The following sync MUST be done before HotbarStore creation, because that * store has migrations that will remove items that previous migrations add * if this is not present */ - syncGeneralEntities(); + const getWeblinksSource = di.inject(getWeblinksSourceInjectable); + + catalogEntityRegistry.addObservableSource(generalEntities); + catalogEntityRegistry.addComputedSource(getWeblinksSource()); logger.info("💾 Loading stores"); - const userStore = di.inject(userStoreInjectable); + const userStore = di.inject(userPreferencesStoreInjectable); userStore.startMainReactions(); @@ -158,27 +167,9 @@ di.runSetups().then(() => { clusterStore.provideInitialFromMain(); - // HotbarStore depends on: ClusterStore - HotbarStore.createInstance(); + HelmRepoManager.createInstance(); - WeblinkStore.createInstance(); - - syncWeblinks(); - - HelmRepoManager.createInstance(); // create the instance - - const router = di.inject(routerInjectable); - const shellApiRequest = di.inject(shellApiRequestInjectable); - - const lensProxy = LensProxy.createInstance(router, { - getClusterForRequest: (req) => ClusterManager.getInstance().getClusterForRequest(req), - kubeApiRequest, - shellApiRequest, - }); - - ClusterManager.createInstance().init(); - - initializers.initClusterMetadataDetectors(); + const lensProxy = di.inject(lensProxyInjectableInjectable); try { logger.info("🔌 Starting LensProxy"); @@ -192,7 +183,8 @@ di.runSetups().then(() => { // test proxy connection try { logger.info("🔎 Testing LensProxy connection ..."); - const versionFromProxy = await getAppVersionFromProxyServer(lensProxy.port); + const getProxyPort = di.inject(getProxyPortInjectable); + const versionFromProxy = await getAppVersionFromProxyServer(getProxyPort.get()); if (getAppVersion() !== versionFromProxy) { logger.error("Proxy server responded with invalid response"); @@ -226,29 +218,27 @@ di.runSetups().then(() => { const extensionDiscovery = di.inject(extensionDiscoveryInjectable); extensionDiscovery.init(); + installDeveloperTools(); + + logger.info("🖥️ Starting WindowManager"); // Start the app without showing the main window when auto starting on login // (On Windows and Linux, we get a flag. On MacOS, we get special API.) const startHidden = process.argv.includes("--hidden") || (isMac && app.getLoginItemSettings().wasOpenedAsHidden); - logger.info("🖥️ Starting WindowManager"); - const windowManager = WindowManager.createInstance(); + if (!startHidden) { + di.inject(windowManagerInjectable).ensureMainWindow(); + } - const menuItems = di.inject(electronMenuItemsInjectable); - const trayMenuItems = di.inject(trayMenuItemsInjectable); + const initAppMenu = di.inject(initAppMenuInjectable); + const initTrayIcon = di.inject(initTrayIconInjectable); onQuitCleanup.push( - initMenu(windowManager, menuItems), - initTray(windowManager, trayMenuItems), + initAppMenu(), + initTrayIcon(), () => ShellSession.cleanup(), ); - installDeveloperTools(); - - if (!startHidden) { - windowManager.ensureMainWindow(); - } - ipcMainOn(IpcRendererNavigationEvents.LOADED, async () => { onCloseCleanup.push(pushCatalogToRenderer(catalogEntityRegistry)); @@ -260,7 +250,7 @@ di.runSetups().then(() => { kubeConfigSyncManager.startSync(); - startUpdateChecking(); + di.inject(startUpdateCheckingInjectable)(); lensProtocolRouterMain.rendererLoaded = true; }); @@ -298,7 +288,7 @@ di.runSetups().then(() => { logger.info("APP:ACTIVATE", { hasVisibleWindows }); if (!hasVisibleWindows) { - WindowManager.getInstance(false)?.ensureMainWindow(false); + di.inject(windowManagerInjectable).ensureMainWindow(false); } }); @@ -316,11 +306,9 @@ di.runSetups().then(() => { logger.debug("will-quit message"); // This is called when the close button of the main window is clicked - - logger.info("APP:QUIT"); appEventBus.emit({ name: "app", action: "close" }); - ClusterManager.getInstance(false)?.stop(); // close cluster connections + di.inject(clusterManagerInjectable).stop(); // close cluster connections const kubeConfigSyncManager = di.inject(kubeconfigSyncManagerInjectable); @@ -353,19 +341,18 @@ di.runSetups().then(() => { }); logger.debug("[APP-MAIN] waiting for 'ready' and other messages"); -}); +} + +main(); /** * Exports for virtual package "@k8slens/extensions" for main-process. * All exporting names available in global runtime scope: * e.g. global.Mobx, global.LensExtensions */ -const LensExtensions = { +export const LensExtensions = { Common: LensExtensionsCommonApi, Main: LensExtensionsMainApi, }; -export { - Mobx, - LensExtensions, -}; +export { Mobx }; diff --git a/src/main/initializers/cluster-metadata-detectors.ts b/src/main/initializers/cluster-metadata-detectors.ts deleted file mode 100644 index 95d724bf3f..0000000000 --- a/src/main/initializers/cluster-metadata-detectors.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import { ClusterIdDetector } from "../cluster-detectors/cluster-id-detector"; -import { DetectorRegistry } from "../cluster-detectors/detector-registry"; -import { DistributionDetector } from "../cluster-detectors/distribution-detector"; -import { LastSeenDetector } from "../cluster-detectors/last-seen-detector"; -import { NodesCountDetector } from "../cluster-detectors/nodes-count-detector"; -import { VersionDetector } from "../cluster-detectors/version-detector"; - -export function initClusterMetadataDetectors() { - DetectorRegistry.createInstance() - .add(ClusterIdDetector) - .add(LastSeenDetector) - .add(VersionDetector) - .add(DistributionDetector) - .add(NodesCountDetector); -} diff --git a/src/main/initializers/index.ts b/src/main/initializers/index.ts deleted file mode 100644 index 233376a91f..0000000000 --- a/src/main/initializers/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ -export * from "./metrics-providers"; -export * from "./cluster-metadata-detectors"; diff --git a/src/main/initializers/init-ipc-main-handlers/init-ipc-main-handlers.ts b/src/main/initializers/init-ipc-main-handlers.injectable.ts similarity index 56% rename from src/main/initializers/init-ipc-main-handlers/init-ipc-main-handlers.ts rename to src/main/initializers/init-ipc-main-handlers.injectable.ts index 53d2ed9567..72e29aca38 100644 --- a/src/main/initializers/init-ipc-main-handlers/init-ipc-main-handlers.ts +++ b/src/main/initializers/init-ipc-main-handlers.injectable.ts @@ -4,59 +4,75 @@ */ import { BrowserWindow, dialog, IpcMainInvokeEvent, Menu } from "electron"; -import { clusterFrameMap } from "../../../common/cluster-frames"; -import { clusterActivateHandler, clusterSetFrameIdHandler, clusterVisibilityHandler, clusterRefreshHandler, clusterDisconnectHandler, clusterKubectlApplyAllHandler, clusterKubectlDeleteAllHandler, clusterDeleteHandler, clusterSetDeletingHandler, clusterClearDeletingHandler } from "../../../common/cluster-ipc"; -import type { ClusterId } from "../../../common/cluster-types"; -import { ClusterStore } from "../../../common/cluster-store/cluster-store"; -import { appEventBus } from "../../../common/app-event-bus/event-bus"; -import { dialogShowOpenDialogHandler, ipcMainHandle, ipcMainOn } from "../../../common/ipc"; -import { catalogEntityRegistry } from "../../catalog"; -import { pushCatalogToRenderer } from "../../catalog-pusher"; -import { ClusterManager } from "../../cluster-manager"; -import { ResourceApplier } from "../../resource-applier"; -import { IpcMainWindowEvents, WindowManager } from "../../window-manager"; +import { clusterFrameMap } from "../../common/cluster-frames"; +import { clusterActivateHandler, clusterSetFrameIdHandler, clusterVisibilityHandler, clusterRefreshHandler, clusterDisconnectHandler, clusterKubectlApplyAllHandler, clusterKubectlDeleteAllHandler, clusterDeleteHandler, clusterSetDeletingHandler, clusterClearDeletingHandler } from "../../common/cluster-ipc"; +import type { ClusterId } from "../../common/cluster-types"; +import type { ClusterStore } from "../../common/cluster-store/store"; +import { appEventBus } from "../../common/app-event-bus/event-bus"; +import { dialogShowOpenDialogHandler, ipcMainHandle, ipcMainOn } from "../../common/ipc"; +import { pushCatalogToRenderer } from "../catalog-pusher"; +import type { ClusterManager } from "../cluster-manager/cluster-manager"; +import { ResourceApplier } from "../resource-applier"; +import { IpcMainWindowEvents, WindowManager } from "../windows/manager"; import path from "path"; import { remove } from "fs-extra"; -import { getAppMenu } from "../../menu/menu"; -import type { MenuRegistration } from "../../menu/menu-registration"; -import type { IComputedValue } from "mobx"; +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { bind } from "../../renderer/utils"; +import clusterManagerInjectable from "../cluster-manager/cluster-manager.injectable"; +import catalogEntityRegistryInjectable from "../catalog/entity-registry.injectable"; +import type { CatalogEntityRegistry } from "../catalog"; +import clusterStoreInjectable from "../../common/cluster-store/store.injectable"; +import directoryForLensLocalStorageInjectable from "../../common/directory-for-lens-local-storage/directory-for-lens-local-storage.injectable"; +import windowManagerInjectable from "../windows/manager.injectable"; +import buildMenuInjectable from "../menu/build-menu.injectable"; -interface Dependencies { - electronMenuItems: IComputedValue, +interface InitIpcMainHandlersDependencies { + clusterStore: ClusterStore; + entityRegistry: CatalogEntityRegistry; + clusterManager: ClusterManager; + windowManager: WindowManager; directoryForLensLocalStorage: string; + buildMenu: () => Menu; } -export const initIpcMainHandlers = ({ electronMenuItems, directoryForLensLocalStorage }: Dependencies) => () => { +function initIpcMainHandlers({ + entityRegistry, + clusterStore, + clusterManager, + windowManager, + directoryForLensLocalStorage, + buildMenu, +}: InitIpcMainHandlersDependencies) { ipcMainHandle(clusterActivateHandler, (event, clusterId: ClusterId, force = false) => { - return ClusterStore.getInstance() + return clusterStore .getById(clusterId) ?.activate(force); }); ipcMainHandle(clusterSetFrameIdHandler, (event: IpcMainInvokeEvent, clusterId: ClusterId) => { - const cluster = ClusterStore.getInstance().getById(clusterId); + const cluster = clusterStore.getById(clusterId); if (cluster) { clusterFrameMap.set(cluster.id, { frameId: event.frameId, processId: event.processId }); cluster.pushState(); - pushCatalogToRenderer(catalogEntityRegistry); + pushCatalogToRenderer(entityRegistry); } }); ipcMainOn(clusterVisibilityHandler, (event, clusterId?: ClusterId) => { - ClusterManager.getInstance().visibleCluster = clusterId; + clusterManager.visibleCluster = clusterId; }); ipcMainHandle(clusterRefreshHandler, (event, clusterId: ClusterId) => { - return ClusterStore.getInstance() + return clusterStore .getById(clusterId) ?.refresh({ refreshMetadata: true }); }); ipcMainHandle(clusterDisconnectHandler, (event, clusterId: ClusterId) => { appEventBus.emit({ name: "cluster", action: "stop" }); - const cluster = ClusterStore.getInstance().getById(clusterId); + const cluster = clusterStore.getById(clusterId); if (cluster) { cluster.disconnect(); @@ -67,7 +83,6 @@ export const initIpcMainHandlers = ({ electronMenuItems, directoryForLensLocalSt ipcMainHandle(clusterDeleteHandler, async (event, clusterId: ClusterId) => { appEventBus.emit({ name: "cluster", action: "remove" }); - const clusterStore = ClusterStore.getInstance(); const cluster = clusterStore.getById(clusterId); if (!cluster) { @@ -91,16 +106,16 @@ export const initIpcMainHandlers = ({ electronMenuItems, directoryForLensLocalSt }); ipcMainHandle(clusterSetDeletingHandler, (event, clusterId: string) => { - ClusterManager.getInstance().deleting.add(clusterId); + clusterManager.deleting.add(clusterId); }); ipcMainHandle(clusterClearDeletingHandler, (event, clusterId: string) => { - ClusterManager.getInstance().deleting.delete(clusterId); + clusterManager.deleting.delete(clusterId); }); ipcMainHandle(clusterKubectlApplyAllHandler, async (event, clusterId: ClusterId, resources: string[], extraArgs: string[]) => { appEventBus.emit({ name: "cluster", action: "kubectl-apply-all" }); - const cluster = ClusterStore.getInstance().getById(clusterId); + const cluster = clusterStore.getById(clusterId); if (cluster) { const applier = new ResourceApplier(cluster); @@ -119,7 +134,7 @@ export const initIpcMainHandlers = ({ electronMenuItems, directoryForLensLocalSt ipcMainHandle(clusterKubectlDeleteAllHandler, async (event, clusterId: ClusterId, resources: string[], extraArgs: string[]) => { appEventBus.emit({ name: "cluster", action: "kubectl-delete-all" }); - const cluster = ClusterStore.getInstance().getById(clusterId); + const cluster = clusterStore.getById(clusterId); if (cluster) { const applier = new ResourceApplier(cluster); @@ -137,20 +152,31 @@ export const initIpcMainHandlers = ({ electronMenuItems, directoryForLensLocalSt }); ipcMainHandle(dialogShowOpenDialogHandler, async (event, dialogOpts: Electron.OpenDialogOptions) => { - await WindowManager.getInstance().ensureMainWindow(); + await windowManager.ensureMainWindow(); return dialog.showOpenDialog(BrowserWindow.getFocusedWindow(), dialogOpts); }); - ipcMainOn(IpcMainWindowEvents.OPEN_CONTEXT_MENU, async (event) => { - const menu = Menu.buildFromTemplate(getAppMenu(WindowManager.getInstance(), electronMenuItems.get())); - const options = { + ipcMainOn(IpcMainWindowEvents.OPEN_CONTEXT_MENU, (event) => { + buildMenu().popup({ ...BrowserWindow.fromWebContents(event.sender), // Center of the topbar menu icon x: 20, y: 20, - } as Electron.PopupOptions; - - menu.popup(options); + }); }); -}; +} + +const initIpcMainHandlersInjectable = getInjectable({ + instantiate: (di) => bind(initIpcMainHandlers, null, { + clusterManager: di.inject(clusterManagerInjectable), + clusterStore: di.inject(clusterStoreInjectable), + windowManager: di.inject(windowManagerInjectable), + entityRegistry: di.inject(catalogEntityRegistryInjectable), + directoryForLensLocalStorage: di.inject(directoryForLensLocalStorageInjectable), + buildMenu: di.inject(buildMenuInjectable), + }), + lifecycle: lifecycleEnum.singleton, +}); + +export default initIpcMainHandlersInjectable; diff --git a/src/main/initializers/init-ipc-main-handlers/init-ipc-main-handlers.injectable.ts b/src/main/initializers/init-ipc-main-handlers/init-ipc-main-handlers.injectable.ts deleted file mode 100644 index 5a9950e189..0000000000 --- a/src/main/initializers/init-ipc-main-handlers/init-ipc-main-handlers.injectable.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ -import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; -import electronMenuItemsInjectable from "../../menu/electron-menu-items.injectable"; -import directoryForLensLocalStorageInjectable - from "../../../common/directory-for-lens-local-storage/directory-for-lens-local-storage.injectable"; -import { initIpcMainHandlers } from "./init-ipc-main-handlers"; - -const initIpcMainHandlersInjectable = getInjectable({ - instantiate: (di) => initIpcMainHandlers({ - electronMenuItems: di.inject(electronMenuItemsInjectable), - directoryForLensLocalStorage: di.inject(directoryForLensLocalStorageInjectable), - }), - - lifecycle: lifecycleEnum.singleton, -}); - -export default initIpcMainHandlersInjectable; diff --git a/src/main/k8s-api/create-kube-json-api-for-cluster.injectable.ts b/src/main/k8s-api/create-kube-json-api-for-cluster.injectable.ts new file mode 100644 index 0000000000..6bacd80540 --- /dev/null +++ b/src/main/k8s-api/create-kube-json-api-for-cluster.injectable.ts @@ -0,0 +1,37 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import type { IComputedValue } from "mobx"; +import { bind } from "../../common/utils"; +import { apiKubePrefix, isDebugging } from "../../common/vars"; +import { KubeJsonApi } from "../../common/k8s-api/kube-json-api"; +import getProxyPortInjectable from "../lens-proxy/get-proxy-port.injectable"; + +interface Dependencies { + proxyPort: IComputedValue; +} + +function createKubeJsonApiForCluster({ proxyPort }: Dependencies, clusterId: string): KubeJsonApi { + const port = proxyPort.get(); + + return new KubeJsonApi({ + serverAddress: `http://127.0.0.1:${port}`, + apiBase: apiKubePrefix, + debug: isDebugging, + }, { + headers: { + "Host": `${clusterId}.localhost:${port}`, + }, + }); +} + +const createKubeJsonApiForClusterInjectable = getInjectable({ + instantiate: (di) => bind(createKubeJsonApiForCluster, null, { + proxyPort: di.inject(getProxyPortInjectable), + }), + lifecycle: lifecycleEnum.singleton, +}); + +export default createKubeJsonApiForClusterInjectable; diff --git a/src/main/k8s-api/get-metrics.injectable.ts b/src/main/k8s-api/get-metrics.injectable.ts new file mode 100644 index 0000000000..bdf486af3e --- /dev/null +++ b/src/main/k8s-api/get-metrics.injectable.ts @@ -0,0 +1,40 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import type { RequestPromiseOptions } from "request-promise-native"; +import type { Cluster } from "../../common/cluster/cluster"; +import type { IMetricsReqParams } from "../../common/k8s-api/endpoints/metrics.api"; +import { bind } from "../../common/utils"; +import k8sRequestInjectable from "./k8s-request.injectable"; + +interface Dependencies { + k8sRequest: (cluster: Cluster, path: string, options?: RequestPromiseOptions) => Promise; +} + +export interface GetMetricsReqParams extends IMetricsReqParams { + query: string; +} + +function getMetrics({ k8sRequest }: Dependencies, cluster: Cluster, prometheusPath: string, queryParams: GetMetricsReqParams): Promise { + const prometheusPrefix = cluster.preferences.prometheus?.prefix || ""; + const metricsPath = `/api/v1/namespaces/${prometheusPath}/proxy${prometheusPrefix}/api/v1/query_range`; + + return k8sRequest(cluster, metricsPath, { + timeout: 0, + resolveWithFullResponse: false, + json: true, + method: "POST", + form: queryParams, + }); +} + +const getMetricsInjectable = getInjectable({ + instantiate: (di) => bind(getMetrics, null, { + k8sRequest: di.inject(k8sRequestInjectable), + }), + lifecycle: lifecycleEnum.singleton, +}); + +export default getMetricsInjectable; diff --git a/src/main/k8s-api/k8s-request.injectable.ts b/src/main/k8s-api/k8s-request.injectable.ts new file mode 100644 index 0000000000..8964d6d82e --- /dev/null +++ b/src/main/k8s-api/k8s-request.injectable.ts @@ -0,0 +1,35 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import type { IComputedValue } from "mobx"; +import request, { RequestPromiseOptions } from "request-promise-native"; +import type { Cluster } from "../../common/cluster/cluster"; +import { bind } from "../../common/utils"; +import { apiKubePrefix } from "../../common/vars"; +import getProxyPortInjectable from "../lens-proxy/get-proxy-port.injectable"; + +interface Dependencies { + proxyPort: IComputedValue; +} + +function k8sRequest({ proxyPort }: Dependencies, cluster: Cluster, path: string, options: RequestPromiseOptions = {}): Promise { + const kubeProxyUrl = `http://localhost:${proxyPort.get()}${apiKubePrefix}`; + + options.headers ??= {}; + options.json ??= true; + options.timeout ??= 30000; + options.headers.Host = `${cluster.id}.${new URL(kubeProxyUrl).host}`; // required in ClusterManager.getClusterForRequest() + + return request(kubeProxyUrl + path, options); +} + +const k8sRequestInjectable = getInjectable({ + instantiate: (di) => bind(k8sRequest, null, { + proxyPort: di.inject(getProxyPortInjectable), + }), + lifecycle: lifecycleEnum.singleton, +}); + +export default k8sRequestInjectable; diff --git a/src/main/k8s-request.ts b/src/main/k8s-request.ts deleted file mode 100644 index 43002f0b47..0000000000 --- a/src/main/k8s-request.ts +++ /dev/null @@ -1,34 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import request, { RequestPromiseOptions } from "request-promise-native"; -import { apiKubePrefix } from "../common/vars"; -import type { IMetricsReqParams } from "../common/k8s-api/endpoints/metrics.api"; -import { LensProxy } from "./lens-proxy"; -import type { Cluster } from "../common/cluster/cluster"; - -export async function k8sRequest(cluster: Cluster, path: string, options: RequestPromiseOptions = {}): Promise { - const kubeProxyUrl = `http://localhost:${LensProxy.getInstance().port}${apiKubePrefix}`; - - options.headers ??= {}; - options.json ??= true; - options.timeout ??= 30000; - options.headers.Host = `${cluster.id}.${new URL(kubeProxyUrl).host}`; // required in ClusterManager.getClusterForRequest() - - return request(kubeProxyUrl + path, options); -} - -export async function getMetrics(cluster: Cluster, prometheusPath: string, queryParams: IMetricsReqParams & { query: string }): Promise { - const prometheusPrefix = cluster.preferences.prometheus?.prefix || ""; - const metricsPath = `/api/v1/namespaces/${prometheusPath}/proxy${prometheusPrefix}/api/v1/query_range`; - - return k8sRequest(cluster, metricsPath, { - timeout: 0, - resolveWithFullResponse: false, - json: true, - method: "POST", - form: queryParams, - }); -} diff --git a/src/main/kube-auth-proxy/create-kube-auth-proxy.injectable.ts b/src/main/kube-auth-proxy/create-kube-auth-proxy.injectable.ts index 129d7b3eb9..4a568aeb64 100644 --- a/src/main/kube-auth-proxy/create-kube-auth-proxy.injectable.ts +++ b/src/main/kube-auth-proxy/create-kube-auth-proxy.injectable.ts @@ -4,20 +4,13 @@ */ import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; import { KubeAuthProxy } from "./kube-auth-proxy"; -import type { Cluster } from "../../common/cluster/cluster"; -import bundledKubectlInjectable from "../kubectl/bundled-kubectl.injectable"; +import bundledKubectlPathInjectable from "../kubectl/get-bundled-path.injectable"; +import { bind } from "../../common/utils"; const createKubeAuthProxyInjectable = getInjectable({ - instantiate: (di) => { - const bundledKubectl = di.inject(bundledKubectlInjectable); - - const dependencies = { - getProxyBinPath: bundledKubectl.getPath, - }; - - return (cluster: Cluster, environmentVariables: NodeJS.ProcessEnv) => - new KubeAuthProxy(dependencies, cluster, environmentVariables); - }, + instantiate: (di) => bind(KubeAuthProxy.create, null, { + bundledKubectlPath: di.inject(bundledKubectlPathInjectable), + }), lifecycle: lifecycleEnum.singleton, }); diff --git a/src/main/kube-auth-proxy/kube-auth-proxy.ts b/src/main/kube-auth-proxy/kube-auth-proxy.ts index 1e96d69980..00a7fba1cf 100644 --- a/src/main/kube-auth-proxy/kube-auth-proxy.ts +++ b/src/main/kube-auth-proxy/kube-auth-proxy.ts @@ -15,7 +15,7 @@ import { makeObservable, observable, when } from "mobx"; const startingServeRegex = /^starting to serve on (?

.+)/i; interface Dependencies { - getProxyBinPath: () => Promise; + bundledKubectlPath: string; } export class KubeAuthProxy { @@ -30,6 +30,10 @@ export class KubeAuthProxy { protected readonly acceptHosts: string; @observable protected ready = false; + static create(...args: ConstructorParameters) { + return new KubeAuthProxy(...args); + } + constructor(private dependencies: Dependencies, protected readonly cluster: Cluster, protected readonly env: NodeJS.ProcessEnv) { makeObservable(this); @@ -45,7 +49,6 @@ export class KubeAuthProxy { return this.whenReady; } - const proxyBin = await this.dependencies.getProxyBinPath(); const args = [ "proxy", "-p", "0", @@ -61,7 +64,7 @@ export class KubeAuthProxy { } logger.debug(`spawning kubectl proxy with args: ${args}`); - this.proxyProcess = spawn(proxyBin, args, { env: this.env }); + this.proxyProcess = spawn(this.dependencies.bundledKubectlPath, args, { env: this.env }); this.proxyProcess.on("error", (error) => { this.cluster.broadcastConnectUpdate(error.message, true); this.exit(); diff --git a/src/main/kubeconfig-manager/create-kubeconfig-manager.injectable.ts b/src/main/kubeconfig-manager/create-kubeconfig-manager.injectable.ts index 49eb4681e5..54cd93c28d 100644 --- a/src/main/kubeconfig-manager/create-kubeconfig-manager.injectable.ts +++ b/src/main/kubeconfig-manager/create-kubeconfig-manager.injectable.ts @@ -3,22 +3,16 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; -import type { Cluster } from "../../common/cluster/cluster"; -import directoryForTempInjectable from "../../common/app-paths/directory-for-temp/directory-for-temp.injectable"; +import directoryForTempInjectable from "../../common/app-paths/directory-for-temp.injectable"; import { KubeconfigManager } from "./kubeconfig-manager"; - -export interface KubeConfigManagerInstantiationParameter { - cluster: Cluster; -} +import getProxyPortInjectable from "../lens-proxy/get-proxy-port.injectable"; +import { bind } from "../../common/utils"; const createKubeconfigManagerInjectable = getInjectable({ - instantiate: (di) => { - const dependencies = { - directoryForTemp: di.inject(directoryForTempInjectable), - }; - - return (cluster: Cluster) => new KubeconfigManager(dependencies, cluster); - }, + instantiate: (di) => bind(KubeconfigManager.create, null, { + directoryForTemp: di.inject(directoryForTempInjectable), + proxyPort: di.inject(getProxyPortInjectable), + }), lifecycle: lifecycleEnum.singleton, }); diff --git a/src/main/kubeconfig-manager/kubeconfig-manager.ts b/src/main/kubeconfig-manager/kubeconfig-manager.ts index 7fd2ad4c42..1bf8f58300 100644 --- a/src/main/kubeconfig-manager/kubeconfig-manager.ts +++ b/src/main/kubeconfig-manager/kubeconfig-manager.ts @@ -10,10 +10,11 @@ import path from "path"; import fs from "fs-extra"; import { dumpConfigYaml } from "../../common/kube-helpers"; import logger from "../logger"; -import { LensProxy } from "../lens-proxy"; +import type { IComputedValue } from "mobx"; interface Dependencies { - directoryForTemp: string + directoryForTemp: string; + proxyPort: IComputedValue; } export class KubeconfigManager { @@ -28,6 +29,10 @@ export class KubeconfigManager { protected contextHandler: ContextHandler; + static create(...args: ConstructorParameters) { + return new KubeconfigManager(...args); + } + constructor(private dependencies: Dependencies, protected cluster: Cluster) { this.contextHandler = cluster.contextHandler; } @@ -78,15 +83,12 @@ export class KubeconfigManager { } } - get resolveProxyUrl() { - return `http://127.0.0.1:${LensProxy.getInstance().port}/${this.cluster.id}`; - } - /** * Creates new "temporary" kubeconfig that point to the kubectl-proxy. * This way any user of the config does not need to know anything about the auth etc. details. */ protected async createProxyKubeconfig(): Promise { + const resolveProxyUrl = `http://127.0.0.1:${this.dependencies.proxyPort.get()}/${this.cluster.id}`; const { cluster } = this; const { contextName, id } = cluster; const tempFile = path.join( @@ -99,7 +101,7 @@ export class KubeconfigManager { clusters: [ { name: contextName, - server: this.resolveProxyUrl, + server: resolveProxyUrl, skipTLSVerify: undefined, }, ], diff --git a/src/main/kubectl/bundled-kubectl.injectable.ts b/src/main/kubectl/bundled-kubectl.injectable.ts index 47d42741c0..82c7b04e99 100644 --- a/src/main/kubectl/bundled-kubectl.injectable.ts +++ b/src/main/kubectl/bundled-kubectl.injectable.ts @@ -10,9 +10,7 @@ const bundledKubectlInjectable = getInjectable({ instantiate: (di) => { const createKubectl = di.inject(createKubectlInjectable); - const bundledKubectlVersion = getBundledKubectlVersion(); - - return createKubectl(bundledKubectlVersion); + return createKubectl(getBundledKubectlVersion()); }, lifecycle: lifecycleEnum.singleton, diff --git a/src/main/kubectl/create-kubectl.injectable.ts b/src/main/kubectl/create-kubectl.injectable.ts index 305427da1a..dca430552a 100644 --- a/src/main/kubectl/create-kubectl.injectable.ts +++ b/src/main/kubectl/create-kubectl.injectable.ts @@ -4,22 +4,15 @@ */ import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; import { Kubectl } from "./kubectl"; -import directoryForKubectlBinariesInjectable from "./directory-for-kubectl-binaries/directory-for-kubectl-binaries.injectable"; -import userStoreInjectable from "../../common/user-store/user-store.injectable"; +import directoryForKubectlBinariesInjectable from "./directory-for-kubectl-binaries.injectable"; +import userPreferencesStoreInjectable from "../../common/user-preferences/store.injectable"; +import { bind } from "../../common/utils"; const createKubectlInjectable = getInjectable({ - instantiate: (di) => { - const dependencies = { - userStore: di.inject(userStoreInjectable), - - directoryForKubectlBinaries: di.inject( - directoryForKubectlBinariesInjectable, - ), - }; - - return (clusterVersion: string) => - new Kubectl(dependencies, clusterVersion); - }, + instantiate: (di) => bind(Kubectl.create, null, { + userStore: di.inject(userPreferencesStoreInjectable), + directoryForKubectlBinaries: di.inject(directoryForKubectlBinariesInjectable), + }), lifecycle: lifecycleEnum.singleton, }); diff --git a/src/main/kubectl/directory-for-kubectl-binaries/directory-for-kubectl-binaries.injectable.ts b/src/main/kubectl/directory-for-kubectl-binaries.injectable.ts similarity index 64% rename from src/main/kubectl/directory-for-kubectl-binaries/directory-for-kubectl-binaries.injectable.ts rename to src/main/kubectl/directory-for-kubectl-binaries.injectable.ts index 1757f06c2a..d0689eb9a6 100644 --- a/src/main/kubectl/directory-for-kubectl-binaries/directory-for-kubectl-binaries.injectable.ts +++ b/src/main/kubectl/directory-for-kubectl-binaries.injectable.ts @@ -3,13 +3,12 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; -import directoryForBinariesInjectable from "../../../common/app-paths/directory-for-binaries/directory-for-binaries.injectable"; +import directoryForBinariesInjectable from "../../common/app-paths/directory-for-binaries.injectable"; import path from "path"; const directoryForKubectlBinariesInjectable = getInjectable({ - instantiate: (di) => - path.join(di.inject(directoryForBinariesInjectable), "kubectl"), - + instantiate: (di) => path.join(di.inject(directoryForBinariesInjectable), "kubectl"), + lifecycle: lifecycleEnum.singleton, }); diff --git a/src/main/kubectl/get-bundled-path.injectable.ts b/src/main/kubectl/get-bundled-path.injectable.ts new file mode 100644 index 0000000000..057c663d6f --- /dev/null +++ b/src/main/kubectl/get-bundled-path.injectable.ts @@ -0,0 +1,13 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { bundledKubectlPath } from "./kubectl"; + +const bundledKubectlPathInjectable = getInjectable({ + instantiate: () => bundledKubectlPath, + lifecycle: lifecycleEnum.singleton, +}); + +export default bundledKubectlPathInjectable; diff --git a/src/main/kubectl/kubectl.ts b/src/main/kubectl/kubectl.ts index 80bdbbd66c..de77195264 100644 --- a/src/main/kubectl/kubectl.ts +++ b/src/main/kubectl/kubectl.ts @@ -13,7 +13,7 @@ import { helmCli } from "../helm/helm-cli"; import { getBundledKubectlVersion } from "../../common/utils/app-version"; import { isDevelopment, isWindows, isTestEnv } from "../../common/vars"; import { SemVer } from "semver"; -import { defaultPackageMirror, packageMirrors } from "../../common/user-store/preferences-helpers"; +import { defaultPackageMirror, packageMirrors } from "../../common/user-preferences/preferences-helpers"; import got from "got/dist/source"; import { promisify } from "util"; import stream from "stream"; @@ -37,11 +37,10 @@ const kubectlMap: Map = new Map([ ["1.20", "1.20.8"], ["1.21", bundledVersion], ]); -let bundledPath: string; const initScriptVersionString = "# lens-initscript v3"; -export function bundledKubectlPath(): string { - if (bundledPath) { return bundledPath; } +export const bundledKubectlPath = (() => { + let bundledPath: string; if (isDevelopment || isTestEnv) { const platformName = isWindows ? "windows" : process.platform; @@ -56,7 +55,7 @@ export function bundledKubectlPath(): string { } return bundledPath; -} +})(); interface Dependencies { directoryForKubectlBinaries: string; @@ -79,6 +78,10 @@ export class Kubectl { public static readonly bundledKubectlVersion: string = bundledVersion; public static invalidBundle = false; + static create(...args: ConstructorParameters) { + return new Kubectl(...args); + } + constructor(private dependencies: Dependencies, clusterVersion: string) { let version: SemVer; @@ -119,12 +122,8 @@ export class Kubectl { this.path = path.join(this.dirname, binaryName); } - public getBundledPath() { - return bundledKubectlPath(); - } - public getPathFromPreferences() { - return this.dependencies.userStore.kubectlBinariesPath || this.getBundledPath(); + return this.dependencies.userStore.kubectlBinariesPath || bundledKubectlPath; } protected getDownloadDir() { @@ -137,7 +136,7 @@ export class Kubectl { public getPath = async (bundled = false): Promise => { if (bundled) { - return this.getBundledPath(); + return bundledKubectlPath; } if (this.dependencies.userStore.downloadKubectlBinaries === false) { @@ -145,17 +144,17 @@ export class Kubectl { } // return binary name if bundled path is not functional - if (!await this.checkBinary(this.getBundledPath(), false)) { + if (!await this.checkBinary(bundledKubectlPath, false)) { Kubectl.invalidBundle = true; - return path.basename(this.getBundledPath()); + return path.basename(bundledKubectlPath); } try { if (!await this.ensureKubectl()) { logger.error("Failed to ensure kubectl, fallback to the bundled version"); - return this.getBundledPath(); + return bundledKubectlPath; } return this.path; @@ -163,7 +162,7 @@ export class Kubectl { logger.error("Failed to ensure kubectl, fallback to the bundled version"); logger.error(err); - return this.getBundledPath(); + return bundledKubectlPath; } }; @@ -223,7 +222,7 @@ export class Kubectl { const exist = await pathExists(this.path); if (!exist) { - await fs.promises.copyFile(this.getBundledPath(), this.path); + await fs.promises.copyFile(bundledKubectlPath, this.path); await fs.promises.chmod(this.path, 0o755); } diff --git a/src/main/lens-binary.ts b/src/main/lens-binary.ts index 2b8b5c27d3..9a9b14eed0 100644 --- a/src/main/lens-binary.ts +++ b/src/main/lens-binary.ts @@ -133,7 +133,7 @@ export class LensBinary { } } - protected async untarBinary() { + protected untarBinary() { return new Promise(resolve => { this.logger.debug(`Extracting ${this.originalBinaryName} binary`); tar.x({ @@ -145,7 +145,7 @@ export class LensBinary { }); } - protected async renameBinary() { + protected renameBinary() { return new Promise((resolve, reject) => { this.logger.debug(`Renaming ${this.originalBinaryName} binary to ${this.binaryName}`); fs.rename(this.getOriginalBinaryPath(), this.getBinaryPath(), (err) => { diff --git a/src/main/lens-proxy/get-proxy-port.injectable.ts b/src/main/lens-proxy/get-proxy-port.injectable.ts new file mode 100644 index 0000000000..d1c74929f6 --- /dev/null +++ b/src/main/lens-proxy/get-proxy-port.injectable.ts @@ -0,0 +1,26 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { computed } from "mobx"; +import proxyPortStateInjectable from "./proxy-port.state.injectable"; + +const getProxyPortInjectable = getInjectable({ + instantiate: (di) => { + const state = di.inject(proxyPortStateInjectable); + + return computed(() => { + const port = state.get(); + + if (typeof port !== "number") { + throw new Error("Proxy port has not yet been set"); + } + + return port; + }); + }, + lifecycle: lifecycleEnum.singleton, +}); + +export default getProxyPortInjectable; diff --git a/src/main/lens-proxy/lens-proxy.injectable.ts b/src/main/lens-proxy/lens-proxy.injectable.ts new file mode 100644 index 0000000000..df967c2788 --- /dev/null +++ b/src/main/lens-proxy/lens-proxy.injectable.ts @@ -0,0 +1,24 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import getClusterForRequestInjectable from "../cluster-manager/get-cluster-for-request.injectable"; +import kubeApiRequestInjectable from "../proxy-functions/kube-api-request.injectable"; +import shellApiRequestHandlerInjectable from "../proxy-functions/shell-api-request/shell-api-request.injectable"; +import routerInjectable from "../router/router.injectable"; +import { LensProxy } from "./lens-proxy"; +import setProxyPortInjectable from "./set-proxy-port.injectable"; + +const lensProxyInjectableInjectable = getInjectable({ + instantiate: (di) => new LensProxy({ + getClusterForRequest: di.inject(getClusterForRequestInjectable), + kubeApiRequest: di.inject(kubeApiRequestInjectable), + shellApiRequest: di.inject(shellApiRequestHandlerInjectable), + route: di.inject(routerInjectable).route, + setProxyPort: di.inject(setProxyPortInjectable), + }), + lifecycle: lifecycleEnum.singleton, +}); + +export default lensProxyInjectableInjectable; diff --git a/src/main/lens-proxy.ts b/src/main/lens-proxy/lens-proxy.ts similarity index 83% rename from src/main/lens-proxy.ts rename to src/main/lens-proxy/lens-proxy.ts index 3a1c4a829e..e5ae231817 100644 --- a/src/main/lens-proxy.ts +++ b/src/main/lens-proxy/lens-proxy.ts @@ -7,23 +7,13 @@ import type net from "net"; import type http from "http"; import spdy from "spdy"; import httpProxy from "http-proxy"; -import { apiPrefix, apiKubePrefix } from "../common/vars"; -import type { Router } from "./router"; -import type { ContextHandler } from "./context-handler/context-handler"; -import logger from "./logger"; -import { Singleton } from "../common/utils"; -import type { Cluster } from "../common/cluster/cluster"; -import type { ProxyApiRequestArgs } from "./proxy-functions"; -import { appEventBus } from "../common/app-event-bus/event-bus"; -import { getBoolean } from "./utils/parse-query"; - -type GetClusterForRequest = (req: http.IncomingMessage) => Cluster | null; - -export interface LensProxyFunctions { - getClusterForRequest: GetClusterForRequest, - shellApiRequest: (args: ProxyApiRequestArgs) => void | Promise; - kubeApiRequest: (args: ProxyApiRequestArgs) => void | Promise; -} +import { apiPrefix, apiKubePrefix } from "../../common/vars"; +import type { ContextHandler } from "../context-handler/context-handler"; +import logger from "../logger"; +import type { Cluster } from "../../common/cluster/cluster"; +import { appEventBus } from "../../common/app-event-bus/event-bus"; +import { getBoolean } from "../utils/parse-query"; +import type { ProxyApiRequestArgs } from "../proxy-functions/types"; const watchParam = "watch"; const followParam = "follow"; @@ -52,21 +42,22 @@ const disallowedPorts = new Set([ 10080, ]); -export class LensProxy extends Singleton { +export interface LensProxyDependencies { + route: (cluster: Cluster, req: http.IncomingMessage, res: http.ServerResponse) => Promise; + getClusterForRequest: (req: http.IncomingMessage) => Cluster | null, + shellApiRequest: (args: ProxyApiRequestArgs) => void | Promise; + kubeApiRequest: (args: ProxyApiRequestArgs) => void | Promise; + setProxyPort: (port: number) => void; +} + +export class LensProxy { protected origin: string; protected proxyServer: http.Server; protected closed = false; protected retryCounters = new Map(); protected proxy = this.createProxy(); - protected getClusterForRequest: GetClusterForRequest; - - public port: number; - - constructor(protected router: Router, { shellApiRequest, kubeApiRequest, getClusterForRequest }: LensProxyFunctions) { - super(); - - this.getClusterForRequest = getClusterForRequest; + constructor(protected readonly dependencies: LensProxyDependencies) { this.proxyServer = spdy.createServer({ spdy: { plain: true, @@ -79,15 +70,17 @@ export class LensProxy extends Singleton { this.proxyServer .on("upgrade", (req: http.IncomingMessage, socket: net.Socket, head: Buffer) => { const isInternal = req.url.startsWith(`${apiPrefix}?`); - const cluster = getClusterForRequest(req); + const cluster = dependencies.getClusterForRequest(req); if (!cluster) { return void logger.error(`[LENS-PROXY]: Could not find cluster for upgrade request from url=${req.url}`); } - const reqHandler = isInternal ? shellApiRequest : kubeApiRequest; + const reqHandler = isInternal + ? dependencies.shellApiRequest + : dependencies.kubeApiRequest; - (async () => reqHandler({ req, socket, head, cluster }))() + (async () => await reqHandler({ req, socket, head, cluster }))() .catch(error => logger.error("[LENS-PROXY]: failed to handle proxy upgrade", error)); }); } @@ -114,7 +107,7 @@ export class LensProxy extends Singleton { logger.info(`[LENS-PROXY]: Subsequent error: ${error}`); }); - this.port = port; + this.dependencies.setProxyPort(port); appEventBus.emit({ name: "lens-proxy", action: "listen", params: { port }}); resolve(port); }) @@ -223,21 +216,23 @@ export class LensProxy extends Singleton { return proxy; } - protected async getProxyTarget(req: http.IncomingMessage, contextHandler: ContextHandler): Promise { + protected getProxyTarget(req: http.IncomingMessage, contextHandler: ContextHandler): Promise { if (req.url.startsWith(apiKubePrefix)) { delete req.headers.authorization; req.url = req.url.replace(apiKubePrefix, ""); return contextHandler.getApiTarget(isLongRunningRequest(req.url)); } + + return Promise.resolve(undefined); } protected getRequestId(req: http.IncomingMessage) { return req.headers.host + req.url; } - protected async handleRequest(req: http.IncomingMessage, res: http.ServerResponse) { - const cluster = this.getClusterForRequest(req); + protected async handleRequest(req: http.IncomingMessage, res: http.ServerResponse): Promise { + const cluster = this.dependencies.getClusterForRequest(req); if (cluster) { const proxyTarget = await this.getProxyTarget(req, cluster.contextHandler); @@ -246,6 +241,7 @@ export class LensProxy extends Singleton { return this.proxy.web(req, res, proxyTarget); } } - this.router.route(cluster, req, res); + + await this.dependencies.route(cluster, req, res); } } diff --git a/src/main/lens-proxy/proxy-port.state.injectable.ts b/src/main/lens-proxy/proxy-port.state.injectable.ts new file mode 100644 index 0000000000..03a97417d7 --- /dev/null +++ b/src/main/lens-proxy/proxy-port.state.injectable.ts @@ -0,0 +1,13 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { observable } from "mobx"; + +const proxyPortStateInjectable = getInjectable({ + instantiate: () => observable.box(), + lifecycle: lifecycleEnum.singleton, +}); + +export default proxyPortStateInjectable; diff --git a/src/main/lens-proxy/set-proxy-port.injectable.ts b/src/main/lens-proxy/set-proxy-port.injectable.ts new file mode 100644 index 0000000000..b6ed127e48 --- /dev/null +++ b/src/main/lens-proxy/set-proxy-port.injectable.ts @@ -0,0 +1,25 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import type { IObservableValue } from "mobx"; +import { bind } from "../../common/utils"; +import proxyPortStateInjectable from "./proxy-port.state.injectable"; + +interface Dependencies { + state: IObservableValue; +} + +function setProxyPort({ state }: Dependencies, newPort: number): void { + state.set(newPort); +} + +const setProxyPortInjectable = getInjectable({ + instantiate: (di) => bind(setProxyPort, null, { + state: di.inject(proxyPortStateInjectable), + }), + lifecycle: lifecycleEnum.singleton, +}); + +export default setProxyPortInjectable; diff --git a/src/main/menu/menu.ts b/src/main/menu/build-menu.injectable.ts similarity index 77% rename from src/main/menu/menu.ts rename to src/main/menu/build-menu.injectable.ts index 0d3875042e..7b2a486092 100644 --- a/src/main/menu/menu.ts +++ b/src/main/menu/build-menu.injectable.ts @@ -2,55 +2,36 @@ * Copyright (c) OpenLens Authors. All rights reserved. * Licensed under MIT License. See LICENSE in root directory for more information. */ -import { app, BrowserWindow, dialog, Menu, MenuItem, MenuItemConstructorOptions, webContents, shell } from "electron"; -import { autorun, IComputedValue } from "mobx"; -import type { WindowManager } from "../window-manager"; -import { appName, isMac, isWindows, docsUrl, supportUrl, productName } from "../../common/vars"; -import logger from "../logger"; -import { exitApp } from "../exit-app"; -import { broadcastMessage } from "../../common/ipc"; -import * as packageJson from "../../../package.json"; -import { preferencesURL, extensionsURL, addClusterURL, catalogURL, welcomeURL } from "../../common/routes"; -import { checkForUpdates, isAutoUpdateEnabled } from "../app-updater"; -import type { MenuRegistration } from "./menu-registration"; -export type MenuTopId = "mac" | "file" | "edit" | "view" | "help"; +import { app, BrowserWindow, Menu, MenuItem, MenuItemConstructorOptions, webContents, shell } from "electron"; +import type { WindowManager } from "../windows/manager"; +import { isMac, docsUrl, supportUrl, productName } from "../../common/vars"; +import logger from "../logger"; +import { broadcastMessage } from "../../common/ipc"; +import { preferencesURL, extensionsURL, addClusterURL, catalogURL, welcomeURL } from "../../common/routes"; +import type { MenuRegistration } from "./menu-registration"; +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { bind } from "../../common/utils"; +import electronMenuItemsInjectable from "./electron-menu-items.injectable"; +import type { IComputedValue } from "mobx"; +import { showAbout } from "./show-about"; +import windowManagerInjectable from "../windows/manager.injectable"; +import exitAppInjectable from "../exit-app.injectable"; +import { isAutoUpdateEnabled } from "../app-updater/start-update-checking.injectable"; +import checkForUpdatesInjectable from "../app-updater/check-for-updates.injectable"; + +interface BuildMenuDependencies { + windowManager: WindowManager; + electronMenuItems: IComputedValue; + exitApp: () => void; + checkForUpdates: () => Promise; +} interface MenuItemsOpts extends MenuItemConstructorOptions { submenu?: MenuItemConstructorOptions[]; } -export function initMenu( - windowManager: WindowManager, - electronMenuItems: IComputedValue, -) { - return autorun(() => buildMenu(windowManager, electronMenuItems.get()), { - delay: 100, - }); -} - -export function showAbout(browserWindow: BrowserWindow) { - const appInfo = [ - `${appName}: ${app.getVersion()}`, - `Electron: ${process.versions.electron}`, - `Chrome: ${process.versions.chrome}`, - `Node: ${process.versions.node}`, - packageJson.copyright, - ]; - - dialog.showMessageBoxSync(browserWindow, { - title: `${isWindows ? " ".repeat(2) : ""}${appName}`, - type: "info", - buttons: ["Close"], - message: productName, - detail: appInfo.join("\r\n"), - }); -} - -export function getAppMenu( - windowManager: WindowManager, - electronMenuItems: MenuRegistration[], -) { +function buildMenu({ windowManager, electronMenuItems, exitApp, checkForUpdates }: BuildMenuDependencies): Menu { function ignoreIf(check: boolean, menuItems: MenuItemConstructorOptions[]) { return check ? [] : menuItems; } @@ -62,7 +43,7 @@ export function getAppMenu( const autoUpdateDisabled = !isAutoUpdateEnabled(); - logger.info(`[MENU]: autoUpdateDisabled=${autoUpdateDisabled}`); + logger.info(`[MENU]: auto updating is ${autoUpdateDisabled ? "disabled" : "enabled"}`); const macAppMenu: MenuItemsOpts = { label: app.getName(), @@ -260,16 +241,12 @@ export function getAppMenu( { label: "Documentation", id: "documentation", - click: async () => { - shell.openExternal(docsUrl); - }, + click: () => shell.openExternal(docsUrl), }, { label: "Support", id: "support", - click: async () => { - shell.openExternal(supportUrl); - }, + click: () => shell.openExternal(supportUrl), }, ...ignoreIf(isMac, [ { @@ -299,7 +276,7 @@ export function getAppMenu( ]); // Modify menu from extensions-api - for (const menuItem of electronMenuItems) { + for (const menuItem of electronMenuItems.get()) { if (!appMenu.has(menuItem.parentId)) { logger.error( `[MENU]: cannot register menu item for parentId=${menuItem.parentId}, parent item doesn't exist`, @@ -316,15 +293,18 @@ export function getAppMenu( appMenu.delete("mac"); } - return [...appMenu.values()]; - + return Menu.buildFromTemplate([...appMenu.values()]); } -export function buildMenu( - windowManager: WindowManager, - electronMenuItems: MenuRegistration[], -) { - Menu.setApplicationMenu( - Menu.buildFromTemplate(getAppMenu(windowManager, electronMenuItems)), - ); -} +const buildMenuInjectable = getInjectable({ + instantiate: (di) => bind(buildMenu, null, { + electronMenuItems: di.inject(electronMenuItemsInjectable), + windowManager: di.inject(windowManagerInjectable), + exitApp: di.inject(exitAppInjectable), + checkForUpdates: di.inject(checkForUpdatesInjectable), + }), + lifecycle: lifecycleEnum.singleton, +}); + +export default buildMenuInjectable; + diff --git a/src/main/menu/electron-menu-items.injectable.ts b/src/main/menu/electron-menu-items.injectable.ts index e62b34afe6..35ad389209 100644 --- a/src/main/menu/electron-menu-items.injectable.ts +++ b/src/main/menu/electron-menu-items.injectable.ts @@ -12,8 +12,7 @@ const electronMenuItemsInjectable = getInjectable({ instantiate: (di) => { const extensions = di.inject(mainExtensionsInjectable); - return computed(() => - extensions.get().flatMap((extension) => extension.appMenus)); + return computed(() => extensions.get().flatMap((extension) => extension.appMenus)); }, }); diff --git a/src/main/menu/init-app-menu.injectable.ts b/src/main/menu/init-app-menu.injectable.ts new file mode 100644 index 0000000000..8a30c82452 --- /dev/null +++ b/src/main/menu/init-app-menu.injectable.ts @@ -0,0 +1,31 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { Menu } from "electron"; +import { autorun } from "mobx"; +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { bind } from "../../common/utils"; +import buildMenuInjectable from "./build-menu.injectable"; + +export type MenuTopId = "mac" | "file" | "edit" | "view" | "help"; + +interface Dependencies { + buildMenu: () => Menu; +} + +function initAppMenu({ buildMenu }: Dependencies) { + return autorun(() => Menu.setApplicationMenu(buildMenu()), { + delay: 100, + }); +} + +const initAppMenuInjectable = getInjectable({ + instantiate: (di) => bind(initAppMenu, null, { + buildMenu: di.inject(buildMenuInjectable), + }), + lifecycle: lifecycleEnum.singleton, +}); + +export default initAppMenuInjectable; + diff --git a/src/main/menu/show-about.ts b/src/main/menu/show-about.ts new file mode 100644 index 0000000000..932d056d1a --- /dev/null +++ b/src/main/menu/show-about.ts @@ -0,0 +1,25 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { app, BrowserWindow, dialog } from "electron"; +import { appName, isWindows, productName } from "../../common/vars"; +import * as packageJson from "../../../package.json"; + +export function showAbout(browserWindow: BrowserWindow) { + const appInfo = [ + `${appName}: ${app.getVersion()}`, + `Electron: ${process.versions.electron}`, + `Chrome: ${process.versions.chrome}`, + `Node: ${process.versions.node}`, + packageJson.copyright, + ]; + + dialog.showMessageBoxSync(browserWindow, { + title: `${isWindows ? " ".repeat(2) : ""}${appName}`, + type: "info", + buttons: ["Close"], + message: productName, + detail: appInfo.join("\r\n"), + }); +} diff --git a/src/main/migrations/cluster-store/3.6.0-beta.1.injectable.ts b/src/main/migrations/cluster-store/3.6.0-beta.1.injectable.ts new file mode 100644 index 0000000000..206225f1e4 --- /dev/null +++ b/src/main/migrations/cluster-store/3.6.0-beta.1.injectable.ts @@ -0,0 +1,97 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +// Move embedded kubeconfig into separate file and add reference to it to cluster settings +// convert file path cluster icons to their base64 encoded versions + +import path from "path"; +import fse from "fs-extra"; +import { loadConfigFromFileSync } from "../../../common/kube-helpers"; +import { MigrationDeclaration, migrationLog } from "../helpers"; +import type { ClusterModel } from "../../../common/cluster-types"; +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import getCustomKubeConfigDirectoryInjectable from "../../../common/app-paths/get-custom-kube-config-directory.injectable"; +import directoryForKubeConfigsInjectable from "../../../common/app-paths/directory-for-kube-configs.injectable"; +import directoryForUserDataInjectable from "../../../common/app-paths/directory-for-user-data.injectable"; +import type { ClusterStoreModel } from "../../../common/cluster-store/store"; + +interface Pre360ClusterModel extends ClusterModel { + kubeConfig: string; +} + +interface Dependencies { + userDataPath: string; + kubeConfigsPath: string; + getCustomKubeConfigDirectory: (id: string) => string; +} + +function getMigration({ userDataPath, kubeConfigsPath, getCustomKubeConfigDirectory }: Dependencies): MigrationDeclaration { + return { + version: "3.6.0-beta.1", + run(store) { + const storedClusters = store.get("clusters") as Pre360ClusterModel[] ?? []; + const migratedClusters: ClusterModel[] = []; + + fse.ensureDirSync(kubeConfigsPath); + + migrationLog("Number of clusters to migrate: ", storedClusters.length); + + for (const clusterModel of storedClusters) { + /** + * migrate kubeconfig + */ + try { + const absPath = getCustomKubeConfigDirectory(clusterModel.id); + + // take the embedded kubeconfig and dump it into a file + fse.writeFileSync(absPath, clusterModel.kubeConfig, { encoding: "utf-8", mode: 0o600 }); + + clusterModel.kubeConfigPath = absPath; + clusterModel.contextName = loadConfigFromFileSync(clusterModel.kubeConfigPath).config.getCurrentContext(); + delete clusterModel.kubeConfig; + + } catch (error) { + migrationLog(`Failed to migrate Kubeconfig for cluster "${clusterModel.id}", removing clusterModel...`, error); + + continue; + } + + /** + * migrate cluster icon + */ + try { + if (clusterModel.preferences?.icon) { + migrationLog(`migrating ${clusterModel.preferences.icon} for ${clusterModel.preferences.clusterName}`); + const iconPath = clusterModel.preferences.icon.replace("store://", ""); + const fileData = fse.readFileSync(path.join(userDataPath, iconPath)); + + clusterModel.preferences.icon = `data:;base64,${fileData.toString("base64")}`; + } else { + delete clusterModel.preferences?.icon; + } + } catch (error) { + migrationLog(`Failed to migrate cluster icon for cluster "${clusterModel.id}"`, error); + delete clusterModel.preferences.icon; + } + + migratedClusters.push(clusterModel); + } + + store.set("clusters", migratedClusters); + }, + }; +} + +const version360Beta1InjectableInjectable = getInjectable({ + instantiate: (di) => getMigration({ + getCustomKubeConfigDirectory: di.inject(getCustomKubeConfigDirectoryInjectable), + kubeConfigsPath: di.inject(directoryForKubeConfigsInjectable), + userDataPath: di.inject(directoryForUserDataInjectable), + }), + lifecycle: lifecycleEnum.singleton, +}); + +export default version360Beta1InjectableInjectable; + diff --git a/src/main/migrations/cluster-store/5.0.0-beta.10.injectable.ts b/src/main/migrations/cluster-store/5.0.0-beta.10.injectable.ts new file mode 100644 index 0000000000..b503e3fc5a --- /dev/null +++ b/src/main/migrations/cluster-store/5.0.0-beta.10.injectable.ts @@ -0,0 +1,64 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import path from "path"; +import fse from "fs-extra"; +import type { ClusterModel } from "../../../common/cluster-types"; +import type { MigrationDeclaration } from "../helpers"; +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import directoryForUserDataInjectable from "../../../common/app-paths/directory-for-user-data.injectable"; +import type { ClusterStoreModel } from "../../../common/cluster-store/store"; + +interface Pre500WorkspaceStoreModel { + workspaces: { + id: string; + name: string; + }[]; +} + +interface Dependencies { + userDataPath: string; +} + +function getMigration({ userDataPath }: Dependencies): MigrationDeclaration { + return { + version: "5.0.0-beta.10", + run(store) { + try { + const workspaceData: Pre500WorkspaceStoreModel = fse.readJsonSync(path.join(userDataPath, "lens-workspace-store.json")); + const workspaces = new Map(); // mapping from WorkspaceId to name + + for (const { id, name } of workspaceData.workspaces) { + workspaces.set(id, name); + } + + const clusters: ClusterModel[] = store.get("clusters") ?? []; + + for (const cluster of clusters) { + if (cluster.workspace && workspaces.has(cluster.workspace)) { + cluster.labels ??= {}; + cluster.labels.workspace = workspaces.get(cluster.workspace); + } + } + + store.set("clusters", clusters); + } catch (error) { + if (!(error.code === "ENOENT" && error.path.endsWith("lens-workspace-store.json"))) { + // ignore lens-workspace-store.json being missing + throw error; + } + } + }, + }; +} + +const version500Beta10MigrationInjectable = getInjectable({ + instantiate: (di) => getMigration({ + userDataPath: di.inject(directoryForUserDataInjectable), + }), + lifecycle: lifecycleEnum.singleton, +}); + +export default version500Beta10MigrationInjectable; diff --git a/src/migrations/cluster-store/5.0.0-beta.13.ts b/src/main/migrations/cluster-store/5.0.0-beta.13.injectable.ts similarity index 64% rename from src/migrations/cluster-store/5.0.0-beta.13.ts rename to src/main/migrations/cluster-store/5.0.0-beta.13.injectable.ts index bf1b16760d..440fa3c517 100644 --- a/src/migrations/cluster-store/5.0.0-beta.13.ts +++ b/src/main/migrations/cluster-store/5.0.0-beta.13.injectable.ts @@ -3,14 +3,14 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import type { ClusterModel, ClusterPreferences, ClusterPrometheusPreferences } from "../../common/cluster-types"; +import type { ClusterModel, ClusterPreferences, ClusterPrometheusPreferences } from "../../../common/cluster-types"; import { MigrationDeclaration, migrationLog } from "../helpers"; import { generateNewIdFor } from "../utils"; import path from "path"; import { moveSync, removeSync } from "fs-extra"; -import { getLegacyGlobalDiForExtensionApi } from "../../extensions/as-legacy-globals-for-extension-api/legacy-global-di-for-extension-api"; -import directoryForUserDataInjectable - from "../../common/app-paths/directory-for-user-data/directory-for-user-data.injectable"; +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import directoryForUserDataInjectable from "../../../common/app-paths/directory-for-user-data.injectable"; +import type { ClusterStoreModel } from "../../../common/cluster-store/store"; function mergePrometheusPreferences(left: ClusterPrometheusPreferences, right: ClusterPrometheusPreferences): ClusterPrometheusPreferences { if (left.prometheus && left.prometheusProvider) { @@ -89,35 +89,46 @@ function moveStorageFolder({ folder, newId, oldId }: { folder: string, newId: st } } -export default { - version: "5.0.0-beta.13", - run(store) { - const di = getLegacyGlobalDiForExtensionApi(); +interface Dependencies { + userDataPath: string; +} - const userDataPath = di.inject(directoryForUserDataInjectable); +function getMigration({ userDataPath }: Dependencies): MigrationDeclaration { + return { + version: "5.0.0-beta.13", + run(store) { + const folder = path.resolve(userDataPath, "lens-local-storage"); + const oldClusters: ClusterModel[] = store.get("clusters") ?? []; + const clusters = new Map(); - const folder = path.resolve(userDataPath, "lens-local-storage"); + for (const { id: oldId, ...cluster } of oldClusters) { + const newId = generateNewIdFor(cluster); - const oldClusters: ClusterModel[] = store.get("clusters") ?? []; - const clusters = new Map(); - - for (const { id: oldId, ...cluster } of oldClusters) { - const newId = generateNewIdFor(cluster); - - if (clusters.has(newId)) { - migrationLog(`Duplicate entries for ${newId}`, { oldId }); - clusters.set(newId, mergeClusterModel(clusters.get(newId), cluster)); - } else { - migrationLog(`First entry for ${newId}`, { oldId }); - clusters.set(newId, { - ...cluster, - id: newId, - workspaces: [cluster.workspace].filter(Boolean), - }); - moveStorageFolder({ folder, newId, oldId }); + if (clusters.has(newId)) { + migrationLog(`Duplicate entries for ${newId}`, { oldId }); + clusters.set(newId, mergeClusterModel(clusters.get(newId), cluster)); + } else { + migrationLog(`First entry for ${newId}`, { oldId }); + clusters.set(newId, { + ...cluster, + id: newId, + workspaces: [cluster.workspace].filter(Boolean), + }); + moveStorageFolder({ folder, newId, oldId }); + } } - } - store.set("clusters", [...clusters.values()]); - }, -} as MigrationDeclaration; + store.set("clusters", [...clusters.values()]); + }, + }; +} + +const version500Beta13MigrationInjectable = getInjectable({ + instantiate: (di) => getMigration({ + userDataPath: di.inject(directoryForUserDataInjectable), + }), + lifecycle: lifecycleEnum.singleton, +}); + +export default version500Beta13MigrationInjectable; + diff --git a/src/main/migrations/cluster-store/migrations.injectable.ts b/src/main/migrations/cluster-store/migrations.injectable.ts new file mode 100644 index 0000000000..9fb2d0abb8 --- /dev/null +++ b/src/main/migrations/cluster-store/migrations.injectable.ts @@ -0,0 +1,28 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +// Cluster store migrations + +import { joinMigrations } from "../helpers"; +import snap from "./snap"; + +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import version360Beta1InjectableInjectable from "./3.6.0-beta.1.injectable"; +import version500Beta10MigrationInjectable from "./5.0.0-beta.10.injectable"; +import version500Beta13MigrationInjectable from "./5.0.0-beta.13.injectable"; +import { clusterStoreMigrationsInjectionToken } from "../../../common/cluster-store/migrations-injection-token"; + +const clusterStoreMigrationsInjectable = getInjectable({ + instantiate: (di) => joinMigrations( + di.inject(version360Beta1InjectableInjectable), + di.inject(version500Beta10MigrationInjectable), + di.inject(version500Beta13MigrationInjectable), + snap, + ), + injectionToken: clusterStoreMigrationsInjectionToken, + lifecycle: lifecycleEnum.singleton, +}); + +export default clusterStoreMigrationsInjectable; diff --git a/src/migrations/cluster-store/snap.ts b/src/main/migrations/cluster-store/snap.ts similarity index 81% rename from src/migrations/cluster-store/snap.ts rename to src/main/migrations/cluster-store/snap.ts index bb3bfba273..14fd01d022 100644 --- a/src/migrations/cluster-store/snap.ts +++ b/src/main/migrations/cluster-store/snap.ts @@ -5,10 +5,11 @@ // Fix embedded kubeconfig paths under snap config -import type { ClusterModel } from "../../common/cluster-types"; -import { getAppVersion } from "../../common/utils/app-version"; +import type { ClusterModel } from "../../../common/cluster-types"; +import { getAppVersion } from "../../../common/utils/app-version"; import fs from "fs"; import { MigrationDeclaration, migrationLog } from "../helpers"; +import type { ClusterStoreModel } from "../../../common/cluster-store/store"; export default { version: getAppVersion(), // Run always after upgrade @@ -38,4 +39,4 @@ export default { store.set("clusters", migratedClusters); }, -} as MigrationDeclaration; +} as MigrationDeclaration; diff --git a/src/migrations/helpers.ts b/src/main/migrations/helpers.ts similarity index 75% rename from src/migrations/helpers.ts rename to src/main/migrations/helpers.ts index 74d20aba1e..d8b3e674ea 100644 --- a/src/migrations/helpers.ts +++ b/src/main/migrations/helpers.ts @@ -5,8 +5,8 @@ import type Conf from "conf"; import type { Migrations } from "conf/dist/source/types"; -import { ExtendedMap, iter } from "../common/utils"; -import { isTestEnv } from "../common/vars"; +import { ExtendedMap, iter } from "../../common/utils"; +import { isTestEnv } from "../../common/vars"; export function migrationLog(...args: any[]) { if (!isTestEnv) { @@ -14,12 +14,12 @@ export function migrationLog(...args: any[]) { } } -export interface MigrationDeclaration { +export interface MigrationDeclaration { version: string, - run(store: Conf): void; + run(store: Conf): void; } -export function joinMigrations(...declarations: MigrationDeclaration[]): Migrations { +export function joinMigrations(...declarations: MigrationDeclaration[]): Migrations { const migrations = new ExtendedMap) => void)[]>(); for (const decl of declarations) { diff --git a/src/migrations/hotbar-store/5.0.0-alpha.0.ts b/src/main/migrations/hotbar-store/5.0.0-alpha.0.ts similarity index 67% rename from src/migrations/hotbar-store/5.0.0-alpha.0.ts rename to src/main/migrations/hotbar-store/5.0.0-alpha.0.ts index eb6c479f8e..65d7eef93f 100644 --- a/src/migrations/hotbar-store/5.0.0-alpha.0.ts +++ b/src/main/migrations/hotbar-store/5.0.0-alpha.0.ts @@ -5,8 +5,9 @@ // Cleans up a store that had the state related data stored import type { MigrationDeclaration } from "../helpers"; -import { catalogEntity } from "../../main/catalog-sources/general"; -import { getEmptyHotbar } from "../../common/hotbar-types"; +import { catalogEntity } from "../../catalog-sources/general"; +import { getEmptyHotbar } from "../../../common/hotbar-store/hotbar-types"; +import type { HotbarStoreModel } from "../../../common/hotbar-store/store"; export default { version: "5.0.0-alpha.0", @@ -18,4 +19,4 @@ export default { store.set("hotbars", [hotbar]); }, -} as MigrationDeclaration; +} as MigrationDeclaration; diff --git a/src/migrations/hotbar-store/5.0.0-alpha.2.ts b/src/main/migrations/hotbar-store/5.0.0-alpha.2.ts similarity index 73% rename from src/migrations/hotbar-store/5.0.0-alpha.2.ts rename to src/main/migrations/hotbar-store/5.0.0-alpha.2.ts index 8291bb267d..0c5c020947 100644 --- a/src/migrations/hotbar-store/5.0.0-alpha.2.ts +++ b/src/main/migrations/hotbar-store/5.0.0-alpha.2.ts @@ -4,19 +4,19 @@ */ // Cleans up a store that had the state related data stored -import type { Hotbar } from "../../common/hotbar-types"; import * as uuid from "uuid"; +import type { HotbarStoreModel } from "../../../common/hotbar-store/store"; import type { MigrationDeclaration } from "../helpers"; export default { version: "5.0.0-alpha.2", run(store) { const rawHotbars = store.get("hotbars"); - const hotbars: Hotbar[] = Array.isArray(rawHotbars) ? rawHotbars : []; + const hotbars = Array.isArray(rawHotbars) ? rawHotbars : []; store.set("hotbars", hotbars.map((hotbar) => ({ id: uuid.v4(), ...hotbar, }))); }, -} as MigrationDeclaration; +} as MigrationDeclaration; diff --git a/src/main/migrations/hotbar-store/5.0.0-beta.10.injectable.ts b/src/main/migrations/hotbar-store/5.0.0-beta.10.injectable.ts new file mode 100644 index 0000000000..215ac7751a --- /dev/null +++ b/src/main/migrations/hotbar-store/5.0.0-beta.10.injectable.ts @@ -0,0 +1,167 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import fse from "fs-extra"; +import { isNull } from "lodash"; +import path from "path"; +import * as uuid from "uuid"; +import type { ClusterStoreModel } from "../../../common/cluster-store/store"; +import { defaultHotbarCells, getEmptyHotbar, HotbarItem } from "../../../common/hotbar-store/hotbar-types"; +import { catalogEntity } from "../../catalog-sources/general"; +import { MigrationDeclaration, migrationLog } from "../helpers"; +import { generateNewIdFor } from "../utils"; +import type { HotbarStoreModel } from "../../../common/hotbar-store/store"; +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import directoryForUserDataInjectable from "../../../common/app-paths/directory-for-user-data.injectable"; + +interface Pre500WorkspaceStoreModel { + workspaces: { + id: string; + name: string; + }[]; +} + +interface PartialHotbar { + id: string; + name: string; + items: (null | HotbarItem)[]; +} + +interface Dependencies { + userDataPath: string; +} + +function getMirgation({ userDataPath }: Dependencies): MigrationDeclaration { + return { + version: "5.0.0-beta.10", + run(store) { + const rawHotbars = store.get("hotbars"); + const hotbars: HotbarStoreModel["hotbars"] = Array.isArray(rawHotbars) ? rawHotbars.filter(h => h && typeof h === "object") : []; + + // Hotbars might be empty, if some of the previous migrations weren't run + if (hotbars.length === 0) { + const hotbar = getEmptyHotbar("default"); + const { metadata: { uid, name, source }} = catalogEntity; + + hotbar.items[0] = { entity: { uid, name, source }}; + + hotbars.push(hotbar); + } + + try { + const workspaceStoreData: Pre500WorkspaceStoreModel = fse.readJsonSync(path.join(userDataPath, "lens-workspace-store.json")); + const { clusters }: ClusterStoreModel = fse.readJSONSync(path.join(userDataPath, "lens-cluster-store.json")); + const workspaceHotbars = new Map(); // mapping from WorkspaceId to HotBar + + for (const { id, name } of workspaceStoreData.workspaces) { + migrationLog(`Creating new hotbar for ${name}`); + workspaceHotbars.set(id, { + id: uuid.v4(), // don't use the old IDs as they aren't necessarily UUIDs + items: [], + name: `Workspace: ${name}`, + }); + } + + { + // grab the default named hotbar or the first. + const defaultHotbarIndex = Math.max(0, hotbars.findIndex(hotbar => hotbar.name === "default")); + const [{ name, id, items }] = hotbars.splice(defaultHotbarIndex, 1); + + workspaceHotbars.set("default", { + name, + id, + items: items.filter(Boolean), + }); + } + + for (const cluster of clusters) { + const uid = generateNewIdFor(cluster); + + for (const workspaceId of cluster.workspaces ?? [cluster.workspace].filter(Boolean)) { + const workspaceHotbar = workspaceHotbars.get(workspaceId); + + if (!workspaceHotbar) { + migrationLog(`Cluster ${uid} has unknown workspace ID, skipping`); + continue; + } + + migrationLog(`Adding cluster ${uid} to ${workspaceHotbar.name}`); + + if (workspaceHotbar?.items.length < defaultHotbarCells) { + workspaceHotbar.items.push({ + entity: { + uid: generateNewIdFor(cluster), + name: cluster.preferences.clusterName || cluster.contextName, + }, + }); + } + } + } + + for (const hotbar of workspaceHotbars.values()) { + if (hotbar.items.length === 0) { + migrationLog(`Skipping ${hotbar.name} due to it being empty`); + continue; + } + + while (hotbar.items.length < defaultHotbarCells) { + hotbar.items.push(null); + } + + hotbars.push(hotbar as HotbarStoreModel["hotbars"][number]); + } + + /** + * Finally, make sure that the catalog entity hotbar item is in place. + * Just in case something else removed it. + * + * if every hotbar has elements that all not the `catalog-entity` item + */ + if (hotbars.every(hotbar => hotbar.items.every(item => item?.entity?.uid !== "catalog-entity"))) { + // note, we will add a new whole hotbar here called "default" if that was previously removed + const defaultHotbar = hotbars.find(hotbar => hotbar.name === "default"); + const { metadata: { uid, name, source }} = catalogEntity; + + if (defaultHotbar) { + const freeIndex = defaultHotbar.items.findIndex(isNull); + + if (freeIndex === -1) { + // making a new hotbar is less destructive if the first hotbar + // called "default" is full than overriding a hotbar item + const hotbar = getEmptyHotbar("initial"); + + hotbar.items[0] = { entity: { uid, name, source }}; + hotbars.unshift(hotbar); + } else { + defaultHotbar.items[freeIndex] = { entity: { uid, name, source }}; + } + } else { + const hotbar = getEmptyHotbar("default"); + + hotbar.items[0] = { entity: { uid, name, source }}; + hotbars.unshift(hotbar); + } + } + + } catch (error) { + // ignore files being missing + if (error.code !== "ENOENT") { + throw error; + } + } + + store.set("hotbars", hotbars); + }, + }; +} + +const version500Beta10MigrationInjectable = getInjectable({ + instantiate: (di) => getMirgation({ + userDataPath: di.inject(directoryForUserDataInjectable), + }), + lifecycle: lifecycleEnum.singleton, +}); + +export default version500Beta10MigrationInjectable; diff --git a/src/main/migrations/hotbar-store/5.0.0-beta.5.injectable.ts b/src/main/migrations/hotbar-store/5.0.0-beta.5.injectable.ts new file mode 100644 index 0000000000..23fe8cca77 --- /dev/null +++ b/src/main/migrations/hotbar-store/5.0.0-beta.5.injectable.ts @@ -0,0 +1,44 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { MigrationDeclaration } from "../helpers"; +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import catalogEntityRegistryInjectable from "../../catalog/entity-registry.injectable"; +import type { HotbarStoreModel } from "../../../common/hotbar-store/store"; + +const version500Beta5MigrationInjectable = getInjectable({ + instantiate: (di) => ({ + version: "5.0.0-beta.5", + run(store) { + const rawHotbars = store.get("hotbars"); + const hotbars: HotbarStoreModel["hotbars"] = Array.isArray(rawHotbars) ? rawHotbars : []; + const catalogEntityRegistry = di.inject(catalogEntityRegistryInjectable); + + for (const hotbar of hotbars) { + for (let i = 0; i < hotbar.items.length; i += 1) { + const item = hotbar.items[i]; + const entity = catalogEntityRegistry.items.find((entity) => entity.metadata.uid === item?.entity.uid); + + if (!entity) { + // Clear disabled item + hotbar.items[i] = null; + } else { + // Save additional data + hotbar.items[i].entity = { + ...item.entity, + name: entity.metadata.name, + source: entity.metadata.source, + }; + } + } + } + + store.set("hotbars", hotbars); + }, + } as MigrationDeclaration), + lifecycle: lifecycleEnum.singleton, +}); + +export default version500Beta5MigrationInjectable; diff --git a/src/main/migrations/hotbar-store/migrations.injectable.ts b/src/main/migrations/hotbar-store/migrations.injectable.ts new file mode 100644 index 0000000000..629fa78d0f --- /dev/null +++ b/src/main/migrations/hotbar-store/migrations.injectable.ts @@ -0,0 +1,30 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import clusterStoreInjectable from "../../../common/cluster-store/store.injectable"; +import { hotbarStoreMigrationsInjectionToken } from "../../../common/hotbar-store/migrations-injectable-token"; +import { joinMigrations } from "../helpers"; +import version500alpha0 from "./5.0.0-alpha.0"; +import version500alpha2 from "./5.0.0-alpha.2"; +import version500Beta10MigrationInjectable from "./5.0.0-beta.10.injectable"; +import version500Beta5MigrationInjectable from "./5.0.0-beta.5.injectable"; + +const hotbarStoreMigrationsInjectable = getInjectable({ + instantiate: (di) => { + // the migrations assume that the cluster migrations have been run + di.inject(clusterStoreInjectable); + + return joinMigrations( + version500alpha0, + version500alpha2, + di.inject(version500Beta5MigrationInjectable), + di.inject(version500Beta10MigrationInjectable), + ); + }, + injectionToken: hotbarStoreMigrationsInjectionToken, + lifecycle: lifecycleEnum.singleton, +}); + +export default hotbarStoreMigrationsInjectable; diff --git a/src/migrations/user-store/5.0.0-alpha.3.ts b/src/main/migrations/user-store/5.0.0-alpha.3.ts similarity index 69% rename from src/migrations/user-store/5.0.0-alpha.3.ts rename to src/main/migrations/user-store/5.0.0-alpha.3.ts index 1fe379c3d5..22a7bab42e 100644 --- a/src/migrations/user-store/5.0.0-alpha.3.ts +++ b/src/main/migrations/user-store/5.0.0-alpha.3.ts @@ -4,13 +4,14 @@ */ // Switch representation of hiddenTableColumns in store +import type { UserPreferencesStoreModel } from "../../../common/user-preferences"; import type { MigrationDeclaration } from "../helpers"; export default { version: "5.0.0-alpha.3", run(store) { const preferences = store.get("preferences"); - const oldHiddenTableColumns: Record = preferences?.hiddenTableColumns; + const oldHiddenTableColumns = preferences?.hiddenTableColumns as any as Record; if (!oldHiddenTableColumns) { return; @@ -20,4 +21,4 @@ export default { store.set("preferences", preferences); }, -} as MigrationDeclaration; +} as MigrationDeclaration; diff --git a/src/main/migrations/user-store/5.0.3-beta.1.injectable.ts.ts b/src/main/migrations/user-store/5.0.3-beta.1.injectable.ts.ts new file mode 100644 index 0000000000..1fa366d2c5 --- /dev/null +++ b/src/main/migrations/user-store/5.0.3-beta.1.injectable.ts.ts @@ -0,0 +1,86 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { existsSync, readFileSync } from "fs"; +import path from "path"; +import os from "os"; +import type { ClusterStoreModel } from "../../../common/cluster-store/store"; +import type { KubeconfigSyncEntry, UserPreferencesStoreModel } from "../../../common/user-preferences"; +import { MigrationDeclaration, migrationLog } from "../helpers"; +import { isLogicalChildPath } from "../../../common/utils"; +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import directoryForKubeConfigsInjectable from "../../../common/app-paths/directory-for-kube-configs.injectable"; +import directoryForUserDataInjectable from "../../../common/app-paths/directory-for-user-data.injectable"; + +interface Dependencies { + userDataPath: string; + kubeConfigsPath: string; +} + +function getMigration({ userDataPath, kubeConfigsPath }: Dependencies): MigrationDeclaration { + return { + version: "5.0.3-beta.1", + run(store) { + try { + const { syncKubeconfigEntries = [], ...preferences } = store.get("preferences") ?? {}; + const { clusters = [] }: ClusterStoreModel = JSON.parse(readFileSync(path.resolve(userDataPath, "lens-cluster-store.json"), "utf-8")) ?? {}; + const extensionDataDir = path.resolve(userDataPath, "extension_data"); + const syncPaths = new Set(syncKubeconfigEntries.map(s => s.filePath)); + + syncPaths.add(path.join(os.homedir(), ".kube")); + + for (const cluster of clusters) { + if (!cluster.kubeConfigPath) { + continue; + } + const dirOfKubeconfig = path.dirname(cluster.kubeConfigPath); + + if (dirOfKubeconfig === kubeConfigsPath) { + migrationLog(`Skipping ${cluster.id} because kubeConfigPath is under the stored KubeConfig folder`); + continue; + } + + if (syncPaths.has(cluster.kubeConfigPath) || syncPaths.has(dirOfKubeconfig)) { + migrationLog(`Skipping ${cluster.id} because kubeConfigPath is already being synced`); + continue; + } + + if (isLogicalChildPath(extensionDataDir, cluster.kubeConfigPath)) { + migrationLog(`Skipping ${cluster.id} because kubeConfigPath is placed under an extension_data folder`); + continue; + } + + if (!existsSync(cluster.kubeConfigPath)) { + migrationLog(`Skipping ${cluster.id} because kubeConfigPath no longer exists`); + continue; + } + + migrationLog(`Adding ${cluster.kubeConfigPath} from ${cluster.id} to sync paths`); + syncPaths.add(cluster.kubeConfigPath); + } + + const updatedSyncEntries: KubeconfigSyncEntry[] = [...syncPaths].map(filePath => ({ filePath })); + + migrationLog("Final list of synced paths", updatedSyncEntries); + store.set("preferences", { ...preferences, syncKubeconfigEntries: updatedSyncEntries }); + } catch (error) { + if (error.code !== "ENOENT") { + // ignore files being missing + throw error; + } + } + }, + }; +} + +const version503Beta1Injecable = getInjectable({ + instantiate: (di) => getMigration({ + kubeConfigsPath: di.inject(directoryForKubeConfigsInjectable), + userDataPath: di.inject(directoryForUserDataInjectable), + }), + lifecycle: lifecycleEnum.singleton, +}); + +export default version503Beta1Injecable; diff --git a/src/main/migrations/user-store/file-name-migration.injectable.ts b/src/main/migrations/user-store/file-name-migration.injectable.ts new file mode 100644 index 0000000000..9446a71dbc --- /dev/null +++ b/src/main/migrations/user-store/file-name-migration.injectable.ts @@ -0,0 +1,44 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import fse from "fs-extra"; +import path from "path"; +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { bind } from "../../../common/utils"; +import directoryForUserDataInjectable from "../../../common/app-paths/directory-for-user-data.injectable"; +import { userStoreFileNameMigrationInjectionToken } from "../../../common/user-preferences/file-name-migration-injection-token"; + +interface Dependencies { + userDataPath: string; +} + +function fileNameMigration({ userDataPath }: Dependencies) { + const configJsonPath = path.join(userDataPath, "config.json"); + const lensUserStoreJsonPath = path.join(userDataPath, "lens-user-store.json"); + + try { + fse.moveSync(configJsonPath, lensUserStoreJsonPath); + } catch (error) { + if (error.code === "ENOENT" && error.path === configJsonPath) { // (No such file or directory) + return; // file already moved + } else if (error.message === "dest already exists.") { + fse.removeSync(configJsonPath); + } else { + // pass other errors along + throw error; + } + } +} + +const fileNameMigrationInjectable = getInjectable({ + instantiate: (di) => bind(fileNameMigration, null, { + userDataPath: di.inject(directoryForUserDataInjectable), + }), + injectionToken: userStoreFileNameMigrationInjectionToken, + lifecycle: lifecycleEnum.singleton, +}); + +export default fileNameMigrationInjectable; + diff --git a/src/main/migrations/user-store/migrations.injectable.ts b/src/main/migrations/user-store/migrations.injectable.ts new file mode 100644 index 0000000000..9eebb0fe0b --- /dev/null +++ b/src/main/migrations/user-store/migrations.injectable.ts @@ -0,0 +1,21 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { joinMigrations } from "../helpers"; +import version500Alpha3 from "./5.0.0-alpha.3"; +import version503Beta1Injecable from "./5.0.3-beta.1.injectable.ts"; +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { userPreferencesStoreMigrationsInjectionToken } from "../../../common/user-preferences/migrations-injection-token"; + +const userPreferencesStoreMigrationsInjectable = getInjectable({ + instantiate: (di) => joinMigrations( + version500Alpha3, + di.inject(version503Beta1Injecable), + ), + injectionToken: userPreferencesStoreMigrationsInjectionToken, + lifecycle: lifecycleEnum.singleton, +}); + +export default userPreferencesStoreMigrationsInjectable; diff --git a/src/migrations/utils.ts b/src/main/migrations/utils.ts similarity index 100% rename from src/migrations/utils.ts rename to src/main/migrations/utils.ts diff --git a/src/migrations/weblinks-store/5.1.4.ts b/src/main/migrations/weblinks-store/5.1.4.ts similarity index 84% rename from src/migrations/weblinks-store/5.1.4.ts rename to src/main/migrations/weblinks-store/5.1.4.ts index d36016e7fa..6990409653 100644 --- a/src/migrations/weblinks-store/5.1.4.ts +++ b/src/main/migrations/weblinks-store/5.1.4.ts @@ -3,8 +3,8 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import { docsUrl, slackUrl } from "../../common/vars"; -import type { WeblinkData } from "../../common/weblink-store"; +import { docsUrl, slackUrl } from "../../../common/vars"; +import type { WeblinkData, WeblinkStoreModel } from "../../../common/weblinks/store"; import type { MigrationDeclaration } from "../helpers"; export default { @@ -24,4 +24,4 @@ export default { store.set("weblinks", weblinks); }, -} as MigrationDeclaration; +} as MigrationDeclaration; diff --git a/src/main/migrations/weblinks-store/migrations.injectable.ts b/src/main/migrations/weblinks-store/migrations.injectable.ts new file mode 100644 index 0000000000..10864a70cd --- /dev/null +++ b/src/main/migrations/weblinks-store/migrations.injectable.ts @@ -0,0 +1,19 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { joinMigrations } from "../helpers"; +import version514 from "./5.1.4"; +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { weblinksStoreMigrationsInjectionToken } from "../../../common/weblinks/migrations-injection-token"; + +const weblinksStoreMigrationsInjectable = getInjectable({ + instantiate: () => joinMigrations( + version514, + ), + injectionToken: weblinksStoreMigrationsInjectionToken, + lifecycle: lifecycleEnum.singleton, +}); + +export default weblinksStoreMigrationsInjectable; diff --git a/src/main/prometheus/helm.ts b/src/main/prometheus/helm.ts index d797ca014c..2e55a8a5c5 100644 --- a/src/main/prometheus/helm.ts +++ b/src/main/prometheus/helm.ts @@ -13,7 +13,7 @@ export class PrometheusHelm extends PrometheusLens { readonly rateAccuracy: string = "5m"; readonly isConfigurable: boolean = true; - public async getPrometheusService(client: CoreV1Api): Promise { + public getPrometheusService(client: CoreV1Api): Promise { return this.getFirstNamespacedService(client, "app=prometheus,component=server,heritage=Helm"); } } diff --git a/src/main/prometheus/operator.ts b/src/main/prometheus/operator.ts index 6f055379e1..07ea1b4cd8 100644 --- a/src/main/prometheus/operator.ts +++ b/src/main/prometheus/operator.ts @@ -13,7 +13,7 @@ export class PrometheusOperator extends PrometheusProvider { readonly name: string = "Prometheus Operator"; readonly isConfigurable: boolean = true; - public async getPrometheusService(client: CoreV1Api): Promise { + public getPrometheusService(client: CoreV1Api): Promise { return this.getFirstNamespacedService(client, "operated-prometheus=true", "self-monitor=true"); } diff --git a/src/main/protocol-handler/__test__/router.test.ts b/src/main/protocol-handler/__test__/router.test.ts index d96cd342dc..6b2b4d905b 100644 --- a/src/main/protocol-handler/__test__/router.test.ts +++ b/src/main/protocol-handler/__test__/router.test.ts @@ -9,16 +9,13 @@ import { broadcastMessage } from "../../../common/ipc"; import { ProtocolHandlerExtension, ProtocolHandlerInternal } from "../../../common/protocol-handler"; import { delay, noop } from "../../../common/utils"; import { LensExtension } from "../../../extensions/main-api"; -import { ExtensionsStore } from "../../../extensions/extensions-store/extensions-store"; -import type { LensProtocolRouterMain } from "../lens-protocol-router-main/lens-protocol-router-main"; +import type { ExtensionsStore } from "../../../extensions/extensions-store/extensions-store"; +import type { LensProtocolRouterMain } from "../router"; import mockFs from "mock-fs"; import { getDiForUnitTesting } from "../../getDiForUnitTesting"; -import extensionLoaderInjectable - from "../../../extensions/extension-loader/extension-loader.injectable"; -import lensProtocolRouterMainInjectable - from "../lens-protocol-router-main/lens-protocol-router-main.injectable"; -import extensionsStoreInjectable - from "../../../extensions/extensions-store/extensions-store.injectable"; +import extensionLoaderInjectable from "../../../extensions/extension-loader/extension-loader.injectable"; +import lensProtocolRouterMainInjectable from "../router.injectable"; +import extensionsStoreInjectable from "../../../extensions/extensions-store/extensions-store.injectable"; jest.mock("../../../common/ipc"); @@ -46,19 +43,12 @@ describe("protocol router tests", () => { extensionLoader = di.inject(extensionLoaderInjectable); extensionsStore = di.inject(extensionsStoreInjectable); - - lpr = di.inject(lensProtocolRouterMainInjectable); - lpr.rendererLoaded = true; }); afterEach(() => { jest.clearAllMocks(); - - // TODO: Remove Singleton from BaseStore to achieve independent unit testing - ExtensionsStore.resetInstance(); - mockFs.restore(); }); diff --git a/src/main/protocol-handler/index.ts b/src/main/protocol-handler/index.ts deleted file mode 100644 index 973d27c28c..0000000000 --- a/src/main/protocol-handler/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -export * from "./lens-protocol-router-main/lens-protocol-router-main"; diff --git a/src/main/protocol-handler/lens-protocol-router-main/lens-protocol-router-main.injectable.ts b/src/main/protocol-handler/router.injectable.ts similarity index 57% rename from src/main/protocol-handler/lens-protocol-router-main/lens-protocol-router-main.injectable.ts rename to src/main/protocol-handler/router.injectable.ts index 59afdb1668..0935d41383 100644 --- a/src/main/protocol-handler/lens-protocol-router-main/lens-protocol-router-main.injectable.ts +++ b/src/main/protocol-handler/router.injectable.ts @@ -3,15 +3,17 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; -import extensionLoaderInjectable from "../../../extensions/extension-loader/extension-loader.injectable"; -import { LensProtocolRouterMain } from "./lens-protocol-router-main"; -import extensionsStoreInjectable from "../../../extensions/extensions-store/extensions-store.injectable"; +import extensionLoaderInjectable from "../../extensions/extension-loader/extension-loader.injectable"; +import { LensProtocolRouterMain } from "./router"; +import extensionsStoreInjectable from "../../extensions/extensions-store/extensions-store.injectable"; +import ensureMainWindowInjectable from "../windows/ensure-main-window.injectable"; const lensProtocolRouterMainInjectable = getInjectable({ instantiate: (di) => new LensProtocolRouterMain({ extensionLoader: di.inject(extensionLoaderInjectable), extensionsStore: di.inject(extensionsStoreInjectable), + ensureMainWindow: di.inject(ensureMainWindowInjectable), }), lifecycle: lifecycleEnum.singleton, diff --git a/src/main/protocol-handler/lens-protocol-router-main/lens-protocol-router-main.ts b/src/main/protocol-handler/router.ts similarity index 86% rename from src/main/protocol-handler/lens-protocol-router-main/lens-protocol-router-main.ts rename to src/main/protocol-handler/router.ts index b57a167361..667bf88882 100644 --- a/src/main/protocol-handler/lens-protocol-router-main/lens-protocol-router-main.ts +++ b/src/main/protocol-handler/router.ts @@ -3,17 +3,16 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import logger from "../../logger"; -import * as proto from "../../../common/protocol-handler"; +import logger from "../logger"; +import * as proto from "../../common/protocol-handler"; import URLParse from "url-parse"; -import type { LensExtension } from "../../../extensions/lens-extension"; -import { broadcastMessage } from "../../../common/ipc"; +import type { LensExtension } from "../../extensions/lens-extension"; +import { broadcastMessage } from "../../common/ipc"; import { observable, when, makeObservable } from "mobx"; -import { ProtocolHandlerInvalid, RouteAttempt } from "../../../common/protocol-handler"; -import { disposer, noop } from "../../../common/utils"; -import { WindowManager } from "../../window-manager"; -import type { ExtensionLoader } from "../../../extensions/extension-loader"; -import type { ExtensionsStore } from "../../../extensions/extensions-store/extensions-store"; +import { ProtocolHandlerInvalid, RouteAttempt } from "../../common/protocol-handler"; +import { disposer } from "../../common/utils"; +import type { ExtensionLoader } from "../../extensions/extension-loader"; +import type { ExtensionsStore } from "../../extensions/extensions-store/extensions-store"; export interface FallbackHandler { (name: string): Promise; @@ -39,6 +38,7 @@ function checkHost(url: URLParse): boolean { interface Dependencies { extensionLoader: ExtensionLoader extensionsStore: ExtensionsStore + ensureMainWindow: () => void; } export class LensProtocolRouterMain extends proto.LensProtocolRouter { @@ -72,7 +72,7 @@ export class LensProtocolRouterMain extends proto.LensProtocolRouter { throw new proto.RoutingError(proto.RoutingErrorType.INVALID_PROTOCOL, url); } - WindowManager.getInstance(false)?.ensureMainWindow().catch(noop); + this.dependencies.ensureMainWindow(); const routeInternally = checkHost(url); logger.info(`${proto.LensProtocolRouter.LoggingPrefix}: routing ${url.toString()}`); diff --git a/src/main/proxy-functions/index.ts b/src/main/proxy-functions/index.ts deleted file mode 100644 index 490e3c4a74..0000000000 --- a/src/main/proxy-functions/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ -export * from "./kube-api-request"; -export * from "./types"; diff --git a/src/main/proxy-functions/kube-api-request.ts b/src/main/proxy-functions/kube-api-request.injectable.ts similarity index 83% rename from src/main/proxy-functions/kube-api-request.ts rename to src/main/proxy-functions/kube-api-request.injectable.ts index 8995d965e5..80723a2fad 100644 --- a/src/main/proxy-functions/kube-api-request.ts +++ b/src/main/proxy-functions/kube-api-request.injectable.ts @@ -8,10 +8,11 @@ import net from "net"; import url from "url"; import { apiKubePrefix } from "../../common/vars"; import type { ProxyApiRequestArgs } from "./types"; +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; const skipRawHeaders = new Set(["Host", "Authorization"]); -export async function kubeApiRequest({ req, socket, head, cluster }: ProxyApiRequestArgs) { +async function kubeApiRequest({ req, socket, head, cluster }: ProxyApiRequestArgs) { const proxyUrl = await cluster.contextHandler.resolveAuthProxyUrl() + req.url.replace(apiKubePrefix, ""); const apiUrl = url.parse(cluster.apiUrl); const pUrl = url.parse(proxyUrl); @@ -59,3 +60,11 @@ export async function kubeApiRequest({ req, socket, head, cluster }: ProxyApiReq proxySocket.end(); }); } + +const kubeApiRequestInjectable = getInjectable({ + instantiate: () => kubeApiRequest, + lifecycle: lifecycleEnum.singleton, +}); + +export default kubeApiRequestInjectable; + diff --git a/src/main/proxy-functions/shell-api-request/shell-api-request.injectable.ts b/src/main/proxy-functions/shell-api-request/shell-api-request.injectable.ts index c584cd35ea..dcf76f2c6c 100644 --- a/src/main/proxy-functions/shell-api-request/shell-api-request.injectable.ts +++ b/src/main/proxy-functions/shell-api-request/shell-api-request.injectable.ts @@ -3,18 +3,11 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; -import { shellApiRequest } from "./shell-api-request"; -import createShellSessionInjectable from "../../shell-session/create-shell-session.injectable"; -import shellRequestAuthenticatorInjectable - from "./shell-request-authenticator/shell-request-authenticator.injectable"; - -const shellApiRequestInjectable = getInjectable({ - instantiate: (di) => shellApiRequest({ - createShellSession: di.inject(createShellSessionInjectable), - authenticateRequest: di.inject(shellRequestAuthenticatorInjectable).authenticate, - }), +import shellRequestAuthenticatorInjectable from "./shell-request-authenticator.injectable"; +const shellApiRequestHandlerInjectable = getInjectable({ + instantiate: (di) => di.inject(shellRequestAuthenticatorInjectable).shellApiRequest, lifecycle: lifecycleEnum.singleton, }); -export default shellApiRequestInjectable; +export default shellApiRequestHandlerInjectable; diff --git a/src/main/proxy-functions/shell-api-request/shell-api-request.ts b/src/main/proxy-functions/shell-api-request/shell-api-request.ts deleted file mode 100644 index 8fe4d51b09..0000000000 --- a/src/main/proxy-functions/shell-api-request/shell-api-request.ts +++ /dev/null @@ -1,43 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import logger from "../../logger"; -import WebSocket, { Server as WebSocketServer } from "ws"; -import type { ProxyApiRequestArgs } from "../types"; -import { ClusterManager } from "../../cluster-manager"; -import URLParse from "url-parse"; -import type { Cluster } from "../../../common/cluster/cluster"; -import type { ClusterId } from "../../../common/cluster-types"; - -interface Dependencies { - authenticateRequest: (clusterId: ClusterId, tabId: string, shellToken: string) => boolean, - - createShellSession: (args: { - webSocket: WebSocket; - cluster: Cluster; - tabId: string; - nodeName?: string; - }) => { open: () => Promise }; -} - -export const shellApiRequest = ({ createShellSession, authenticateRequest }: Dependencies) => ({ req, socket, head }: ProxyApiRequestArgs): void => { - const cluster = ClusterManager.getInstance().getClusterForRequest(req); - const { query: { node: nodeName, shellToken, id: tabId }} = new URLParse(req.url, true); - - if (!cluster || !authenticateRequest(cluster.id, tabId, shellToken)) { - socket.write("Invalid shell request"); - - return void socket.end(); - } - - const ws = new WebSocketServer({ noServer: true }); - - ws.handleUpgrade(req, socket, head, (webSocket) => { - const shell = createShellSession({ webSocket, cluster, tabId, nodeName }); - - shell.open() - .catch(error => logger.error(`[SHELL-SESSION]: failed to open a ${nodeName ? "node" : "local"} shell`, error)); - }); -}; diff --git a/src/main/proxy-functions/shell-api-request/shell-request-authenticator/shell-request-authenticator.injectable.ts b/src/main/proxy-functions/shell-api-request/shell-request-authenticator.injectable.ts similarity index 53% rename from src/main/proxy-functions/shell-api-request/shell-request-authenticator/shell-request-authenticator.injectable.ts rename to src/main/proxy-functions/shell-api-request/shell-request-authenticator.injectable.ts index 708cf4e277..c100c6a436 100644 --- a/src/main/proxy-functions/shell-api-request/shell-request-authenticator/shell-request-authenticator.injectable.ts +++ b/src/main/proxy-functions/shell-api-request/shell-request-authenticator.injectable.ts @@ -3,17 +3,15 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import getClusterForRequestInjectable from "../../cluster-manager/get-cluster-for-request.injectable"; +import createShellSessionInjectable from "../../shell-sessions/create-shell-session.injectable"; import { ShellRequestAuthenticator } from "./shell-request-authenticator"; const shellRequestAuthenticatorInjectable = getInjectable({ - instantiate: () => { - const authenticator = new ShellRequestAuthenticator(); - - authenticator.init(); - - return authenticator; - }, - + instantiate: (di) => new ShellRequestAuthenticator({ + getClusterForRequest: di.inject(getClusterForRequestInjectable), + createShellSession: di.inject(createShellSessionInjectable), + }), lifecycle: lifecycleEnum.singleton, }); diff --git a/src/main/proxy-functions/shell-api-request/shell-request-authenticator.ts b/src/main/proxy-functions/shell-api-request/shell-request-authenticator.ts new file mode 100644 index 0000000000..dac91e4cdd --- /dev/null +++ b/src/main/proxy-functions/shell-api-request/shell-request-authenticator.ts @@ -0,0 +1,89 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { ExtendedMap } from "../../../common/utils"; +import type { ClusterId } from "../../../common/cluster-types"; +import { ipcMainHandle } from "../../../common/ipc"; +import crypto from "crypto"; +import { promisify } from "util"; +import { Server as WebSocketServer } from "ws"; +import type { ProxyApiRequestArgs } from "../types"; +import URLParse from "url-parse"; +import type http from "http"; +import type { Cluster } from "../../../common/cluster/cluster"; +import logger from "../../logger"; +import type { CreateShellSessionArgs } from "../../shell-sessions/create-shell-session.injectable"; + +const randomBytes = promisify(crypto.randomBytes); + +interface ShellSession { + open: () => Promise; +} + +interface Dependencies { + getClusterForRequest: (req: http.IncomingMessage) => Cluster; + createShellSession: (args: CreateShellSessionArgs) => ShellSession; +} + +export class ShellRequestAuthenticator { + private tokens = new ExtendedMap>(); + + constructor(protected readonly dependencies: Dependencies) { + ipcMainHandle("cluster:shell-api", async (event, clusterId, tabId) => { + const authToken = Uint8Array.from(await randomBytes(128)); + + this.tokens + .getOrInsert(clusterId, () => new Map()) + .set(tabId, authToken); + + return authToken; + }); + } + + /** + * Authenticates a single use token for creating a new shell + * @param clusterId The `ClusterId` for the shell + * @param tabId The ID for the shell + * @param token The value that is being presented as a one time authentication token + * @returns `true` if `token` was valid, false otherwise + */ + authenticate = (clusterId: ClusterId, tabId: string, token: string): boolean => { + const clusterTokens = this.tokens.get(clusterId); + + if (!clusterTokens) { + return false; + } + + const authToken = clusterTokens.get(tabId); + const buf = Uint8Array.from(Buffer.from(token, "base64")); + + if (authToken instanceof Uint8Array && authToken.length === buf.length && crypto.timingSafeEqual(authToken, buf)) { + // remove the token because it is a single use token + clusterTokens.delete(tabId); + + return true; + } + + return false; + }; + + shellApiRequest = ({ req, socket, head }: ProxyApiRequestArgs): void => { + const cluster = this.dependencies.getClusterForRequest(req); + const { query: { node: nodeName, shellToken, id: tabId }} = new URLParse(req.url, true); + + if (!cluster || !this.authenticate(cluster.id, tabId, shellToken)) { + socket.write("Invalid shell request"); + + return void socket.end(); + } + + const ws = new WebSocketServer({ noServer: true }); + + ws.handleUpgrade(req, socket, head, (websocket) => { + this.dependencies.createShellSession({ websocket, cluster, tabId, nodeName }) + .open() + .catch(error => logger.error(`[SHELL-SESSION]: failed to open a ${nodeName ? "node" : "local"} shell`, error)); + }); + }; +} diff --git a/src/main/proxy-functions/shell-api-request/shell-request-authenticator/shell-request-authenticator.ts b/src/main/proxy-functions/shell-api-request/shell-request-authenticator/shell-request-authenticator.ts deleted file mode 100644 index d1d6303e32..0000000000 --- a/src/main/proxy-functions/shell-api-request/shell-request-authenticator/shell-request-authenticator.ts +++ /dev/null @@ -1,54 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ -import { ExtendedMap } from "../../../../common/utils"; -import type { ClusterId } from "../../../../common/cluster-types"; -import { ipcMainHandle } from "../../../../common/ipc"; -import crypto from "crypto"; -import { promisify } from "util"; - -const randomBytes = promisify(crypto.randomBytes); - -export class ShellRequestAuthenticator { - private tokens = new ExtendedMap>(); - - init() { - ipcMainHandle("cluster:shell-api", async (event, clusterId, tabId) => { - const authToken = Uint8Array.from(await randomBytes(128)); - - this.tokens - .getOrInsert(clusterId, () => new Map()) - .set(tabId, authToken); - - return authToken; - }); - } - - /** - * Authenticates a single use token for creating a new shell - * @param clusterId The `ClusterId` for the shell - * @param tabId The ID for the shell - * @param token The value that is being presented as a one time authentication token - * @returns `true` if `token` was valid, false otherwise - */ - authenticate = (clusterId: ClusterId, tabId: string, token: string): boolean => { - const clusterTokens = this.tokens.get(clusterId); - - if (!clusterTokens) { - return false; - } - - const authToken = clusterTokens.get(tabId); - const buf = Uint8Array.from(Buffer.from(token, "base64")); - - if (authToken instanceof Uint8Array && authToken.length === buf.length && crypto.timingSafeEqual(authToken, buf)) { - // remove the token because it is a single use token - clusterTokens.delete(tabId); - - return true; - } - - return false; - }; -} diff --git a/src/main/resource-applier.ts b/src/main/resource-applier.ts index 11c26ad171..ab34b9ff7e 100644 --- a/src/main/resource-applier.ts +++ b/src/main/resource-applier.ts @@ -58,7 +58,7 @@ export class ResourceApplier { } } - async apply(resource: KubernetesObject | any): Promise { + apply(resource: KubernetesObject | any): Promise { resource = this.sanitizeObject(resource); appEventBus.emit({ name: "resource", action: "apply" }); @@ -98,11 +98,11 @@ export class ResourceApplier { } } - public async kubectlApplyAll(resources: string[], extraArgs = ["-o", "json"]): Promise { + public kubectlApplyAll(resources: string[], extraArgs = ["-o", "json"]): Promise { return this.kubectlCmdAll("apply", resources, extraArgs); } - public async kubectlDeleteAll(resources: string[], extraArgs?: string[]): Promise { + public kubectlDeleteAll(resources: string[], extraArgs?: string[]): Promise { return this.kubectlCmdAll("delete", resources, extraArgs); } diff --git a/src/main/router/router.injectable.ts b/src/main/router/router.injectable.ts index f5a28fd08a..1d96fc8947 100644 --- a/src/main/router/router.injectable.ts +++ b/src/main/router/router.injectable.ts @@ -3,14 +3,26 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; -import { Router } from "../router"; -import routePortForwardInjectable - from "../routes/port-forward/route-port-forward/route-port-forward.injectable"; +import { Router } from "./router"; +import routePortForwardInjectable from "../routes/port-forward/route-port-forward/route-port-forward.injectable"; +import { apiPrefix } from "../../common/vars"; +import metricsRouteInjectable from "../routes/metrics/route.injectable"; const routerInjectable = getInjectable({ - instantiate: (di) => new Router({ - routePortForward: di.inject(routePortForwardInjectable), - }), + instantiate: (di) => { + const router = new Router(); + const routePortForward = di.inject(routePortForwardInjectable); + + router.addRoute("post", `${apiPrefix}/pods/port-forward/{namespace}/{resourceType}/{resourceName}`, routePortForward); + + // Metrics API + const { routeMetrics, routeMetricsProviders } = di.inject(metricsRouteInjectable); + + router.addRoute("post", `${apiPrefix}/metrics`, routeMetrics); + router.addRoute("get", `${apiPrefix}/metrics/providers`, routeMetricsProviders); + + return router; + }, lifecycle: lifecycleEnum.singleton, }); diff --git a/src/main/router.ts b/src/main/router/router.ts similarity index 84% rename from src/main/router.ts rename to src/main/router/router.ts index 25328fb540..67550cc680 100644 --- a/src/main/router.ts +++ b/src/main/router/router.ts @@ -8,10 +8,10 @@ import Subtext from "@hapi/subtext"; import type http from "http"; import path from "path"; import { readFile } from "fs-extra"; -import type { Cluster } from "../common/cluster/cluster"; -import { apiPrefix, appName, publicPath, isDevelopment, webpackDevServerPort } from "../common/vars"; -import { HelmApiRoute, KubeconfigRoute, MetricsRoute, PortForwardRoute, ResourceApplierApiRoute, VersionRoute } from "./routes"; -import logger from "./logger"; +import type { Cluster } from "../../common/cluster/cluster"; +import { apiPrefix, appName, publicPath, isDevelopment, webpackDevServerPort } from "../../common/vars"; +import { HelmApiRoute, KubeconfigRoute, PortForwardRoute, ResourceApplierApiRoute, VersionRoute } from "../routes"; +import logger from "../logger"; export interface RouterRequestOpts { req: http.IncomingMessage; @@ -60,19 +60,17 @@ function getMimeType(filename: string) { return mimeTypes[path.extname(filename).slice(1)] || "text/plain"; } -interface Dependencies { - routePortForward: (request: LensApiRequest) => Promise -} +export type RouteMethod = "get" | "put" | "post" | "delete" | "patch"; export class Router { protected router = new Call.Router(); protected static rootPath = path.resolve(__static); - public constructor(private dependencies: Dependencies) { + public constructor() { this.addRoutes(); } - public async route(cluster: Cluster, req: http.IncomingMessage, res: http.ServerResponse): Promise { + public route = async (cluster: Cluster, req: http.IncomingMessage, res: http.ServerResponse): Promise => { const url = new URL(req.url, "http://localhost"); const path = url.pathname; const method = req.method.toLowerCase(); @@ -80,15 +78,11 @@ export class Router { const routeFound = !matchingRoute.isBoom; if (routeFound) { - const request = await this.getRequest({ req, res, cluster, url, params: matchingRoute.params }); - - await matchingRoute.route(request); - - return true; + await matchingRoute.route(await this.getRequest({ req, res, cluster, url, params: matchingRoute.params })); } - return false; - } + return routeFound; + }; protected async getRequest(opts: RouterRequestOpts): Promise { const { req, res, url, cluster, params } = opts; @@ -153,7 +147,10 @@ export class Router { filePath = `${publicPath}/${appName}.html`; } } + } + public addRoute(method: RouteMethod, path: string, handler: (req: LensApiRequest) => Promise | any) { + this.router.add({ method, path }, handler); } protected addRoutes() { @@ -163,12 +160,7 @@ export class Router { this.router.add({ method: "get", path: "/version" }, VersionRoute.getVersion); this.router.add({ method: "get", path: `${apiPrefix}/kubeconfig/service-account/{namespace}/{account}` }, KubeconfigRoute.routeServiceAccountRoute); - // Metrics API - this.router.add({ method: "post", path: `${apiPrefix}/metrics` }, MetricsRoute.routeMetrics); - this.router.add({ method: "get", path: `${apiPrefix}/metrics/providers` }, MetricsRoute.routeMetricsProviders); - // Port-forward API (the container port and local forwarding port are obtained from the query parameters) - this.router.add({ method: "post", path: `${apiPrefix}/pods/port-forward/{namespace}/{resourceType}/{resourceName}` }, this.dependencies.routePortForward); this.router.add({ method: "get", path: `${apiPrefix}/pods/port-forward/{namespace}/{resourceType}/{resourceName}` }, PortForwardRoute.routeCurrentPortForward); this.router.add({ method: "delete", path: `${apiPrefix}/pods/port-forward/{namespace}/{resourceType}/{resourceName}` }, PortForwardRoute.routeCurrentPortForwardStop); diff --git a/src/main/routes/helm-route.ts b/src/main/routes/helm-route.ts index 0f0905a675..ea44ddc2e3 100644 --- a/src/main/routes/helm-route.ts +++ b/src/main/routes/helm-route.ts @@ -3,7 +3,7 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import type { LensApiRequest } from "../router"; +import type { LensApiRequest } from "../router/router"; import { helmService } from "../helm/helm-service"; import logger from "../logger"; import { respondJson, respondText } from "../utils/http-responses"; diff --git a/src/main/routes/index.ts b/src/main/routes/index.ts index 635d92b3ba..c8a40c7132 100644 --- a/src/main/routes/index.ts +++ b/src/main/routes/index.ts @@ -4,7 +4,6 @@ */ export * from "./kubeconfig-route"; -export * from "./metrics-route"; export * from "./port-forward-route"; export * from "./helm-route"; export * from "./resource-applier-route"; diff --git a/src/main/routes/kubeconfig-route.ts b/src/main/routes/kubeconfig-route.ts index 4ed5427d0a..ad5acab62b 100644 --- a/src/main/routes/kubeconfig-route.ts +++ b/src/main/routes/kubeconfig-route.ts @@ -3,7 +3,7 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import type { LensApiRequest } from "../router"; +import type { LensApiRequest } from "../router/router"; import { respondJson } from "../utils/http-responses"; import type { Cluster } from "../../common/cluster/cluster"; import { CoreV1Api, V1Secret } from "@kubernetes/client-node"; diff --git a/src/main/routes/metrics/load-metrics.injectable.ts b/src/main/routes/metrics/load-metrics.injectable.ts new file mode 100644 index 0000000000..7023ac1254 --- /dev/null +++ b/src/main/routes/metrics/load-metrics.injectable.ts @@ -0,0 +1,55 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { Cluster } from "../../../common/cluster/cluster"; +import type { GetMetricsReqParams } from "../../k8s-api/get-metrics.injectable"; +import logger from "../../logger"; +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { bind } from "../../../common/utils"; +import getMetricsInjectable from "../../k8s-api/get-metrics.injectable"; + +// This is used for backoff retry tracking. +const ATTEMPTS = [false, false, false, false, true]; + +interface Dependencies { + getMetrics: (cluster: Cluster, prometheusPath: string, queryParams: GetMetricsReqParams) => Promise; +} + +// prometheus metrics loader +function loadMetrics({ getMetrics }: Dependencies, promQueries: string[], cluster: Cluster, prometheusPath: string, queryParams: Record): Promise { + const queries = promQueries.map(p => p.trim()); + const loaders = new Map>(); + + function loadMetric(query: string): Promise { + async function loadMetricHelper(): Promise { + for (const [attempt, lastAttempt] of ATTEMPTS.entries()) { // retry + try { + return await getMetrics(cluster, prometheusPath, { query, ...queryParams }); + } catch (error) { + if (lastAttempt || (error?.statusCode >= 400 && error?.statusCode < 500)) { + logger.error("[Metrics]: metrics not available", error?.response ? error.response?.body : error); + throw new Error("Metrics not available"); + } + + await new Promise(resolve => setTimeout(resolve, (attempt + 1) * 1000)); // add delay before repeating request + } + } + } + + return loaders.get(query) ?? loaders.set(query, loadMetricHelper()).get(query); + } + + return Promise.all(queries.map(loadMetric)); +} + +const loadMetricsInjectable = getInjectable({ + instantiate: (di) => bind(loadMetrics, null, { + getMetrics: di.inject(getMetricsInjectable), + }), + lifecycle: lifecycleEnum.singleton, +}); + +export default loadMetricsInjectable; + diff --git a/src/main/routes/metrics/route.injectable.ts b/src/main/routes/metrics/route.injectable.ts new file mode 100644 index 0000000000..16a876b2da --- /dev/null +++ b/src/main/routes/metrics/route.injectable.ts @@ -0,0 +1,16 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import loadMetricsInjectable from "./load-metrics.injectable"; +import { MetricsRoute } from "./route"; + +const metricsRouteInjectable = getInjectable({ + instantiate: (di) => new MetricsRoute({ + loadMetrics: di.inject(loadMetricsInjectable), + }), + lifecycle: lifecycleEnum.singleton, +}); + +export default metricsRouteInjectable; diff --git a/src/main/routes/metrics-route.ts b/src/main/routes/metrics/route.ts similarity index 51% rename from src/main/routes/metrics-route.ts rename to src/main/routes/metrics/route.ts index 083801680f..7ef926f95e 100644 --- a/src/main/routes/metrics-route.ts +++ b/src/main/routes/metrics/route.ts @@ -3,56 +3,31 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import type { LensApiRequest } from "../router"; -import { respondJson } from "../utils/http-responses"; -import type { Cluster } from "../../common/cluster/cluster"; -import { ClusterMetadataKey, ClusterPrometheusMetadata } from "../../common/cluster-types"; -import logger from "../logger"; -import { getMetrics } from "../k8s-request"; -import { PrometheusProviderRegistry } from "../prometheus"; +import type { LensApiRequest } from "../../router/router"; +import { respondJson } from "../../utils/http-responses"; +import type { Cluster } from "../../../common/cluster/cluster"; +import { ClusterMetadataKey, ClusterPrometheusMetadata } from "../../../common/cluster-types"; +import logger from "../../logger"; +import { PrometheusProviderRegistry } from "../../prometheus"; export type IMetricsQuery = string | string[] | { [metricName: string]: string; }; -// This is used for backoff retry tracking. -const ATTEMPTS = [false, false, false, false, true]; - -// prometheus metrics loader -async function loadMetrics(promQueries: string[], cluster: Cluster, prometheusPath: string, queryParams: Record): Promise { - const queries = promQueries.map(p => p.trim()); - const loaders = new Map>(); - - async function loadMetric(query: string): Promise { - async function loadMetricHelper(): Promise { - for (const [attempt, lastAttempt] of ATTEMPTS.entries()) { // retry - try { - return await getMetrics(cluster, prometheusPath, { query, ...queryParams }); - } catch (error) { - if (lastAttempt || (error?.statusCode >= 400 && error?.statusCode < 500)) { - logger.error("[Metrics]: metrics not available", error?.response ? error.response?.body : error); - throw new Error("Metrics not available"); - } - - await new Promise(resolve => setTimeout(resolve, (attempt + 1) * 1000)); // add delay before repeating request - } - } - } - - return loaders.get(query) ?? loaders.set(query, loadMetricHelper()).get(query); - } - - return Promise.all(queries.map(loadMetric)); -} - interface MetricProviderInfo { name: string; id: string; isConfigurable: boolean; } +export interface MetricsRouteDependencies { + loadMetrics: (promQueries: string[], cluster: Cluster, prometheusPath: string, queryParams: Record) => Promise; +} + export class MetricsRoute { - static async routeMetrics({ response, cluster, payload, query }: LensApiRequest) { + constructor(protected readonly dependencies: MetricsRouteDependencies) {} + + routeMetrics = async ({ response, cluster, payload, query }: LensApiRequest) => { const queryParams: IMetricsQuery = Object.fromEntries(query.entries()); const prometheusMetadata: ClusterPrometheusMetadata = {}; @@ -70,11 +45,11 @@ export class MetricsRoute { // return data in same structure as query if (typeof payload === "string") { - const [data] = await loadMetrics([payload], cluster, prometheusPath, queryParams); + const [data] = await this.dependencies.loadMetrics([payload], cluster, prometheusPath, queryParams); respondJson(response, data); } else if (Array.isArray(payload)) { - const data = await loadMetrics(payload, cluster, prometheusPath, queryParams); + const data = await this.dependencies.loadMetrics(payload, cluster, prometheusPath, queryParams); respondJson(response, data); } else { @@ -82,7 +57,7 @@ export class MetricsRoute { .map(([queryName, queryOpts]) => ( provider.getQuery(queryOpts, queryName) )); - const result = await loadMetrics(queries, cluster, prometheusPath, queryParams); + const result = await this.dependencies.loadMetrics(queries, cluster, prometheusPath, queryParams); const data = Object.fromEntries(Object.keys(payload).map((metricName, i) => [metricName, result[i]])); respondJson(response, data); @@ -95,9 +70,9 @@ export class MetricsRoute { } finally { cluster.metadata[ClusterMetadataKey.PROMETHEUS] = prometheusMetadata; } - } + }; - static async routeMetricsProviders({ response }: LensApiRequest) { + routeMetricsProviders = ({ response }: LensApiRequest) => { const providers: MetricProviderInfo[] = []; for (const { name, id, isConfigurable } of PrometheusProviderRegistry.getInstance().providers.values()) { @@ -105,5 +80,5 @@ export class MetricsRoute { } respondJson(response, providers); - } + }; } diff --git a/src/main/routes/port-forward-route.ts b/src/main/routes/port-forward-route.ts index 4d688d68a6..54c55bfb3b 100644 --- a/src/main/routes/port-forward-route.ts +++ b/src/main/routes/port-forward-route.ts @@ -3,13 +3,13 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import type { LensApiRequest } from "../router"; +import type { LensApiRequest } from "../router/router"; import logger from "../logger"; import { respondJson } from "../utils/http-responses"; import { PortForward } from "./port-forward/port-forward"; export class PortForwardRoute { - static async routeCurrentPortForward(request: LensApiRequest) { + static routeCurrentPortForward(request: LensApiRequest) { const { params, query, response, cluster } = request; const { namespace, resourceType, resourceName } = params; const port = Number(query.get("port")); @@ -23,7 +23,7 @@ export class PortForwardRoute { respondJson(response, { port: portForward?.forwardPort ?? null }); } - static async routeCurrentPortForwardStop(request: LensApiRequest) { + static routeCurrentPortForwardStop(request: LensApiRequest) { const { params, query, response, cluster } = request; const { namespace, resourceType, resourceName } = params; const port = Number(query.get("port")); @@ -35,7 +35,7 @@ export class PortForwardRoute { }); try { - await portForward.stop(); + portForward.stop(); respondJson(response, { status: true }); } catch (error) { logger.error("[PORT-FORWARD-ROUTE]: error stopping a port-forward", { namespace, port, forwardPort, resourceType, resourceName }); diff --git a/src/main/routes/port-forward/create-port-forward.injectable.ts b/src/main/routes/port-forward/create-port-forward.injectable.ts index b74f724ae3..ed664ff134 100644 --- a/src/main/routes/port-forward/create-port-forward.injectable.ts +++ b/src/main/routes/port-forward/create-port-forward.injectable.ts @@ -3,20 +3,14 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; -import { PortForward, PortForwardArgs } from "./port-forward"; -import bundledKubectlInjectable from "../../kubectl/bundled-kubectl.injectable"; +import { PortForward } from "./port-forward"; +import { bind } from "../../../common/utils"; +import bundledKubectlPathInjectable from "../../kubectl/get-bundled-path.injectable"; const createPortForwardInjectable = getInjectable({ - instantiate: (di) => { - const bundledKubectl = di.inject(bundledKubectlInjectable); - - const dependencies = { - getKubectlBinPath: bundledKubectl.getPath, - }; - - return (pathToKubeConfig: string, args: PortForwardArgs) => - new PortForward(dependencies, pathToKubeConfig, args); - }, + instantiate: (di) => bind(PortForward.create, null, { + bundledKubectlPath: di.inject(bundledKubectlPathInjectable), + }), lifecycle: lifecycleEnum.singleton, }); diff --git a/src/main/routes/port-forward/port-forward.ts b/src/main/routes/port-forward/port-forward.ts index 91eaddc13f..6f3053aff4 100644 --- a/src/main/routes/port-forward/port-forward.ts +++ b/src/main/routes/port-forward/port-forward.ts @@ -19,7 +19,7 @@ export interface PortForwardArgs { } interface Dependencies { - getKubectlBinPath: (bundled: boolean) => Promise + readonly bundledKubectlPath: string; } export class PortForward { @@ -43,7 +43,11 @@ export class PortForward { public port: number; public forwardPort: number; - constructor(private dependencies: Dependencies, public pathToKubeConfig: string, args: PortForwardArgs) { + static create(...args: ConstructorParameters) { + return new PortForward(...args); + } + + constructor(private readonly dependencies: Dependencies, public pathToKubeConfig: string, args: PortForwardArgs) { this.clusterId = args.clusterId; this.kind = args.kind; this.namespace = args.namespace; @@ -53,16 +57,13 @@ export class PortForward { } public async start() { - const kubectlBin = await this.dependencies.getKubectlBinPath(true); - const args = [ + this.process = spawn(this.dependencies.bundledKubectlPath, [ "--kubeconfig", this.pathToKubeConfig, "port-forward", "-n", this.namespace, `${this.kind}/${this.name}`, `${this.forwardPort ?? ""}:${this.port}`, - ]; - - this.process = spawn(kubectlBin, args, { + ], { env: process.env, }); PortForward.portForwards.push(this); @@ -96,7 +97,7 @@ export class PortForward { } } - public async stop() { + public stop() { this.process.kill(); } } diff --git a/src/main/routes/port-forward/route-port-forward/route-port-forward.ts b/src/main/routes/port-forward/route-port-forward/route-port-forward.ts index 267bc7529c..e82775a4f3 100644 --- a/src/main/routes/port-forward/route-port-forward/route-port-forward.ts +++ b/src/main/routes/port-forward/route-port-forward/route-port-forward.ts @@ -2,7 +2,7 @@ * Copyright (c) OpenLens Authors. All rights reserved. * Licensed under MIT License. See LICENSE in root directory for more information. */ -import type { LensApiRequest } from "../../../router"; +import type { LensApiRequest } from "../../../router/router"; import logger from "../../../logger"; import { respondJson } from "../../../utils/http-responses"; import { PortForward, PortForwardArgs } from "../port-forward"; diff --git a/src/main/routes/resource-applier-route.ts b/src/main/routes/resource-applier-route.ts index 1072050b9a..1746624d89 100644 --- a/src/main/routes/resource-applier-route.ts +++ b/src/main/routes/resource-applier-route.ts @@ -3,7 +3,7 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import type { LensApiRequest } from "../router"; +import type { LensApiRequest } from "../router/router"; import { respondJson, respondText } from "../utils/http-responses"; import { ResourceApplier } from "../resource-applier"; diff --git a/src/main/routes/version-route.ts b/src/main/routes/version-route.ts index 1c5c7c94d7..cf1db5951b 100644 --- a/src/main/routes/version-route.ts +++ b/src/main/routes/version-route.ts @@ -3,12 +3,12 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import type { LensApiRequest } from "../router"; +import type { LensApiRequest } from "../router/router"; import { respondJson } from "../utils/http-responses"; import { getAppVersion } from "../../common/utils"; export class VersionRoute { - static async getVersion(request: LensApiRequest) { + static getVersion(request: LensApiRequest) { const { response } = request; respondJson(response, { version: getAppVersion() }, 200); diff --git a/src/main/shell-session/create-shell-session.injectable.ts b/src/main/shell-session/create-shell-session.injectable.ts deleted file mode 100644 index 47dba89f04..0000000000 --- a/src/main/shell-session/create-shell-session.injectable.ts +++ /dev/null @@ -1,29 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ -import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; -import type { Cluster } from "../../common/cluster/cluster"; -import type WebSocket from "ws"; -import localShellSessionInjectable from "./local-shell-session/local-shell-session.injectable"; -import nodeShellSessionInjectable from "./node-shell-session/node-shell-session.injectable"; - -interface Args { - webSocket: WebSocket; - cluster: Cluster; - tabId: string; - nodeName?: string; -} - -const createShellSessionInjectable = getInjectable({ - instantiate: - (di) => - ({ nodeName, ...rest }: Args) => - !nodeName - ? di.inject(localShellSessionInjectable, rest) - : di.inject(nodeShellSessionInjectable, { nodeName, ...rest }), - - lifecycle: lifecycleEnum.singleton, -}); - -export default createShellSessionInjectable; diff --git a/src/main/shell-session/index.ts b/src/main/shell-session/index.ts deleted file mode 100644 index e6f11d0360..0000000000 --- a/src/main/shell-session/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -export * from "./node-shell-session/node-shell-session"; -export * from "./local-shell-session/local-shell-session"; diff --git a/src/main/shell-session/local-shell-session/local-shell-session.injectable.ts b/src/main/shell-session/local-shell-session/local-shell-session.injectable.ts deleted file mode 100644 index 30ac1c2d2e..0000000000 --- a/src/main/shell-session/local-shell-session/local-shell-session.injectable.ts +++ /dev/null @@ -1,29 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ -import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; -import { LocalShellSession } from "./local-shell-session"; -import type { Cluster } from "../../../common/cluster/cluster"; -import type WebSocket from "ws"; -import createKubectlInjectable from "../../kubectl/create-kubectl.injectable"; - -interface InstantiationParameter { - webSocket: WebSocket; - cluster: Cluster; - tabId: string; -} - -const localShellSessionInjectable = getInjectable({ - instantiate: (di, { cluster, tabId, webSocket }: InstantiationParameter) => { - const createKubectl = di.inject(createKubectlInjectable); - - const kubectl = createKubectl(cluster.version); - - return new LocalShellSession(kubectl, webSocket, cluster, tabId); - }, - - lifecycle: lifecycleEnum.transient, -}); - -export default localShellSessionInjectable; diff --git a/src/main/shell-session/node-shell-session/node-shell-session.injectable.ts b/src/main/shell-session/node-shell-session/node-shell-session.injectable.ts deleted file mode 100644 index aa57a2ed8a..0000000000 --- a/src/main/shell-session/node-shell-session/node-shell-session.injectable.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ -import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; -import type { Cluster } from "../../../common/cluster/cluster"; -import type WebSocket from "ws"; -import createKubectlInjectable from "../../kubectl/create-kubectl.injectable"; -import { NodeShellSession } from "./node-shell-session"; - -interface InstantiationParameter { - webSocket: WebSocket; - cluster: Cluster; - tabId: string; - nodeName: string; -} - -const nodeShellSessionInjectable = getInjectable({ - instantiate: (di, { cluster, tabId, webSocket, nodeName }: InstantiationParameter) => { - const createKubectl = di.inject(createKubectlInjectable); - - const kubectl = createKubectl(cluster.version); - - return new NodeShellSession(nodeName, kubectl, webSocket, cluster, tabId); - }, - - lifecycle: lifecycleEnum.transient, -}); - -export default nodeShellSessionInjectable; diff --git a/src/main/shell-sessions/create-local-shell-session.injectable.ts b/src/main/shell-sessions/create-local-shell-session.injectable.ts new file mode 100644 index 0000000000..9bbfa2ea77 --- /dev/null +++ b/src/main/shell-sessions/create-local-shell-session.injectable.ts @@ -0,0 +1,24 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { LocalShellSession } from "./local-shell-session"; +import downloadKubectlBinariesInjectable from "../../common/user-preferences/download-kubectl-binaries.injectable"; +import resolvedShellInjectable from "../../common/user-preferences/resolved-shell-injectable"; +import kubectlBinariesPathInjectable from "../../common/user-preferences/kubectl-binaries-path.injectable"; +import bundledKubectlPathInjectable from "../kubectl/get-bundled-path.injectable"; +import { bind } from "../../common/utils"; + +const createLocalShellSessionInjectable = getInjectable({ + instantiate: (di) => bind(LocalShellSession.create, null, { + downloadKubectlBinaries: di.inject(downloadKubectlBinariesInjectable), + resolvedShell: di.inject(resolvedShellInjectable), + kubectlBinariesPath: di.inject(kubectlBinariesPathInjectable), + bundledKubectlPath: di.inject(bundledKubectlPathInjectable), + }), + + lifecycle: lifecycleEnum.singleton, +}); + +export default createLocalShellSessionInjectable; diff --git a/src/main/shell-sessions/create-node-shell-session.injectable.ts b/src/main/shell-sessions/create-node-shell-session.injectable.ts new file mode 100644 index 0000000000..0b16428c60 --- /dev/null +++ b/src/main/shell-sessions/create-node-shell-session.injectable.ts @@ -0,0 +1,20 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { NodeShellSession } from "./node-shell-session"; +import createKubeJsonApiForClusterInjectable from "../k8s-api/create-kube-json-api-for-cluster.injectable"; +import resolvedShellInjectable from "../../common/user-preferences/resolved-shell-injectable"; +import { bind } from "../../common/utils"; + +const createNodeShellSessionInjectable = getInjectable({ + instantiate: (di) => bind(NodeShellSession.create, null, { + createKubeJsonApiForCluster: di.inject(createKubeJsonApiForClusterInjectable), + resolvedShell: di.inject(resolvedShellInjectable), + }), + + lifecycle: lifecycleEnum.singleton, +}); + +export default createNodeShellSessionInjectable; diff --git a/src/main/shell-sessions/create-shell-session.injectable.ts b/src/main/shell-sessions/create-shell-session.injectable.ts new file mode 100644 index 0000000000..f7175473c0 --- /dev/null +++ b/src/main/shell-sessions/create-shell-session.injectable.ts @@ -0,0 +1,50 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import type { Cluster } from "../../common/cluster/cluster"; +import type WebSocket from "ws"; +import createLocalShellSessionInjectable from "./create-local-shell-session.injectable"; +import type { ShellSession } from "./shell-session"; +import type { Kubectl } from "../kubectl/kubectl"; +import { bind } from "../../common/utils"; +import createKubectlInjectable from "../kubectl/create-kubectl.injectable"; +import createNodeShellSessionInjectable from "./create-node-shell-session.injectable"; +import type { LocalShellSessionArgs } from "./local-shell-session"; +import type { NodeShellSessionArgs } from "./node-shell-session"; + +export interface CreateShellSessionArgs { + websocket: WebSocket; + cluster: Cluster; + tabId: string; + nodeName?: string; +} + +interface Dependencies { + createLocalShellSession: (args: LocalShellSessionArgs) => ShellSession; + createNodeShellSession: (args: NodeShellSessionArgs) => ShellSession; + createKubectl: (version: string) => Kubectl; +} + +function createShellSession({ createLocalShellSession, createNodeShellSession, createKubectl }: Dependencies, { nodeName, ...args }: CreateShellSessionArgs): ShellSession { + const kubectl = createKubectl(args.cluster.version); + + if (nodeName) { + return createNodeShellSession({ nodeName, kubectl, ...args }); + } else { + return createLocalShellSession({ kubectl, ...args }); + } +} + +const createShellSessionInjectable = getInjectable({ + instantiate: (di) => bind(createShellSession, null, { + createKubectl: di.inject(createKubectlInjectable), + createLocalShellSession: di.inject(createLocalShellSessionInjectable), + createNodeShellSession: di.inject(createNodeShellSessionInjectable), + }), + + lifecycle: lifecycleEnum.singleton, +}); + +export default createShellSessionInjectable; diff --git a/src/main/shell-session/local-shell-session/local-shell-session.ts b/src/main/shell-sessions/local-shell-session.ts similarity index 55% rename from src/main/shell-session/local-shell-session/local-shell-session.ts rename to src/main/shell-sessions/local-shell-session.ts index ff7a3d34d4..8dafe0253b 100644 --- a/src/main/shell-session/local-shell-session/local-shell-session.ts +++ b/src/main/shell-sessions/local-shell-session.ts @@ -4,13 +4,29 @@ */ import path from "path"; -import { helmCli } from "../../helm/helm-cli"; -import { UserStore } from "../../../common/user-store"; -import { ShellSession } from "../shell-session"; +import { helmCli } from "../helm/helm-cli"; +import { ShellSession, ShellSessionArgs, ShellSessionDependencies } from "./shell-session"; +import type { IComputedValue } from "mobx"; + +export interface LocalShellSessionDependencies extends ShellSessionDependencies { + readonly kubectlBinariesPath: IComputedValue; + readonly downloadKubectlBinaries: IComputedValue; + readonly bundledKubectlPath: string; +} + +export interface LocalShellSessionArgs extends ShellSessionArgs {} export class LocalShellSession extends ShellSession { ShellType = "shell"; + static create(...args: ConstructorParameters) { + return new LocalShellSession(...args); + } + + constructor(protected readonly dependencies: LocalShellSessionDependencies, args: LocalShellSessionArgs) { + super(dependencies, args); + } + protected getPathEntries(): string[] { return [helmCli.getBinaryDir()]; } @@ -29,8 +45,10 @@ export class LocalShellSession extends ShellSession { protected async getShellArgs(shell: string): Promise { const helmpath = helmCli.getBinaryDir(); - const pathFromPreferences = UserStore.getInstance().kubectlBinariesPath || this.kubectl.getBundledPath(); - const kubectlPathDir = UserStore.getInstance().downloadKubectlBinaries ? await this.kubectlBinDirP : path.dirname(pathFromPreferences); + const pathFromPreferences = this.dependencies.kubectlBinariesPath.get() || this.dependencies.bundledKubectlPath; + const kubectlPathDir = this.dependencies.downloadKubectlBinaries.get() + ? await this.kubectlBinDirP + : path.dirname(pathFromPreferences); switch(path.basename(shell)) { case "powershell.exe": diff --git a/src/main/shell-session/node-shell-session/node-shell-session.ts b/src/main/shell-sessions/node-shell-session.ts similarity index 81% rename from src/main/shell-session/node-shell-session/node-shell-session.ts rename to src/main/shell-sessions/node-shell-session.ts index 1fa05a4ae2..0876b7362b 100644 --- a/src/main/shell-session/node-shell-session/node-shell-session.ts +++ b/src/main/shell-sessions/node-shell-session.ts @@ -3,29 +3,41 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import type WebSocket from "ws"; import { v4 as uuid } from "uuid"; import * as k8s from "@kubernetes/client-node"; import type { KubeConfig } from "@kubernetes/client-node"; -import type { Cluster } from "../../../common/cluster/cluster"; -import { ShellOpenError, ShellSession } from "../shell-session"; +import { ShellOpenError, ShellSession, ShellSessionArgs, ShellSessionDependencies } from "./shell-session"; import { get } from "lodash"; -import { Node, NodesApi } from "../../../common/k8s-api/endpoints"; -import { KubeJsonApi } from "../../../common/k8s-api/kube-json-api"; -import logger from "../../logger"; -import { TerminalChannels } from "../../../renderer/api/terminal-api"; -import type { Kubectl } from "../../kubectl/kubectl"; +import { Node, NodeApi } from "../../common/k8s-api/endpoints"; +import type { KubeJsonApi } from "../../common/k8s-api/kube-json-api"; +import logger from "../logger"; +import { TerminalChannels } from "../../renderer/api/terminal-api"; + +export interface NodeShellSessionDependencies extends ShellSessionDependencies { + createKubeJsonApiForCluster: (clusterId: string) => KubeJsonApi; +} + +export interface NodeShellSessionArgs extends ShellSessionArgs { + nodeName: string; +} export class NodeShellSession extends ShellSession { ShellType = "node-shell"; protected readonly podName = `node-shell-${uuid()}`; + protected readonly nodeName: string; protected kc: KubeConfig; protected readonly cwd: string | undefined = undefined; - constructor(protected nodeName: string, kubectl: Kubectl, socket: WebSocket, cluster: Cluster, terminalId: string) { - super(kubectl, socket, cluster, terminalId); + static create(...args: ConstructorParameters) { + return new NodeShellSession(...args); + } + + constructor(protected readonly dependencies: NodeShellSessionDependencies, { nodeName, ...args }: NodeShellSessionArgs) { + super(dependencies, args); + + this.nodeName = nodeName; } public async open() { @@ -47,9 +59,9 @@ export class NodeShellSession extends ShellSession { const env = await this.getCachedShellEnv(); const args = ["exec", "-i", "-t", "-n", "kube-system", this.podName, "--"]; - const nodeApi = new NodesApi({ + const nodeApi = new NodeApi({ objectConstructor: Node, - request: KubeJsonApi.forCluster(this.cluster.id), + request: this.dependencies.createKubeJsonApiForCluster(this.cluster.id), }); const node = await nodeApi.get({ name: this.nodeName }); const nodeOs = node.getOperatingSystem(); diff --git a/src/main/shell-session/shell-session.ts b/src/main/shell-sessions/shell-session.ts similarity index 94% rename from src/main/shell-session/shell-session.ts rename to src/main/shell-sessions/shell-session.ts index 25b7217219..d950781bf2 100644 --- a/src/main/shell-session/shell-session.ts +++ b/src/main/shell-sessions/shell-session.ts @@ -12,13 +12,13 @@ import { clearKubeconfigEnvVars } from "../utils/clear-kube-env-vars"; import path from "path"; import os from "os"; import { isMac, isWindows } from "../../common/vars"; -import { UserStore } from "../../common/user-store"; import * as pty from "node-pty"; import { appEventBus } from "../../common/app-event-bus/event-bus"; import logger from "../logger"; import { TerminalChannels, TerminalMessage } from "../../renderer/api/terminal-api"; import { deserialize, serialize } from "v8"; import { stat } from "fs/promises"; +import type { IComputedValue } from "mobx"; export class ShellOpenError extends Error { constructor(message: string, public cause: Error) { @@ -104,6 +104,17 @@ export enum WebSocketCloseEvent { TlsHandshake = 1015, } +export interface ShellSessionDependencies { + readonly resolvedShell: IComputedValue; +} + +export interface ShellSessionArgs { + kubectl: Kubectl; + websocket: WebSocket; + cluster: Cluster; + tabId: string; +} + export abstract class ShellSession { abstract ShellType: string; @@ -130,6 +141,9 @@ export abstract class ShellSession { protected kubectlBinDirP: Promise; protected kubeconfigPathP: Promise; protected readonly terminalId: string; + protected kubectl: Kubectl; + protected websocket: WebSocket; + protected cluster: Cluster; protected abstract get cwd(): string | undefined; @@ -155,10 +169,13 @@ export abstract class ShellSession { return { shellProcess, resume }; } - constructor(protected kubectl: Kubectl, protected websocket: WebSocket, protected cluster: Cluster, terminalId: string) { + constructor(protected readonly dependencies: ShellSessionDependencies, { cluster, tabId, websocket, kubectl }: ShellSessionArgs) { + this.kubectl = kubectl; + this.websocket = websocket; + this.cluster = cluster; this.kubeconfigPathP = this.cluster.getProxyKubeconfigPath(); this.kubectlBinDirP = this.kubectl.binDir(); - this.terminalId = `${cluster.id}:${terminalId}`; + this.terminalId = `${cluster.id}:${tabId}`; } protected send(message: TerminalMessage): void { @@ -313,7 +330,7 @@ export abstract class ShellSession { protected async getShellEnv() { const env = clearKubeconfigEnvVars(JSON.parse(JSON.stringify(await shellEnv()))); const pathStr = [...this.getPathEntries(), await this.kubectlBinDirP, process.env.PATH].join(path.delimiter); - const shell = UserStore.getInstance().resolvedShell; + const shell = this.dependencies.resolvedShell.get(); delete env.DEBUG; // don't pass DEBUG into shells diff --git a/src/main/tray/build-tray-menu.injectable.ts b/src/main/tray/build-tray-menu.injectable.ts new file mode 100644 index 0000000000..7ef20aec12 --- /dev/null +++ b/src/main/tray/build-tray-menu.injectable.ts @@ -0,0 +1,103 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { bind } from "../../common/utils"; +import { Menu, MenuItemConstructorOptions } from "electron"; +import type { IComputedValue } from "mobx"; +import type { WindowManager } from "../windows/manager"; +import { productName } from "../../common/vars"; +import { preferencesURL } from "../../common/routes"; +import type { TrayMenuRegistration } from "./tray-menu-registration"; +import { showAbout } from "../menu/show-about"; +import type { LensLogger } from "../../common/logger"; +import exitAppInjectable from "../exit-app.injectable"; +import trayLoggerInjectable from "./tray-logger.injectable"; +import trayMenuItemsInjectable from "./tray-menu-items.injectable"; +import windowManagerInjectable from "../windows/manager.injectable"; +import { isAutoUpdateEnabled } from "../app-updater/start-update-checking.injectable"; +import checkForUpdatesInjectable from "../app-updater/check-for-updates.injectable"; + +interface Dependencies { + windowManager: WindowManager; + trayMenuItems: IComputedValue; + exitApp: () => void; + logger: LensLogger; + checkForUpdates: () => Promise; +} + +function getMenuItemConstructorOptions(trayItem: TrayMenuRegistration): MenuItemConstructorOptions { + return { + ...trayItem, + submenu: trayItem.submenu ? trayItem.submenu.map(getMenuItemConstructorOptions) : undefined, + click: trayItem.click + ? () => void trayItem.click(trayItem) + : undefined, + }; +} + +function ignoreIf(check: boolean, menuItems: MenuItemConstructorOptions[]) { + return check ? [] : menuItems; +} + +function buildTrayMenu({ windowManager, trayMenuItems, exitApp, logger, checkForUpdates }: Dependencies) { + const template: MenuItemConstructorOptions[] = [ + { + label: `Open ${productName}`, + click() { + windowManager + .ensureMainWindow() + .catch(error => logger.error(`Failed to open lens`, { error })); + }, + }, + { + label: "Preferences", + click() { + windowManager + .navigate(preferencesURL()) + .catch(error => logger.error(`Failed to navigate to Preferences`, { error })); + }, + }, + ...ignoreIf(!isAutoUpdateEnabled(), [ + { + label: "Check for updates", + click() { + checkForUpdates() + .then(() => windowManager.ensureMainWindow()); + }, + }, + ]), + ...trayMenuItems.get().map(getMenuItemConstructorOptions), + { + label: `About ${productName}`, + click() { + windowManager.ensureMainWindow() + .then(showAbout) + .catch(error => logger.error(`Failed to show Lens About view`, { error })); + }, + }, + { type: "separator" }, + { + label: "Quit App", + click() { + exitApp(); + }, + }, + ]; + + return Menu.buildFromTemplate(template); +} + +const buildTrayMenuInjectable = getInjectable({ + instantiate: (di) => bind(buildTrayMenu, null, { + exitApp: di.inject(exitAppInjectable), + logger: di.inject(trayLoggerInjectable), + trayMenuItems: di.inject(trayMenuItemsInjectable), + windowManager: di.inject(windowManagerInjectable), + checkForUpdates: di.inject(checkForUpdatesInjectable), + }), + lifecycle: lifecycleEnum.singleton, +}); + +export default buildTrayMenuInjectable; diff --git a/src/main/tray/init-tray-icon.injectable.ts b/src/main/tray/init-tray-icon.injectable.ts new file mode 100644 index 0000000000..0f9c6a49b3 --- /dev/null +++ b/src/main/tray/init-tray-icon.injectable.ts @@ -0,0 +1,69 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import path from "path"; +import packageInfo from "../../../package.json"; +import { Menu, Tray } from "electron"; +import { autorun } from "mobx"; +import { isDevelopment, isWindows } from "../../common/vars"; +import type { LensLogger } from "../../common/logger"; +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { bind } from "../../common/utils"; +import buildTrayMenuInjectable from "./build-tray-menu.injectable"; +import ensureMainWindowInjectable from "../windows/ensure-main-window.injectable"; +import trayLoggerInjectable from "./tray-logger.injectable"; + +// note: instance of Tray should be saved somewhere, otherwise it disappears +export let tray: Tray; + +export function getTrayIcon(): string { + return path.resolve( + __static, + isDevelopment ? "../build/tray" : "icons", // copied within electron-builder extras + "trayIconTemplate.png", + ); +} + +interface Dependencies { + ensureMainWindow: () => void; + buildTrayMenu: () => Menu; + logger: LensLogger; +} + +function initTrayIcon({ ensureMainWindow, buildTrayMenu, logger }: Dependencies) { + tray = new Tray(getTrayIcon()); + tray.setToolTip(packageInfo.description); + tray.setIgnoreDoubleClickEvents(true); + + if (isWindows) { + tray.on("click", ensureMainWindow); + } + + const stopUpdating = autorun(() => { + try { + tray.setContextMenu(buildTrayMenu()); + } catch (error) { + logger.error(`building failed`, { error }); + } + }); + + return () => { + stopUpdating(); + tray?.destroy(); + tray = null; + }; +} + +const initTrayIconInjectable = getInjectable({ + instantiate: (di) => bind(initTrayIcon, null, { + buildTrayMenu: di.inject(buildTrayMenuInjectable), + ensureMainWindow: di.inject(ensureMainWindowInjectable), + logger: di.inject(trayLoggerInjectable), + }), + lifecycle: lifecycleEnum.singleton, +}); + +export default initTrayIconInjectable; + diff --git a/src/main/tray/tray-logger.injectable.ts b/src/main/tray/tray-logger.injectable.ts new file mode 100644 index 0000000000..fb03c38457 --- /dev/null +++ b/src/main/tray/tray-logger.injectable.ts @@ -0,0 +1,13 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import createPrefixedLoggerInjectable from "../../common/logger/create-prefixed-logger.injectable"; + +const trayLoggerInjectable = getInjectable({ + instantiate: (di) => di.inject(createPrefixedLoggerInjectable)("[TRAY]:"), + lifecycle: lifecycleEnum.singleton, +}); + +export default trayLoggerInjectable; diff --git a/src/main/tray/tray-menu-items.injectable.ts b/src/main/tray/tray-menu-items.injectable.ts index 5b3f5593f1..55bda36d30 100644 --- a/src/main/tray/tray-menu-items.injectable.ts +++ b/src/main/tray/tray-menu-items.injectable.ts @@ -12,8 +12,7 @@ const trayItemsInjectable = getInjectable({ instantiate: (di) => { const extensions = di.inject(mainExtensionsInjectable); - return computed(() => - extensions.get().flatMap(extension => extension.trayMenus)); + return computed(() => extensions.get().flatMap(extension => extension.trayMenus)); }, }); diff --git a/src/main/tray/tray-menu-registration.d.ts b/src/main/tray/tray-menu-registration.ts similarity index 100% rename from src/main/tray/tray-menu-registration.d.ts rename to src/main/tray/tray-menu-registration.ts diff --git a/src/main/tray/tray.ts b/src/main/tray/tray.ts deleted file mode 100644 index b4e8a24a8c..0000000000 --- a/src/main/tray/tray.ts +++ /dev/null @@ -1,132 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import path from "path"; -import packageInfo from "../../../package.json"; -import { Menu, Tray } from "electron"; -import { autorun, IComputedValue } from "mobx"; -import { showAbout } from "../menu/menu"; -import { checkForUpdates, isAutoUpdateEnabled } from "../app-updater"; -import type { WindowManager } from "../window-manager"; -import logger from "../logger"; -import { isDevelopment, isWindows, productName } from "../../common/vars"; -import { exitApp } from "../exit-app"; -import { preferencesURL } from "../../common/routes"; -import { toJS } from "../../common/utils"; -import type { TrayMenuRegistration } from "./tray-menu-registration"; - -const TRAY_LOG_PREFIX = "[TRAY]"; - -// note: instance of Tray should be saved somewhere, otherwise it disappears -export let tray: Tray; - -export function getTrayIcon(): string { - return path.resolve( - __static, - isDevelopment ? "../build/tray" : "icons", // copied within electron-builder extras - "trayIconTemplate.png", - ); -} - -export function initTray( - windowManager: WindowManager, - trayMenuItems: IComputedValue, -) { - const icon = getTrayIcon(); - - 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, toJS(trayMenuItems.get())); - - tray.setContextMenu(menu); - } catch (error) { - logger.error(`${TRAY_LOG_PREFIX}: building failed`, { error }); - } - }), - ]; - - return () => { - disposers.forEach(disposer => disposer()); - tray?.destroy(); - tray = null; - }; -} - -function getMenuItemConstructorOptions(trayItem: TrayMenuRegistration): Electron.MenuItemConstructorOptions { - return { - ...trayItem, - submenu: trayItem.submenu ? trayItem.submenu.map(getMenuItemConstructorOptions) : undefined, - click: trayItem.click ? () => { - trayItem.click(trayItem); - } : undefined, - }; -} - -function createTrayMenu( - windowManager: WindowManager, - extensionTrayItems: TrayMenuRegistration[], -): Menu { - let template: Electron.MenuItemConstructorOptions[] = [ - { - label: `Open ${productName}`, - click() { - windowManager - .ensureMainWindow() - .catch(error => logger.error(`${TRAY_LOG_PREFIX}: Failed to open lens`, { error })); - }, - }, - { - label: "Preferences", - click() { - windowManager - .navigate(preferencesURL()) - .catch(error => logger.error(`${TRAY_LOG_PREFIX}: Failed to navigate to Preferences`, { error })); - }, - }, - ]; - - if (isAutoUpdateEnabled()) { - template.push({ - label: "Check for updates", - click() { - checkForUpdates() - .then(() => windowManager.ensureMainWindow()); - }, - }); - } - - template = template.concat(extensionTrayItems.map(getMenuItemConstructorOptions)); - - return Menu.buildFromTemplate(template.concat([ - { - label: `About ${productName}`, - click() { - windowManager.ensureMainWindow() - .then(showAbout) - .catch(error => logger.error(`${TRAY_LOG_PREFIX}: Failed to show Lens About view`, { error })); - }, - }, - { type: "separator" }, - { - label: "Quit App", - click() { - exitApp(); - }, - }, - ])); -} diff --git a/src/main/windows/ensure-main-window.injectable.ts b/src/main/windows/ensure-main-window.injectable.ts new file mode 100644 index 0000000000..9a15df8112 --- /dev/null +++ b/src/main/windows/ensure-main-window.injectable.ts @@ -0,0 +1,13 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import windowManagerInjectable from "./manager.injectable"; + +const ensureMainWindowInjectable = getInjectable({ + instantiate: (di) => di.inject(windowManagerInjectable).ensureMainWindow, + lifecycle: lifecycleEnum.singleton, +}); + +export default ensureMainWindowInjectable; diff --git a/src/main/windows/manager.injectable.ts b/src/main/windows/manager.injectable.ts new file mode 100644 index 0000000000..aabdd5eec7 --- /dev/null +++ b/src/main/windows/manager.injectable.ts @@ -0,0 +1,16 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import getProxyPortInjectable from "../lens-proxy/get-proxy-port.injectable"; +import { WindowManager } from "./manager"; + +const windowManagerInjectable = getInjectable({ + instantiate: (di) => new WindowManager({ + proxyPort: di.inject(getProxyPortInjectable), + }), + lifecycle: lifecycleEnum.singleton, +}); + +export default windowManagerInjectable; diff --git a/src/main/window-manager.ts b/src/main/windows/manager.ts similarity index 90% rename from src/main/window-manager.ts rename to src/main/windows/manager.ts index 116f02aefe..834f989b2e 100644 --- a/src/main/window-manager.ts +++ b/src/main/windows/manager.ts @@ -3,18 +3,17 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import type { ClusterId } from "../common/cluster-types"; -import { makeObservable, observable } from "mobx"; +import type { ClusterId } from "../../common/cluster-types"; +import { computed, IComputedValue, makeObservable, observable } from "mobx"; import { app, BrowserWindow, dialog, ipcMain, shell, webContents } from "electron"; import windowStateKeeper from "electron-window-state"; -import { appEventBus } from "../common/app-event-bus/event-bus"; -import { BundledExtensionsLoaded, ipcMainOn } from "../common/ipc"; -import { delay, iter, Singleton } from "../common/utils"; -import { ClusterFrameInfo, clusterFrameMap } from "../common/cluster-frames"; -import { IpcRendererNavigationEvents } from "../renderer/navigation/events"; -import logger from "./logger"; -import { isMac, productName } from "../common/vars"; -import { LensProxy } from "./lens-proxy"; +import { appEventBus } from "../../common/app-event-bus/event-bus"; +import { BundledExtensionsLoaded, ipcMainOn } from "../../common/ipc"; +import { delay, iter } from "../../common/utils"; +import { ClusterFrameInfo, clusterFrameMap } from "../../common/cluster-frames"; +import { IpcRendererNavigationEvents } from "../../renderer/navigation/events"; +import logger from "../logger"; +import { isMac, productName } from "../../common/vars"; export const enum IpcMainWindowEvents { OPEN_CONTEXT_MENU = "window:open-context-menu", @@ -30,7 +29,11 @@ export interface SendToViewArgs { data?: any[]; } -export class WindowManager extends Singleton { +export interface WindowManagerDependencies { + readonly proxyPort: IComputedValue; +} + +export class WindowManager { protected mainWindow: BrowserWindow; protected splashWindow: BrowserWindow; protected windowState: windowStateKeeper.State; @@ -38,14 +41,13 @@ export class WindowManager extends Singleton { @observable activeClusterId: ClusterId; - constructor() { - super(); + constructor(protected readonly dependencies: WindowManagerDependencies) { makeObservable(this); this.bindEvents(); } - get mainUrl() { - return `http://localhost:${LensProxy.getInstance().port}`; + @computed get mainUrl() { + return `http://localhost:${this.dependencies.proxyPort.get()}`; } private async initMainWindow(showSplash: boolean) { diff --git a/src/migrations/cluster-store/3.6.0-beta.1.ts b/src/migrations/cluster-store/3.6.0-beta.1.ts deleted file mode 100644 index 4c7e066c3f..0000000000 --- a/src/migrations/cluster-store/3.6.0-beta.1.ts +++ /dev/null @@ -1,85 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -// Move embedded kubeconfig into separate file and add reference to it to cluster settings -// convert file path cluster icons to their base64 encoded versions - -import path from "path"; -import fse from "fs-extra"; -import { loadConfigFromFileSync } from "../../common/kube-helpers"; -import { MigrationDeclaration, migrationLog } from "../helpers"; -import type { ClusterModel } from "../../common/cluster-types"; -import { getLegacyGlobalDiForExtensionApi } from "../../extensions/as-legacy-globals-for-extension-api/legacy-global-di-for-extension-api"; -import directoryForUserDataInjectable - from "../../common/app-paths/directory-for-user-data/directory-for-user-data.injectable"; -import directoryForKubeConfigsInjectable - from "../../common/app-paths/directory-for-kube-configs/directory-for-kube-configs.injectable"; -import getCustomKubeConfigDirectoryInjectable - from "../../common/app-paths/get-custom-kube-config-directory/get-custom-kube-config-directory.injectable"; - -interface Pre360ClusterModel extends ClusterModel { - kubeConfig: string; -} - -export default { - version: "3.6.0-beta.1", - run(store) { - const di = getLegacyGlobalDiForExtensionApi(); - - const userDataPath = di.inject(directoryForUserDataInjectable); - const kubeConfigsPath = di.inject(directoryForKubeConfigsInjectable); - const getCustomKubeConfigDirectory = di.inject(getCustomKubeConfigDirectoryInjectable); - - const storedClusters: Pre360ClusterModel[] = store.get("clusters") ?? []; - const migratedClusters: ClusterModel[] = []; - - fse.ensureDirSync(kubeConfigsPath); - - migrationLog("Number of clusters to migrate: ", storedClusters.length); - - for (const clusterModel of storedClusters) { - /** - * migrate kubeconfig - */ - try { - const absPath = getCustomKubeConfigDirectory(clusterModel.id); - - // take the embedded kubeconfig and dump it into a file - fse.writeFileSync(absPath, clusterModel.kubeConfig, { encoding: "utf-8", mode: 0o600 }); - - clusterModel.kubeConfigPath = absPath; - clusterModel.contextName = loadConfigFromFileSync(clusterModel.kubeConfigPath).config.getCurrentContext(); - delete clusterModel.kubeConfig; - - } catch (error) { - migrationLog(`Failed to migrate Kubeconfig for cluster "${clusterModel.id}", removing clusterModel...`, error); - - continue; - } - - /** - * migrate cluster icon - */ - try { - if (clusterModel.preferences?.icon) { - migrationLog(`migrating ${clusterModel.preferences.icon} for ${clusterModel.preferences.clusterName}`); - const iconPath = clusterModel.preferences.icon.replace("store://", ""); - const fileData = fse.readFileSync(path.join(userDataPath, iconPath)); - - clusterModel.preferences.icon = `data:;base64,${fileData.toString("base64")}`; - } else { - delete clusterModel.preferences?.icon; - } - } catch (error) { - migrationLog(`Failed to migrate cluster icon for cluster "${clusterModel.id}"`, error); - delete clusterModel.preferences.icon; - } - - migratedClusters.push(clusterModel); - } - - store.set("clusters", migratedClusters); - }, -} as MigrationDeclaration; diff --git a/src/migrations/cluster-store/5.0.0-beta.10.ts b/src/migrations/cluster-store/5.0.0-beta.10.ts deleted file mode 100644 index f087fca2b6..0000000000 --- a/src/migrations/cluster-store/5.0.0-beta.10.ts +++ /dev/null @@ -1,53 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import path from "path"; -import fse from "fs-extra"; -import type { ClusterModel } from "../../common/cluster-types"; -import type { MigrationDeclaration } from "../helpers"; -import { getLegacyGlobalDiForExtensionApi } from "../../extensions/as-legacy-globals-for-extension-api/legacy-global-di-for-extension-api"; -import directoryForUserDataInjectable - from "../../common/app-paths/directory-for-user-data/directory-for-user-data.injectable"; - -interface Pre500WorkspaceStoreModel { - workspaces: { - id: string; - name: string; - }[]; -} - -export default { - version: "5.0.0-beta.10", - run(store) { - const di = getLegacyGlobalDiForExtensionApi(); - - const userDataPath = di.inject(directoryForUserDataInjectable); - - try { - const workspaceData: Pre500WorkspaceStoreModel = fse.readJsonSync(path.join(userDataPath, "lens-workspace-store.json")); - const workspaces = new Map(); // mapping from WorkspaceId to name - - for (const { id, name } of workspaceData.workspaces) { - workspaces.set(id, name); - } - - const clusters: ClusterModel[] = store.get("clusters") ?? []; - - for (const cluster of clusters) { - if (cluster.workspace && workspaces.has(cluster.workspace)) { - cluster.labels ??= {}; - cluster.labels.workspace = workspaces.get(cluster.workspace); - } - } - - store.set("clusters", clusters); - } catch (error) { - if (!(error.code === "ENOENT" && error.path.endsWith("lens-workspace-store.json"))) { - // ignore lens-workspace-store.json being missing - throw error; - } - } - }, -} as MigrationDeclaration; diff --git a/src/migrations/cluster-store/index.ts b/src/migrations/cluster-store/index.ts deleted file mode 100644 index 4851d01cae..0000000000 --- a/src/migrations/cluster-store/index.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -// Cluster store migrations - -import { joinMigrations } from "../helpers"; - -import version360Beta1 from "./3.6.0-beta.1"; -import version500Beta10 from "./5.0.0-beta.10"; -import version500Beta13 from "./5.0.0-beta.13"; -import snap from "./snap"; - -export default joinMigrations( - version360Beta1, - version500Beta10, - version500Beta13, - snap, -); diff --git a/src/migrations/hotbar-store/5.0.0-beta.10.ts b/src/migrations/hotbar-store/5.0.0-beta.10.ts deleted file mode 100644 index 5008232e5d..0000000000 --- a/src/migrations/hotbar-store/5.0.0-beta.10.ts +++ /dev/null @@ -1,156 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import fse from "fs-extra"; -import { isNull } from "lodash"; -import path from "path"; -import * as uuid from "uuid"; -import type { ClusterStoreModel } from "../../common/cluster-store/cluster-store"; -import { defaultHotbarCells, getEmptyHotbar, Hotbar, HotbarItem } from "../../common/hotbar-types"; -import { catalogEntity } from "../../main/catalog-sources/general"; -import { MigrationDeclaration, migrationLog } from "../helpers"; -import { generateNewIdFor } from "../utils"; -import { getLegacyGlobalDiForExtensionApi } from "../../extensions/as-legacy-globals-for-extension-api/legacy-global-di-for-extension-api"; -import directoryForUserDataInjectable - from "../../common/app-paths/directory-for-user-data/directory-for-user-data.injectable"; - -interface Pre500WorkspaceStoreModel { - workspaces: { - id: string; - name: string; - }[]; -} - -interface PartialHotbar { - id: string; - name: string; - items: (null | HotbarItem)[]; -} - -export default { - version: "5.0.0-beta.10", - run(store) { - const rawHotbars = store.get("hotbars"); - const hotbars: Hotbar[] = Array.isArray(rawHotbars) ? rawHotbars.filter(h => h && typeof h === "object") : []; - - const di = getLegacyGlobalDiForExtensionApi(); - - const userDataPath = di.inject(directoryForUserDataInjectable); - - // Hotbars might be empty, if some of the previous migrations weren't run - if (hotbars.length === 0) { - const hotbar = getEmptyHotbar("default"); - const { metadata: { uid, name, source }} = catalogEntity; - - hotbar.items[0] = { entity: { uid, name, source }}; - - hotbars.push(hotbar); - } - - try { - const workspaceStoreData: Pre500WorkspaceStoreModel = fse.readJsonSync(path.join(userDataPath, "lens-workspace-store.json")); - const { clusters }: ClusterStoreModel = fse.readJSONSync(path.join(userDataPath, "lens-cluster-store.json")); - const workspaceHotbars = new Map(); // mapping from WorkspaceId to HotBar - - for (const { id, name } of workspaceStoreData.workspaces) { - migrationLog(`Creating new hotbar for ${name}`); - workspaceHotbars.set(id, { - id: uuid.v4(), // don't use the old IDs as they aren't necessarily UUIDs - items: [], - name: `Workspace: ${name}`, - }); - } - - { - // grab the default named hotbar or the first. - const defaultHotbarIndex = Math.max(0, hotbars.findIndex(hotbar => hotbar.name === "default")); - const [{ name, id, items }] = hotbars.splice(defaultHotbarIndex, 1); - - workspaceHotbars.set("default", { - name, - id, - items: items.filter(Boolean), - }); - } - - for (const cluster of clusters) { - const uid = generateNewIdFor(cluster); - - for (const workspaceId of cluster.workspaces ?? [cluster.workspace].filter(Boolean)) { - const workspaceHotbar = workspaceHotbars.get(workspaceId); - - if (!workspaceHotbar) { - migrationLog(`Cluster ${uid} has unknown workspace ID, skipping`); - continue; - } - - migrationLog(`Adding cluster ${uid} to ${workspaceHotbar.name}`); - - if (workspaceHotbar?.items.length < defaultHotbarCells) { - workspaceHotbar.items.push({ - entity: { - uid: generateNewIdFor(cluster), - name: cluster.preferences.clusterName || cluster.contextName, - }, - }); - } - } - } - - for (const hotbar of workspaceHotbars.values()) { - if (hotbar.items.length === 0) { - migrationLog(`Skipping ${hotbar.name} due to it being empty`); - continue; - } - - while (hotbar.items.length < defaultHotbarCells) { - hotbar.items.push(null); - } - - hotbars.push(hotbar as Hotbar); - } - - /** - * Finally, make sure that the catalog entity hotbar item is in place. - * Just in case something else removed it. - * - * if every hotbar has elements that all not the `catalog-entity` item - */ - if (hotbars.every(hotbar => hotbar.items.every(item => item?.entity?.uid !== "catalog-entity"))) { - // note, we will add a new whole hotbar here called "default" if that was previously removed - const defaultHotbar = hotbars.find(hotbar => hotbar.name === "default"); - const { metadata: { uid, name, source }} = catalogEntity; - - if (defaultHotbar) { - const freeIndex = defaultHotbar.items.findIndex(isNull); - - if (freeIndex === -1) { - // making a new hotbar is less destructive if the first hotbar - // called "default" is full than overriding a hotbar item - const hotbar = getEmptyHotbar("initial"); - - hotbar.items[0] = { entity: { uid, name, source }}; - hotbars.unshift(hotbar); - } else { - defaultHotbar.items[freeIndex] = { entity: { uid, name, source }}; - } - } else { - const hotbar = getEmptyHotbar("default"); - - hotbar.items[0] = { entity: { uid, name, source }}; - hotbars.unshift(hotbar); - } - } - - } catch (error) { - // ignore files being missing - if (error.code !== "ENOENT") { - throw error; - } - } - - store.set("hotbars", hotbars); - }, -} as MigrationDeclaration; diff --git a/src/migrations/hotbar-store/5.0.0-beta.5.ts b/src/migrations/hotbar-store/5.0.0-beta.5.ts deleted file mode 100644 index 421bc2b908..0000000000 --- a/src/migrations/hotbar-store/5.0.0-beta.5.ts +++ /dev/null @@ -1,37 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import type { Hotbar } from "../../common/hotbar-types"; -import { catalogEntityRegistry } from "../../main/catalog"; -import type { MigrationDeclaration } from "../helpers"; - -export default { - version: "5.0.0-beta.5", - run(store) { - const rawHotbars = store.get("hotbars"); - const hotbars: Hotbar[] = Array.isArray(rawHotbars) ? rawHotbars : []; - - for (const hotbar of hotbars) { - for (let i = 0; i < hotbar.items.length; i += 1) { - const item = hotbar.items[i]; - const entity = catalogEntityRegistry.items.find((entity) => entity.metadata.uid === item?.entity.uid); - - if (!entity) { - // Clear disabled item - hotbar.items[i] = null; - } else { - // Save additional data - hotbar.items[i].entity = { - ...item.entity, - name: entity.metadata.name, - source: entity.metadata.source, - }; - } - } - } - - store.set("hotbars", hotbars); - }, -} as MigrationDeclaration; diff --git a/src/migrations/hotbar-store/index.ts b/src/migrations/hotbar-store/index.ts deleted file mode 100644 index 73fd3e093f..0000000000 --- a/src/migrations/hotbar-store/index.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -// Hotbar store migrations - -import { joinMigrations } from "../helpers"; - -import version500alpha0 from "./5.0.0-alpha.0"; -import version500alpha2 from "./5.0.0-alpha.2"; -import version500beta5 from "./5.0.0-beta.5"; -import version500beta10 from "./5.0.0-beta.10"; - -export default joinMigrations( - version500alpha0, - version500alpha2, - version500beta5, - version500beta10, -); diff --git a/src/migrations/user-store/2.1.0-beta.4.ts b/src/migrations/user-store/2.1.0-beta.4.ts deleted file mode 100644 index a8083df711..0000000000 --- a/src/migrations/user-store/2.1.0-beta.4.ts +++ /dev/null @@ -1,14 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -// Add / reset "lastSeenAppVersion" -import type { MigrationDeclaration } from "../helpers"; - -export default { - version: "2.1.0-beta.4", - run(store) { - store.set("lastSeenAppVersion", "0.0.0"); - }, -} as MigrationDeclaration; diff --git a/src/migrations/user-store/5.0.3-beta.1.ts b/src/migrations/user-store/5.0.3-beta.1.ts deleted file mode 100644 index d6b3d19696..0000000000 --- a/src/migrations/user-store/5.0.3-beta.1.ts +++ /dev/null @@ -1,77 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import { existsSync, readFileSync } from "fs"; -import path from "path"; -import os from "os"; -import type { ClusterStoreModel } from "../../common/cluster-store/cluster-store"; -import type { KubeconfigSyncEntry, UserPreferencesModel } from "../../common/user-store"; -import { MigrationDeclaration, migrationLog } from "../helpers"; -import { isLogicalChildPath } from "../../common/utils"; -import { getLegacyGlobalDiForExtensionApi } from "../../extensions/as-legacy-globals-for-extension-api/legacy-global-di-for-extension-api"; -import directoryForUserDataInjectable - from "../../common/app-paths/directory-for-user-data/directory-for-user-data.injectable"; -import directoryForKubeConfigsInjectable - from "../../common/app-paths/directory-for-kube-configs/directory-for-kube-configs.injectable"; - -export default { - version: "5.0.3-beta.1", - run(store) { - try { - const { syncKubeconfigEntries = [], ...preferences }: UserPreferencesModel = store.get("preferences") ?? {}; - - const di = getLegacyGlobalDiForExtensionApi(); - - const userDataPath = di.inject(directoryForUserDataInjectable); - const kubeConfigsPath = di.inject(directoryForKubeConfigsInjectable); - - const { clusters = [] }: ClusterStoreModel = JSON.parse(readFileSync(path.resolve(userDataPath, "lens-cluster-store.json"), "utf-8")) ?? {}; - const extensionDataDir = path.resolve(userDataPath, "extension_data"); - const syncPaths = new Set(syncKubeconfigEntries.map(s => s.filePath)); - - syncPaths.add(path.join(os.homedir(), ".kube")); - - for (const cluster of clusters) { - if (!cluster.kubeConfigPath) { - continue; - } - const dirOfKubeconfig = path.dirname(cluster.kubeConfigPath); - - if (dirOfKubeconfig === kubeConfigsPath) { - migrationLog(`Skipping ${cluster.id} because kubeConfigPath is under the stored KubeConfig folder`); - continue; - } - - if (syncPaths.has(cluster.kubeConfigPath) || syncPaths.has(dirOfKubeconfig)) { - migrationLog(`Skipping ${cluster.id} because kubeConfigPath is already being synced`); - continue; - } - - if (isLogicalChildPath(extensionDataDir, cluster.kubeConfigPath)) { - migrationLog(`Skipping ${cluster.id} because kubeConfigPath is placed under an extension_data folder`); - continue; - } - - if (!existsSync(cluster.kubeConfigPath)) { - migrationLog(`Skipping ${cluster.id} because kubeConfigPath no longer exists`); - continue; - } - - migrationLog(`Adding ${cluster.kubeConfigPath} from ${cluster.id} to sync paths`); - syncPaths.add(cluster.kubeConfigPath); - } - - const updatedSyncEntries: KubeconfigSyncEntry[] = [...syncPaths].map(filePath => ({ filePath })); - - migrationLog("Final list of synced paths", updatedSyncEntries); - store.set("preferences", { ...preferences, syncKubeconfigEntries: updatedSyncEntries }); - } catch (error) { - if (error.code !== "ENOENT") { - // ignore files being missing - throw error; - } - } - }, -} as MigrationDeclaration; diff --git a/src/migrations/user-store/file-name-migration.ts b/src/migrations/user-store/file-name-migration.ts deleted file mode 100644 index c9075aaa6f..0000000000 --- a/src/migrations/user-store/file-name-migration.ts +++ /dev/null @@ -1,31 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import fse from "fs-extra"; -import path from "path"; -import { getLegacyGlobalDiForExtensionApi } from "../../extensions/as-legacy-globals-for-extension-api/legacy-global-di-for-extension-api"; -import directoryForUserDataInjectable - from "../../common/app-paths/directory-for-user-data/directory-for-user-data.injectable"; - -export function fileNameMigration() { - const di = getLegacyGlobalDiForExtensionApi(); - - const userDataPath = di.inject(directoryForUserDataInjectable); - const configJsonPath = path.join(userDataPath, "config.json"); - const lensUserStoreJsonPath = path.join(userDataPath, "lens-user-store.json"); - - try { - fse.moveSync(configJsonPath, lensUserStoreJsonPath); - } catch (error) { - if (error.code === "ENOENT" && error.path === configJsonPath) { // (No such file or directory) - return; // file already moved - } else if (error.message === "dest already exists.") { - fse.removeSync(configJsonPath); - } else { - // pass other errors along - throw error; - } - } -} diff --git a/src/migrations/user-store/index.ts b/src/migrations/user-store/index.ts deleted file mode 100644 index 797868954b..0000000000 --- a/src/migrations/user-store/index.ts +++ /dev/null @@ -1,23 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -// User store migrations - -import { joinMigrations } from "../helpers"; - -import version210Beta4 from "./2.1.0-beta.4"; -import version500Alpha3 from "./5.0.0-alpha.3"; -import version503Beta1 from "./5.0.3-beta.1"; -import { fileNameMigration } from "./file-name-migration"; - -export { - fileNameMigration, -}; - -export default joinMigrations( - version210Beta4, - version500Alpha3, - version503Beta1, -); diff --git a/src/migrations/weblinks-store/index.ts b/src/migrations/weblinks-store/index.ts deleted file mode 100644 index 6b42e44c53..0000000000 --- a/src/migrations/weblinks-store/index.ts +++ /dev/null @@ -1,12 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import { joinMigrations } from "../helpers"; - -import version514 from "./5.1.4"; - -export default joinMigrations( - version514, -); diff --git a/src/renderer/api/catalog-category-registry.ts b/src/renderer/api/catalog-category-registry.ts deleted file mode 100644 index 6cdbd8eb71..0000000000 --- a/src/renderer/api/catalog-category-registry.ts +++ /dev/null @@ -1,7 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -export { catalogCategoryRegistry } from "../../common/catalog"; -export type { CategoryFilter } from "../../common/catalog"; diff --git a/src/renderer/api/catalog-entity.ts b/src/renderer/api/catalog-entity.ts deleted file mode 100644 index 09148c79a3..0000000000 --- a/src/renderer/api/catalog-entity.ts +++ /dev/null @@ -1,16 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -export { catalogEntityRunContext } from "./catalog-entity-registry"; -export { CatalogCategory, CatalogEntity } from "../../common/catalog"; -export type { - CatalogEntityData, - CatalogEntityKindData, - CatalogEntityActionContext, - CatalogEntityAddMenuContext, - CatalogEntityAddMenu, - CatalogEntityContextMenu, - CatalogEntityContextMenuContext, -} from "../../common/catalog"; diff --git a/src/renderer/api/helpers/general-active-sync.ts b/src/renderer/api/helpers/general-active-sync.ts deleted file mode 100644 index ef548d7a9a..0000000000 --- a/src/renderer/api/helpers/general-active-sync.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import { when } from "mobx"; -import { catalogCategoryRegistry } from "../../../common/catalog"; -import { catalogEntityRegistry } from "../catalog-entity-registry"; -import { isActiveRoute } from "../../navigation"; -import type { GeneralEntity } from "../../../common/catalog-entities"; - -export async function setEntityOnRouteMatch() { - await when(() => catalogEntityRegistry.entities.size > 0); - - const entities: GeneralEntity[] = catalogEntityRegistry.getItemsForCategory(catalogCategoryRegistry.getByName("General")); - const activeEntity = entities.find(entity => isActiveRoute(entity.spec.path)); - - if (activeEntity) { - catalogEntityRegistry.activeEntity = activeEntity; - } -} diff --git a/src/renderer/api/kube-object-detail-registry.ts b/src/renderer/api/kube-object-detail-registry.ts deleted file mode 100644 index b3efd96562..0000000000 --- a/src/renderer/api/kube-object-detail-registry.ts +++ /dev/null @@ -1,6 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -export { KubeObjectDetailRegistry } from "../../extensions/registries/kube-object-detail-registry"; diff --git a/src/renderer/api/terminal-api.ts b/src/renderer/api/terminal-api.ts index e05cdb91d5..16f8278f85 100644 --- a/src/renderer/api/terminal-api.ts +++ b/src/renderer/api/terminal-api.ts @@ -59,6 +59,16 @@ export interface TerminalEvents extends WebSocketEvents { connected: () => void; } +export interface TerminalSizes { + cols: number; + rows: number; +} + +interface EmitStatusOptions { + color?: TerminalColor; + showTime?: boolean; +} + export class TerminalApi extends WebSocketApi { protected size: { width: number; height: number }; @@ -132,7 +142,7 @@ export class TerminalApi extends WebSocketApi { return this.send(serialize(message)); } - sendTerminalSize(cols: number, rows: number) { + sendTerminalSize({ cols, rows }: TerminalSizes) { const newSize = { width: cols, height: rows }; if (!isEqual(this.size, newSize)) { @@ -172,7 +182,7 @@ export class TerminalApi extends WebSocketApi { protected _onOpen(evt: Event) { // Client should send terminal size in special channel 4, // But this size will be changed by terminal.fit() - this.sendTerminalSize(120, 80); + this.sendTerminalSize({ cols: 120, rows: 80 }); super._onOpen(evt); } @@ -181,7 +191,7 @@ export class TerminalApi extends WebSocketApi { this.isReady = false; } - protected emitStatus(data: string, options: { color?: TerminalColor; showTime?: boolean } = {}) { + protected emitStatus(data: string, options: EmitStatusOptions = {}) { const { color, showTime } = options; const time = showTime ? `${(new Date()).toLocaleString()} ` : ""; diff --git a/src/renderer/app-paths/app-paths.injectable.ts b/src/renderer/app-paths/app-paths.injectable.ts index a645b9a811..bd45126e4e 100644 --- a/src/renderer/app-paths/app-paths.injectable.ts +++ b/src/renderer/app-paths/app-paths.injectable.ts @@ -10,9 +10,7 @@ let syncAppPaths: AppPaths; const appPathsInjectable = getInjectable({ setup: async (di) => { - const getValueFromRegisteredChannel = di.inject( - getValueFromRegisteredChannelInjectable, - ); + const getValueFromRegisteredChannel = di.inject(getValueFromRegisteredChannelInjectable); syncAppPaths = await getValueFromRegisteredChannel(appPathsIpcChannel); }, diff --git a/src/renderer/bootstrap.tsx b/src/renderer/bootstrap.tsx index 9be509ab61..0d5cc28b05 100644 --- a/src/renderer/bootstrap.tsx +++ b/src/renderer/bootstrap.tsx @@ -20,26 +20,18 @@ import { DefaultProps } from "./mui-base-theme"; import configurePackages from "../common/configure-packages"; import * as initializers from "./initializers"; import logger from "../common/logger"; -import { HotbarStore } from "../common/hotbar-store"; -import { WeblinkStore } from "../common/weblink-store"; -import { ThemeStore } from "./theme.store"; -import { SentryInit } from "../common/sentry"; import { registerCustomThemes } from "./components/monaco-editor"; import { getDi } from "./getDi"; import { DiContextProvider } from "@ogre-tools/injectable-react"; -import type { DependencyInjectionContainer } from "@ogre-tools/injectable"; import extensionLoaderInjectable from "../extensions/extension-loader/extension-loader.injectable"; import extensionDiscoveryInjectable from "../extensions/extension-discovery/extension-discovery.injectable"; import extensionInstallationStateStoreInjectable from "../extensions/extension-installation-state-store/extension-installation-state-store.injectable"; -import clusterStoreInjectable from "../common/cluster-store/cluster-store.injectable"; -import userStoreInjectable from "../common/user-store/user-store.injectable"; +import clusterStoreInjectable from "../common/cluster-store/store.injectable"; +import userPreferencesStoreInjectable from "../common/user-preferences/store.injectable"; import initRootFrameInjectable from "./frames/root-frame/init-root-frame/init-root-frame.injectable"; import initClusterFrameInjectable from "./frames/cluster-frame/init-cluster-frame/init-cluster-frame.injectable"; -import commandOverlayInjectable from "./components/command-palette/command-overlay.injectable"; - -if (process.isMainFrame) { - SentryInit(); -} +import isAllowedResourceInjectable from "./utils/allowed-resource.injectable"; +import initializeSentryReportingInjectable from "../common/sentry.injectable"; configurePackages(); // global packages registerCustomThemes(); // monaco editor themes @@ -55,14 +47,19 @@ async function attachChromeDebugger() { } } -export async function bootstrap(di: DependencyInjectionContainer) { - await di.runSetups(); - +async function bootstrap() { const rootElem = document.getElementById("app"); const logPrefix = `[BOOTSTRAP-${process.isMainFrame ? "ROOT" : "CLUSTER"}-FRAME]:`; + const di = getDi(); + + await di.runSetups(); // TODO: Remove temporal dependencies to make timing of initialization not important - di.inject(userStoreInjectable); + di.inject(userPreferencesStoreInjectable); + + if (process.isMainFrame) { + di.inject(initializeSentryReportingInjectable); + } await attachChromeDebugger(); rootElem.classList.toggle("is-mac", isMac); @@ -76,22 +73,8 @@ export async function bootstrap(di: DependencyInjectionContainer) { logger.info(`${logPrefix} initializing KubeObjectMenuRegistry`); initializers.initKubeObjectMenuRegistry(); - logger.info(`${logPrefix} initializing KubeObjectDetailRegistry`); - initializers.initKubeObjectDetailRegistry(); - logger.info(`${logPrefix} initializing WorkloadsOverviewDetailRegistry`); - initializers.initWorkloadsOverviewDetailRegistry(); - - logger.info(`${logPrefix} initializing CatalogEntityDetailRegistry`); - initializers.initCatalogEntityDetailRegistry(); - - logger.info(`${logPrefix} initializing CatalogCategoryRegistryEntries`); - initializers.initCatalogCategoryRegistryEntries(); - - logger.info(`${logPrefix} initializing Catalog`); - initializers.initCatalog({ - openCommandDialog: di.inject(commandOverlayInjectable).open, - }); + initializers.initWorkloadsOverviewDetailRegistry(di.inject(isAllowedResourceInjectable)); const extensionLoader = di.inject(extensionLoaderInjectable); @@ -109,14 +92,6 @@ export async function bootstrap(di: DependencyInjectionContainer) { await clusterStore.loadInitialOnRenderer(); - // HotbarStore depends on: ClusterStore - HotbarStore.createInstance(); - - // ThemeStore depends on: UserStore - ThemeStore.createInstance(); - - WeblinkStore.createInstance(); - const extensionInstallationStateStore = di.inject(extensionInstallationStateStoreInjectable); extensionInstallationStateStore.bindIpcListeners(); @@ -126,18 +101,16 @@ export async function bootstrap(di: DependencyInjectionContainer) { // Register additional store listeners clusterStore.registerIpcListener(); - let App; - let initializeApp; - - // TODO: Introduce proper architectural boundaries between root and cluster iframes - if (process.isMainFrame) { - initializeApp = di.inject(initRootFrameInjectable); - - App = (await import("./frames/root-frame/root-frame")).RootFrame; - } else { - initializeApp = di.inject(initClusterFrameInjectable); - App = (await import("./frames/cluster-frame/cluster-frame")).ClusterFrame; - } + // TODO: Remove iframes + const [App, initializeApp] = process.isMainFrame + ? [ + (await import("./frames/root-frame/root-frame")).RootFrame, + di.inject(initRootFrameInjectable), + ] + : [ + (await import("./frames/cluster-frame/cluster-frame")).ClusterFrame, + di.inject(initClusterFrameInjectable), + ]; await initializeApp(rootElem); @@ -150,17 +123,15 @@ export async function bootstrap(di: DependencyInjectionContainer) { ); } -const di = getDi(); - // run -bootstrap(di); +bootstrap(); /** * Exports for virtual package "@k8slens/extensions" for renderer-process. * All exporting names available in global runtime scope: * e.g. Devtools -> Console -> window.LensExtensions (renderer) */ -const LensExtensions = { +export const LensExtensions = { Common: LensExtensionsCommonApi, Renderer: LensExtensionsRendererApi, }; @@ -171,5 +142,4 @@ export { ReactRouterDom, Mobx, MobxReact, - LensExtensions, }; diff --git a/src/renderer/api/__tests__/catalog-entity-registry.test.ts b/src/renderer/catalog/__tests__/catalog-entity-registry.test.ts similarity index 65% rename from src/renderer/api/__tests__/catalog-entity-registry.test.ts rename to src/renderer/catalog/__tests__/catalog-entity-registry.test.ts index a76dfdab82..67c3469c9e 100644 --- a/src/renderer/api/__tests__/catalog-entity-registry.test.ts +++ b/src/renderer/catalog/__tests__/catalog-entity-registry.test.ts @@ -3,17 +3,14 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import { CatalogEntityRegistry } from "../catalog-entity-registry"; -import { catalogCategoryRegistry } from "../../../common/catalog/catalog-category-registry"; -import { CatalogCategory, CatalogEntityData, CatalogEntityKindData } from "../catalog-entity"; +import type { CatalogEntityRegistry } from "../entity-registry"; +import { CatalogCategory, CatalogCategoryRegistry } from "../../../common/catalog"; import { KubernetesCluster, WebLink } from "../../../common/catalog-entities"; import { observable } from "mobx"; - -class TestCatalogEntityRegistry extends CatalogEntityRegistry { - replaceItems(items: Array) { - this.updateItems(items); - } -} +import catalogEntityRegistryInjectable from "../entity-registry.injectable"; +import type { ConfigurableDependencyInjectionContainer } from "@ogre-tools/injectable"; +import { getDiForUnitTesting } from "../../getDiForUnitTesting"; +import catalogCategoryRegistryInjectable from "../category-registry.injectable"; class FooBarCategory extends CatalogCategory { public readonly apiVersion = "catalog.k8slens.dev/v1alpha1"; @@ -80,9 +77,19 @@ const entitykc = new KubernetesCluster({ }); describe("CatalogEntityRegistry", () => { + let di: ConfigurableDependencyInjectionContainer; + let catalogEntityRegistry: CatalogEntityRegistry; + let catalogCategoryRegistry: CatalogCategoryRegistry; + + beforeAll(() => { + di = getDiForUnitTesting(); + + catalogCategoryRegistry = di.inject(catalogCategoryRegistryInjectable); + catalogEntityRegistry = di.inject(catalogEntityRegistryInjectable); + }); + describe("updateItems", () => { it("adds new catalog item", () => { - const catalog = new TestCatalogEntityRegistry(catalogCategoryRegistry); const items = [{ apiVersion: "entity.k8slens.dev/v1alpha1", kind: "KubernetesCluster", @@ -98,8 +105,8 @@ describe("CatalogEntityRegistry", () => { spec: {}, }]; - catalog.replaceItems(items); - expect(catalog.items.length).toEqual(1); + catalogEntityRegistry.updateItems(items); + expect(catalogEntityRegistry.items.length).toEqual(1); items.push({ apiVersion: "entity.k8slens.dev/v1alpha1", @@ -116,12 +123,11 @@ describe("CatalogEntityRegistry", () => { spec: {}, }); - catalog.replaceItems(items); - expect(catalog.items.length).toEqual(2); + catalogEntityRegistry.updateItems(items); + expect(catalogEntityRegistry.items.length).toEqual(2); }); it("updates existing items", () => { - const catalog = new TestCatalogEntityRegistry(catalogCategoryRegistry); const items = [{ apiVersion: "entity.k8slens.dev/v1alpha1", kind: "KubernetesCluster", @@ -137,19 +143,18 @@ describe("CatalogEntityRegistry", () => { spec: {}, }]; - catalog.replaceItems(items); - expect(catalog.items.length).toEqual(1); - expect(catalog.items[0].status.phase).toEqual("disconnected"); + catalogEntityRegistry.updateItems(items); + expect(catalogEntityRegistry.items.length).toEqual(1); + expect(catalogEntityRegistry.items[0].status.phase).toEqual("disconnected"); items[0].status.phase = "connected"; - catalog.replaceItems(items); - expect(catalog.items.length).toEqual(1); - expect(catalog.items[0].status.phase).toEqual("connected"); + catalogEntityRegistry.updateItems(items); + expect(catalogEntityRegistry.items.length).toEqual(1); + expect(catalogEntityRegistry.items[0].status.phase).toEqual("connected"); }); it("updates activeEntity", () => { - const catalog = new TestCatalogEntityRegistry(catalogCategoryRegistry); const items = [{ apiVersion: "entity.k8slens.dev/v1alpha1", kind: "KubernetesCluster", @@ -165,17 +170,16 @@ describe("CatalogEntityRegistry", () => { spec: {}, }]; - catalog.replaceItems(items); - catalog.activeEntity = catalog.items[0]; - expect(catalog.activeEntity.status.phase).toEqual("disconnected"); + catalogEntityRegistry.updateItems(items); + catalogEntityRegistry.activeEntity = catalogEntityRegistry.items[0]; + expect(catalogEntityRegistry.activeEntity.status.phase).toEqual("disconnected"); items[0].status.phase = "connected"; - catalog.replaceItems(items); - expect(catalog.activeEntity.status.phase).toEqual("connected"); + catalogEntityRegistry.updateItems(items); + expect(catalogEntityRegistry.activeEntity.status.phase).toEqual("connected"); }); it("removes deleted items", () => { - const catalog = new TestCatalogEntityRegistry(catalogCategoryRegistry); const items = [ { apiVersion: "entity.k8slens.dev/v1alpha1", @@ -207,17 +211,16 @@ describe("CatalogEntityRegistry", () => { }, ]; - catalog.replaceItems(items); + catalogEntityRegistry.updateItems(items); items.splice(0, 1); - catalog.replaceItems(items); - expect(catalog.items.length).toEqual(1); - expect(catalog.items[0].metadata.uid).toEqual("456"); + catalogEntityRegistry.updateItems(items); + expect(catalogEntityRegistry.items.length).toEqual(1); + expect(catalogEntityRegistry.items[0].metadata.uid).toEqual("456"); }); }); describe("items", () => { it("does not return items without matching category", () => { - const catalog = new TestCatalogEntityRegistry(catalogCategoryRegistry); const items = [ { apiVersion: "entity.k8slens.dev/v1alpha1", @@ -249,13 +252,12 @@ describe("CatalogEntityRegistry", () => { }, ]; - catalog.replaceItems(items); - expect(catalog.items.length).toBe(1); + catalogEntityRegistry.updateItems(items); + expect(catalogEntityRegistry.items.length).toBe(2); }); }); it("does return items after matching category is added", () => { - const catalog = new TestCatalogEntityRegistry(catalogCategoryRegistry); const items = [ { apiVersion: "entity.k8slens.dev/v1alpha1", @@ -273,29 +275,28 @@ describe("CatalogEntityRegistry", () => { }, ]; - catalog.replaceItems(items); + catalogEntityRegistry.updateItems(items); catalogCategoryRegistry.add(new FooBarCategory()); - expect(catalog.items.length).toBe(1); + expect(catalogEntityRegistry.items.length).toBe(1); }); it("does not return items that are filtered out", () => { const source = observable.array([entity, entity2, entitykc]); - const catalog = new TestCatalogEntityRegistry(catalogCategoryRegistry); - catalog.replaceItems(source); + catalogEntityRegistry.updateItems(source); - expect(catalog.items.length).toBe(3); - expect(catalog.filteredItems.length).toBe(3); + expect(catalogEntityRegistry.items.length).toBe(3); + expect(catalogEntityRegistry.filteredItems.length).toBe(3); - const d = catalog.addCatalogFilter(entity => entity.kind === KubernetesCluster.kind); + const d = catalogEntityRegistry.addCatalogFilter(entity => entity.kind === KubernetesCluster.kind); - expect(catalog.items.length).toBe(3); - expect(catalog.filteredItems.length).toBe(1); + expect(catalogEntityRegistry.items.length).toBe(3); + expect(catalogEntityRegistry.filteredItems.length).toBe(1); // Remove filter d(); - expect(catalog.items.length).toBe(3); - expect(catalog.filteredItems.length).toBe(3); + expect(catalogEntityRegistry.items.length).toBe(3); + expect(catalogEntityRegistry.filteredItems.length).toBe(3); }); }); diff --git a/src/renderer/catalog/active-cluster-entity.injectable.ts b/src/renderer/catalog/active-cluster-entity.injectable.ts new file mode 100644 index 0000000000..fb19f73976 --- /dev/null +++ b/src/renderer/catalog/active-cluster-entity.injectable.ts @@ -0,0 +1,28 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import type { CatalogEntity } from "../../common/catalog"; +import getClusterByIdInjectable from "../../common/cluster-store/get-cluster-by-id.injectable"; +import type { Cluster } from "../../common/cluster/cluster"; +import activeEntityInjectable from "./active-entity.injectable"; + +interface Dependencies { + activeEntity: CatalogEntity | undefined | null; + getClusterById: (id: string) => Cluster | null; +} + +function activeClusterEntity({ activeEntity, getClusterById }: Dependencies): Cluster | undefined { + return getClusterById(activeEntity?.getId()); +} + +const activeClusterEntityInjectable = getInjectable({ + instantiate: (di) => activeClusterEntity({ + activeEntity: di.inject(activeEntityInjectable).get(), + getClusterById: di.inject(getClusterByIdInjectable), + }), + lifecycle: lifecycleEnum.transient, +}); + +export default activeClusterEntityInjectable; diff --git a/src/renderer/catalog/active-entity.injectable.ts b/src/renderer/catalog/active-entity.injectable.ts new file mode 100644 index 0000000000..35c6b64793 --- /dev/null +++ b/src/renderer/catalog/active-entity.injectable.ts @@ -0,0 +1,13 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import catalogEntityRegistryInjectable from "./entity-registry.injectable"; + +const activeEntityInjectable = getInjectable({ + instantiate: (di) => di.inject(catalogEntityRegistryInjectable).activeEntityComputed, + lifecycle: lifecycleEnum.singleton, +}); + +export default activeEntityInjectable; diff --git a/src/extensions/registries/catalog-entity-detail-registry.ts b/src/renderer/catalog/catalog-entity-details.ts similarity index 54% rename from src/extensions/registries/catalog-entity-detail-registry.ts rename to src/renderer/catalog/catalog-entity-details.ts index ac1f676ebf..99d89bc924 100644 --- a/src/extensions/registries/catalog-entity-detail-registry.ts +++ b/src/renderer/catalog/catalog-entity-details.ts @@ -4,8 +4,7 @@ */ import type React from "react"; -import type { CatalogEntity } from "../common-api/catalog"; -import { BaseRegistry } from "./base-registry"; +import type { CatalogEntity } from "../../extensions/common-api/catalog"; export interface CatalogEntityDetailsProps { entity: T; @@ -21,13 +20,3 @@ export interface CatalogEntityDetailRegistration { components: CatalogEntityDetailComponents; priority?: number; } - -export class CatalogEntityDetailRegistry extends BaseRegistry> { - getItemsForKind(kind: string, apiVersion: string) { - const items = this.getItems().filter((item) => { - return item.kind === kind && item.apiVersions.includes(apiVersion); - }); - - return items.sort((a, b) => (b.priority ?? 50) - (a.priority ?? 50)); - } -} diff --git a/src/renderer/catalog/categories.injectable.ts b/src/renderer/catalog/categories.injectable.ts new file mode 100644 index 0000000000..4d7de33184 --- /dev/null +++ b/src/renderer/catalog/categories.injectable.ts @@ -0,0 +1,13 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import catalogCategoryRegistryInjectable from "./category-registry.injectable"; + +const catalogCategoriesInjectable = getInjectable({ + instantiate: (di) => di.inject(catalogCategoryRegistryInjectable).filteredItems, + lifecycle: lifecycleEnum.singleton, +}); + +export default catalogCategoriesInjectable; diff --git a/src/renderer/catalog/category-registry.injectable.tsx b/src/renderer/catalog/category-registry.injectable.tsx new file mode 100644 index 0000000000..705c5c0ba2 --- /dev/null +++ b/src/renderer/catalog/category-registry.injectable.tsx @@ -0,0 +1,166 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { readFile } from "fs/promises"; +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { CatalogCategoryRegistry } from "../../common/catalog"; +import { loadConfigFromString } from "../../common/kube-helpers"; +import React from "react"; +import { WeblinkAddCommand } from "../components/catalog-entities/weblink-add-command"; +import openCommandDialogInjectable from "../components/command-palette/open-command-dialog.injectable"; +import { GeneralCategory, KubernetesClusterCategory, WebLinkCategory } from "../../common/catalog-entities"; +import { runInAction } from "mobx"; +import { Link } from "react-router-dom"; +import { kubernetesURL, addClusterURL } from "../../common/routes"; +import { isWindows, isLinux, productName } from "../../common/vars"; +import { getAllEntries } from "../components/+preferences/kubeconfig-syncs"; +import { Notifications } from "../components/notifications"; +import { PathPicker } from "../components/path-picker"; +import { multiSet } from "../utils"; +import removeWeblinkByIdInjectable from "../../common/weblinks/remove-by-id.injectable"; +import userPreferencesStoreInjectable from "../../common/user-preferences/store.injectable"; +import getClusterByIdInjectable from "../../common/cluster-store/get-cluster-by-id.injectable"; +import openDeleteClusterDialogInjectable from "../components/delete-cluster-dialog/open-delete-cluster-dialog.injectable"; + +const catalogCategoryRegistryInjectable = getInjectable({ + instantiate: (di) => { + const openCommandOverlay = di.inject(openCommandDialogInjectable); + const removeWeblinkById = di.inject(removeWeblinkByIdInjectable); + const userStore = di.inject(userPreferencesStoreInjectable); + const getClusterById = di.inject(getClusterByIdInjectable); + const openDeleteClusterDialog = di.inject(openDeleteClusterDialogInjectable); + + const addSyncEntries = async (filePaths: string[]) => { + const entries = await getAllEntries(filePaths); + + runInAction(() => { + multiSet(userStore.syncKubeconfigEntries, entries); + }); + + Notifications.ok( +
+

Selected items has been added to Kubeconfig Sync.


+

Check the Preferences{" "} + to see full list.

+
, + ); + }; + const onClusterDelete = async (clusterId: string) =>{ + const cluster = getClusterById(clusterId); + + if (!cluster) { + return console.warn("[KUBERNETES-CLUSTER]: cannot delete cluster, does not exist in store", { clusterId }); + } + + const { config, error } = loadConfigFromString(await readFile(cluster.kubeConfigPath, "utf-8")); + + if (error) { + throw error; + } + + openDeleteClusterDialog(cluster, config); + }; + + const registry = new CatalogCategoryRegistry(); + const kubernetesClusterCategory = new KubernetesClusterCategory(); + const webLinkCategory = new WebLinkCategory(); + + registry.add(kubernetesClusterCategory); + registry.add(new GeneralCategory()); + registry.add(webLinkCategory); + + kubernetesClusterCategory.on("contextMenuOpen", (entity, context) => { + if (entity.metadata?.source == "local") { + context.menuItems.push({ + title: "Delete", + icon: "delete", + onClick: () => onClusterDelete(entity.metadata.uid), + }); + } + }); + + kubernetesClusterCategory.on("catalogAddMenu", ctx => { + ctx.menuItems.push( + { + icon: "text_snippet", + title: "Add from kubeconfig", + onClick: () => ctx.navigate(addClusterURL()), + }, + ); + + if (isWindows || isLinux) { + ctx.menuItems.push( + { + icon: "create_new_folder", + title: "Sync kubeconfig folder(s)", + defaultAction: true, + onClick: async () => { + await PathPicker.pick({ + label: "Sync folder(s)", + buttonLabel: "Sync", + properties: ["showHiddenFiles", "multiSelections", "openDirectory"], + onPick: addSyncEntries, + }); + }, + }, + { + icon: "note_add", + title: "Sync kubeconfig file(s)", + onClick: async () => { + await PathPicker.pick({ + label: "Sync file(s)", + buttonLabel: "Sync", + properties: ["showHiddenFiles", "multiSelections", "openFile"], + onPick: addSyncEntries, + }); + }, + }, + ); + } else { + ctx.menuItems.push( + { + icon: "create_new_folder", + title: "Sync kubeconfig(s)", + defaultAction: true, + onClick: async () => { + await PathPicker.pick({ + label: "Sync file(s)", + buttonLabel: "Sync", + properties: ["showHiddenFiles", "multiSelections", "openFile", "openDirectory"], + onPick: addSyncEntries, + }); + }, + }, + ); + } + }); + + webLinkCategory.on("catalogAddMenu", (context) => { + context.menuItems.push({ + icon: "public", + title: "Add web link", + onClick: () => openCommandOverlay(), + }); + }); + + webLinkCategory.on("contextMenuOpen", (entity, context) => { + if (entity.metadata.source === "local") { + context.menuItems.push({ + title: "Delete", + icon: "delete", + onClick: () => removeWeblinkById(entity.getId()), + confirm: { + message: `Remove Web Link "${entity.getName()}" from ${productName}?`, + }, + }); + } + }); + + return registry; + }, + lifecycle: lifecycleEnum.singleton, +}); + +export default catalogCategoryRegistryInjectable; diff --git a/src/renderer/catalog/entities.injectable.ts b/src/renderer/catalog/entities.injectable.ts new file mode 100644 index 0000000000..6de8490dfa --- /dev/null +++ b/src/renderer/catalog/entities.injectable.ts @@ -0,0 +1,13 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import catalogEntityRegistryInjectable from "./entity-registry.injectable"; + +const entitiesInjectable = getInjectable({ + instantiate: (di) => di.inject(catalogEntityRegistryInjectable).computedItems, + lifecycle: lifecycleEnum.singleton, +}); + +export default entitiesInjectable; diff --git a/src/renderer/catalog/entity-registry.injectable.ts b/src/renderer/catalog/entity-registry.injectable.ts new file mode 100644 index 0000000000..c9ed87773c --- /dev/null +++ b/src/renderer/catalog/entity-registry.injectable.ts @@ -0,0 +1,18 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import loggerInjectable from "../../common/logger.injectable"; +import catalogCategoryRegistryInjectable from "./category-registry.injectable"; +import { CatalogEntityRegistry } from "./entity-registry"; + +const catalogEntityRegistryInjectable = getInjectable({ + instantiate: (di) => new CatalogEntityRegistry({ + getEntityForData: di.inject(catalogCategoryRegistryInjectable).getEntityForData, + logger: di.inject(loggerInjectable), + }), + lifecycle: lifecycleEnum.singleton, +}); + +export default catalogEntityRegistryInjectable; diff --git a/src/renderer/api/catalog-entity-registry.ts b/src/renderer/catalog/entity-registry.ts similarity index 77% rename from src/renderer/api/catalog-entity-registry.ts rename to src/renderer/catalog/entity-registry.ts index 85477245a4..3eb65511af 100644 --- a/src/renderer/api/catalog-entity-registry.ts +++ b/src/renderer/catalog/entity-registry.ts @@ -5,13 +5,11 @@ import { computed, observable, makeObservable, action } from "mobx"; import { catalogEntityRunListener, ipcRendererOn } from "../../common/ipc"; -import { CatalogCategory, CatalogEntity, CatalogEntityData, catalogCategoryRegistry, CatalogCategoryRegistry, CatalogEntityKindData } from "../../common/catalog"; +import type { CatalogCategory, CatalogEntity, CatalogEntityData, CatalogEntityKindData } from "../../common/catalog"; import "../../common/catalog-entities"; -import type { Cluster } from "../../common/cluster/cluster"; -import { ClusterStore } from "../../common/cluster-store/cluster-store"; import { Disposer, iter } from "../utils"; import { once } from "lodash"; -import logger from "../../common/logger"; +import type { LensLogger } from "../../common/logger"; import { CatalogRunEvent } from "../../common/catalog/catalog-run-event"; import { ipcRenderer } from "electron"; import { CatalogIpcEvents } from "../../common/ipc/catalog"; @@ -21,12 +19,10 @@ import { isMainFrame } from "process"; export type EntityFilter = (entity: CatalogEntity) => any; export type CatalogEntityOnBeforeRun = (event: CatalogRunEvent) => void | Promise; -export const catalogEntityRunContext = { - navigate: (url: string) => navigate(url), - setCommandPaletteContext: (entity?: CatalogEntity) => { - catalogEntityRegistry.activeEntity = entity; - }, -}; +export interface CatalogEntityRegistryDependecies { + getEntityForData: (data: CatalogEntityData & CatalogEntityKindData) => CatalogEntity | null; + logger: LensLogger; +} export class CatalogEntityRegistry { @observable protected activeEntityId: string | undefined = undefined; @@ -43,7 +39,7 @@ export class CatalogEntityRegistry { */ protected rawEntities: (CatalogEntityData & CatalogEntityKindData)[] = []; - constructor(private categoryRegistry: CatalogCategoryRegistry) { + constructor(protected readonly dependencies: CatalogEntityRegistryDependecies) { makeObservable(this); } @@ -78,6 +74,8 @@ export class CatalogEntityRegistry { } } + readonly activeEntityComputed = computed(() => this.activeEntity); + init() { ipcRendererOn(CatalogIpcEvents.ITEMS, (event, items: (CatalogEntityData & CatalogEntityKindData)[]) => { this.updateItems(items); @@ -117,7 +115,7 @@ export class CatalogEntityRegistry { const existing = this._entities.get(item.metadata.uid); if (!existing) { - const entity = this.categoryRegistry.getEntityForData(item); + const entity = this.dependencies.getEntityForData(item); if (entity) { this._entities.set(entity.metadata.uid, entity); @@ -147,6 +145,8 @@ export class CatalogEntityRegistry { return Array.from(this._entities.values()); } + readonly computedItems = computed(() => this.items); + @computed get filteredItems() { return Array.from( iter.reduce( @@ -169,23 +169,23 @@ export class CatalogEntityRegistry { ); } - getById(id: string) { - return this.entities.get(id) as T; - } + getById = (id: string) => { + return this.entities.get(id); + }; - getItemsForApiKind(apiVersion: string, kind: string, { filtered = false } = {}): T[] { + getItemsForApiKind(apiVersion: string, kind: string, { filtered = false } = {}): CatalogEntity[] { const byApiKind = (item: CatalogEntity) => item.apiVersion === apiVersion && item.kind === kind; const entities = filtered ? this.filteredItems : this.items; - return entities.filter(byApiKind) as T[]; + return entities.filter(byApiKind); } - getItemsForCategory(category: CatalogCategory, { filtered = false } = {}): T[] { + getItemsForCategory(category: CatalogCategory, { filtered = false } = {}): CatalogEntity[] { const supportedVersions = new Set(category.spec.versions.map((v) => `${category.spec.group}/${v.name}`)); const byApiVersionKind = (item: CatalogEntity) => supportedVersions.has(item.apiVersion) && item.kind === category.spec.names.kind; const entities = filtered ? this.filteredItems : this.items; - return entities.filter(byApiVersionKind) as T[]; + return entities.filter(byApiVersionKind); } /** @@ -216,15 +216,21 @@ export class CatalogEntityRegistry { * @returns Whether the entities `onRun` method should be executed */ async onBeforeRun(entity: CatalogEntity): Promise { - logger.debug(`[CATALOG-ENTITY-REGISTRY]: run onBeforeRun on ${entity.getId()}`); + this.dependencies.logger.debug(`[CATALOG-ENTITY-REGISTRY]: run onBeforeRun on ${entity.getId()}`); const runEvent = new CatalogRunEvent({ target: entity }); for (const onBeforeRun of this.onBeforeRunHooks) { + let limit; + try { - await onBeforeRun(runEvent); + await Promise.race([ + onBeforeRun(runEvent), + new Promise((resolve, reject) => limit = setTimeout(() => reject(new Error("timeout error")), 10_000)), + ]); } catch (error) { - logger.warn(`[CATALOG-ENTITY-REGISTRY]: entity ${entity.getId()} onBeforeRun threw an error`, error); + clearTimeout(limit); + this.dependencies.logger.warn(`[CATALOG-ENTITY-REGISTRY]: entity ${entity.getId()} onBeforeRun threw an error`, error); } if (runEvent.defaultPrevented) { @@ -239,21 +245,20 @@ export class CatalogEntityRegistry { * Perform the onBeforeRun check and, if successful, then proceed to call `entity`'s onRun method * @param entity The instance to invoke the hooks and then execute the onRun */ - onRun(entity: CatalogEntity): void { + onRun = (entity: CatalogEntity): void => { this.onBeforeRun(entity) .then(doOnRun => { if (doOnRun) { - return entity.onRun?.(catalogEntityRunContext); + return entity.onRun?.({ + navigate: (url) => navigate(url), + setCommandPaletteContext: (entity) => { + this.activeEntity = entity; + }, + }); } else { - logger.debug(`onBeforeRun for ${entity.getId()} returned false`); + this.dependencies.logger.debug(`onBeforeRun for ${entity.getId()} returned false`); } }) - .catch(error => logger.error(`[CATALOG-ENTITY-REGISTRY]: entity ${entity.getId()} onRun threw an error`, error)); - } -} - -export const catalogEntityRegistry = new CatalogEntityRegistry(catalogCategoryRegistry); - -export function getActiveClusterEntity(): Cluster | undefined { - return ClusterStore.getInstance().getById(catalogEntityRegistry.activeEntity?.getId()); + .catch(error => this.dependencies.logger.error(`[CATALOG-ENTITY-REGISTRY]: entity ${entity.getId()} onRun threw an error`, error)); + }; } diff --git a/src/renderer/catalog/get-category-by-name.injectable.ts b/src/renderer/catalog/get-category-by-name.injectable.ts new file mode 100644 index 0000000000..4f49c2b215 --- /dev/null +++ b/src/renderer/catalog/get-category-by-name.injectable.ts @@ -0,0 +1,13 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import catalogCategoryRegistryInjectable from "./category-registry.injectable"; + +const getCategoryByNameInjectable = getInjectable({ + instantiate: (di) => di.inject(catalogCategoryRegistryInjectable).getByName, + lifecycle: lifecycleEnum.singleton, +}); + +export default getCategoryByNameInjectable; diff --git a/src/renderer/catalog/get-category-for-entity.injectable.ts b/src/renderer/catalog/get-category-for-entity.injectable.ts new file mode 100644 index 0000000000..4833f60a0c --- /dev/null +++ b/src/renderer/catalog/get-category-for-entity.injectable.ts @@ -0,0 +1,13 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import catalogCategoryRegistryInjectable from "./category-registry.injectable"; + +const getCategoryForEntityInjectable = getInjectable({ + instantiate: (di) => di.inject(catalogCategoryRegistryInjectable).getCategoryForEntity, + lifecycle: lifecycleEnum.singleton, +}); + +export default getCategoryForEntityInjectable; diff --git a/src/renderer/catalog/get-entity-by-id.injectable.ts b/src/renderer/catalog/get-entity-by-id.injectable.ts new file mode 100644 index 0000000000..0aab182cf5 --- /dev/null +++ b/src/renderer/catalog/get-entity-by-id.injectable.ts @@ -0,0 +1,13 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import catalogEntityRegistryInjectable from "./entity-registry.injectable"; + +const getEntityByIdInjectable = getInjectable({ + instantiate: (di) => di.inject(catalogEntityRegistryInjectable).getById, + lifecycle: lifecycleEnum.singleton, +}); + +export default getEntityByIdInjectable; diff --git a/src/renderer/catalog/on-entity-context-menu-open.injectable.ts b/src/renderer/catalog/on-entity-context-menu-open.injectable.ts new file mode 100644 index 0000000000..7e7b8bb6fc --- /dev/null +++ b/src/renderer/catalog/on-entity-context-menu-open.injectable.ts @@ -0,0 +1,26 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import type { CatalogCategory, CatalogEntity, CatalogEntityContextMenuContext } from "../../common/catalog"; +import { bind } from "../utils"; +import getCategoryForEntityInjectable from "./get-category-for-entity.injectable"; + +interface Dependencies { + getCategoryForEntity: (entity: CatalogEntity) => CatalogCategory; +} + +function onEntityContextMenuOpen({ getCategoryForEntity }: Dependencies, entity: CatalogEntity, context: CatalogEntityContextMenuContext): void { + entity.onContextMenuOpen?.(context); + getCategoryForEntity(entity).emit("contextMenuOpen", entity, context); +} + +const onEntityContextMenuOpenInjectable = getInjectable({ + instantiate: (di) => bind(onEntityContextMenuOpen, null, { + getCategoryForEntity: di.inject(getCategoryForEntityInjectable), + }), + lifecycle: lifecycleEnum.singleton, +}); + +export default onEntityContextMenuOpenInjectable; diff --git a/src/renderer/catalog/on-run.injectable.ts b/src/renderer/catalog/on-run.injectable.ts new file mode 100644 index 0000000000..ecb7356dd7 --- /dev/null +++ b/src/renderer/catalog/on-run.injectable.ts @@ -0,0 +1,13 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import catalogEntityRegistryInjectable from "./entity-registry.injectable"; + +const onRunInjectable = getInjectable({ + instantiate: (di) => di.inject(catalogEntityRegistryInjectable).onRun, + lifecycle: lifecycleEnum.singleton, +}); + +export default onRunInjectable; diff --git a/src/renderer/catalog/render-context-menu-item.injectable.tsx b/src/renderer/catalog/render-context-menu-item.injectable.tsx new file mode 100644 index 0000000000..2c7d15dc55 --- /dev/null +++ b/src/renderer/catalog/render-context-menu-item.injectable.tsx @@ -0,0 +1,65 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import React from "react"; +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import type { ConfirmDialogParams } from "../components/confirm-dialog"; +import openConfirmDialogInjectable from "../components/confirm-dialog/dialog-open.injectable"; +import { bind } from "../../common/utils"; +import type { CatalogEntityContextMenu } from "../../common/catalog/catalog-entity"; +import { MenuItem } from "../components/menu"; +import { Icon } from "../components/icon"; + +interface Dependencies { + openConfirmDialog: (params: ConfirmDialogParams) => void; +} + +function registerEntityContextMenuItem({ openConfirmDialog }: Dependencies, display: "icon" | "title"): ({ title, icon, onClick: rawOnClick, confirm }: CatalogEntityContextMenu, index: number) => React.ReactNode { + return ({ title, icon, onClick: rawOnClick, confirm }: CatalogEntityContextMenu, index: number) => { + if (display === "icon" && !icon) { + return null; + } + + const onClick = confirm + ? () => openConfirmDialog({ + okButtonProps: { + primary: false, + accent: true, + }, + ok: rawOnClick, + message: confirm.message, + }) + : () => { + (async () => await rawOnClick())() + .catch(error => console.error(error)); + }; + + return ( + + { + display === "title" + ? title + : ( + + ) + } + + ); + }; +} + +export type RenderEntityContextMenuItem = (display: "title" | "icon") => (menuItem: CatalogEntityContextMenu, index: number) => React.ReactNode; + +const renderEntityContextMenuItemInjectable = getInjectable({ + instantiate: (di) => bind(registerEntityContextMenuItem, null, { + openConfirmDialog: di.inject(openConfirmDialogInjectable), + }) as RenderEntityContextMenuItem, + lifecycle: lifecycleEnum.singleton, +}); + +export default renderEntityContextMenuItemInjectable; diff --git a/src/renderer/catalog/set-entity-on-route-match.injectable.ts b/src/renderer/catalog/set-entity-on-route-match.injectable.ts new file mode 100644 index 0000000000..9e882b670c --- /dev/null +++ b/src/renderer/catalog/set-entity-on-route-match.injectable.ts @@ -0,0 +1,40 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { CatalogEntityRegistry } from "./entity-registry"; +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { bind } from "../utils"; +import catalogEntityRegistryInjectable from "./entity-registry.injectable"; +import type { CatalogCategoryRegistry } from "../../common/catalog"; +import catalogCategoryRegistryInjectable from "./category-registry.injectable"; +import { when } from "mobx"; +import { isActiveRoute } from "../navigation"; + +interface Dependencies { + catalogEntityRegistry: CatalogEntityRegistry; + catalogCategoryRegistry: CatalogCategoryRegistry; +} + +async function setEntityOnRouteMatch({ catalogEntityRegistry, catalogCategoryRegistry }: Dependencies) { + await when(() => catalogEntityRegistry.entities.size > 0); + + const entities = catalogEntityRegistry.getItemsForCategory(catalogCategoryRegistry.getByName("General")); + const activeEntity = entities.find(entity => isActiveRoute(entity.spec.path)); + + if (activeEntity) { + catalogEntityRegistry.activeEntity = activeEntity; + } +} + +const setEntityOnRouteMatchInjectable = getInjectable({ + instantiate: (di) => bind(setEntityOnRouteMatch, null, { + catalogEntityRegistry: di.inject(catalogEntityRegistryInjectable), + catalogCategoryRegistry: di.inject(catalogCategoryRegistryInjectable), + }), + lifecycle: lifecycleEnum.singleton, +}); + +export default setEntityOnRouteMatchInjectable; + diff --git a/src/renderer/cluster-frame-context/cluster-frame-context.injectable.ts b/src/renderer/cluster-frame-context/cluster-frame-context.injectable.ts index 1d15174f04..6e57c8c073 100644 --- a/src/renderer/cluster-frame-context/cluster-frame-context.injectable.ts +++ b/src/renderer/cluster-frame-context/cluster-frame-context.injectable.ts @@ -3,24 +3,18 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; -import { ClusterFrameContext } from "./cluster-frame-context"; -import namespaceStoreInjectable from "../components/+namespaces/namespace-store/namespace-store.injectable"; +import { FrameContext } from "./cluster-frame-context"; import hostedClusterInjectable from "../../common/cluster-store/hosted-cluster/hosted-cluster.injectable"; +import namespacesInjectable from "../components/+namespaces/namespaces.injectable"; +import selectedNamespacesInjectable from "../components/+namespaces/selected-namespaces.injectable"; -const clusterFrameContextInjectable = getInjectable({ - instantiate: (di) => { - const cluster = di.inject(hostedClusterInjectable); - - return new ClusterFrameContext( - cluster, - - { - namespaceStore: di.inject(namespaceStoreInjectable), - }, - ); - }, - +const frameContextInjectable = getInjectable({ + instantiate: (di) => new FrameContext({ + cluster: di.inject(hostedClusterInjectable), + namespaces: di.inject(namespacesInjectable), + selectedNamespaces: di.inject(selectedNamespacesInjectable), + }), lifecycle: lifecycleEnum.singleton, }); -export default clusterFrameContextInjectable; +export default frameContextInjectable; diff --git a/src/renderer/cluster-frame-context/cluster-frame-context.ts b/src/renderer/cluster-frame-context/cluster-frame-context.ts index 3a4c453987..ffc75edf07 100755 --- a/src/renderer/cluster-frame-context/cluster-frame-context.ts +++ b/src/renderer/cluster-frame-context/cluster-frame-context.ts @@ -2,38 +2,44 @@ * Copyright (c) OpenLens Authors. All rights reserved. * Licensed under MIT License. See LICENSE in root directory for more information. */ - import type { Cluster } from "../../common/cluster/cluster"; -import type { NamespaceStore } from "../components/+namespaces/namespace-store/namespace.store"; import type { ClusterContext } from "../../common/k8s-api/cluster-context"; -import { computed, makeObservable } from "mobx"; +import { computed, IComputedValue, makeObservable } from "mobx"; -interface Dependencies { - namespaceStore: NamespaceStore +export interface FrameContextDependencies { + readonly cluster: Cluster; + readonly namespaces: IComputedValue; + readonly selectedNamespaces: IComputedValue; } -export class ClusterFrameContext implements ClusterContext { - constructor(public cluster: Cluster, private dependencies: Dependencies) { +export class FrameContext implements ClusterContext { + constructor(protected readonly dependencies: FrameContextDependencies) { makeObservable(this); } + get cluster() { + return this.dependencies.cluster; + } + @computed get allNamespaces(): string[] { // user given list of namespaces if (this.cluster.accessibleNamespaces.length) { return this.cluster.accessibleNamespaces; } - if (this.dependencies.namespaceStore.items.length > 0) { + const namespaces = this.dependencies.namespaces.get(); + + if (namespaces.length > 0) { // namespaces from kubernetes api - return this.dependencies.namespaceStore.items.map((namespace) => namespace.getName()); + return namespaces; } else { // fallback to cluster resolved namespaces because we could not load list return this.cluster.allowedNamespaces || []; } } - @computed get contextNamespaces(): string[] { - return this.dependencies.namespaceStore.contextNamespaces; + get contextNamespaces(): string[] { + return this.dependencies.selectedNamespaces.get(); } @computed get hasSelectedAll(): boolean { diff --git a/src/renderer/components/+add-cluster/add-cluster.tsx b/src/renderer/components/+add-cluster/add-cluster.tsx index ba06039d4c..30942700af 100644 --- a/src/renderer/components/+add-cluster/add-cluster.tsx +++ b/src/renderer/components/+add-cluster/add-cluster.tsx @@ -25,8 +25,7 @@ import { Notifications } from "../notifications"; import { SettingLayout } from "../layout/setting-layout"; import { MonacoEditor } from "../monaco-editor"; import { withInjectables } from "@ogre-tools/injectable-react"; -import getCustomKubeConfigDirectoryInjectable - from "../../../common/app-paths/get-custom-kube-config-directory/get-custom-kube-config-directory.injectable"; +import getCustomKubeConfigDirectoryInjectable from "../../../common/app-paths/get-custom-kube-config-directory.injectable"; interface Option { config: KubeConfig; diff --git a/src/renderer/components/+apps-releases/release-details.tsx b/src/renderer/components/+apps-releases/release-details.tsx deleted file mode 100644 index 7712ea4052..0000000000 --- a/src/renderer/components/+apps-releases/release-details.tsx +++ /dev/null @@ -1,321 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import "./release-details.scss"; - -import React, { Component } from "react"; -import groupBy from "lodash/groupBy"; -import isEqual from "lodash/isEqual"; -import { makeObservable, observable, reaction } from "mobx"; -import { Link } from "react-router-dom"; -import kebabCase from "lodash/kebabCase"; -import { getRelease, getReleaseValues, HelmRelease, IReleaseDetails } from "../../../common/k8s-api/endpoints/helm-releases.api"; -import { HelmReleaseMenu } from "./release-menu"; -import { Drawer, DrawerItem, DrawerTitle } from "../drawer"; -import { Badge } from "../badge"; -import { cssNames, stopPropagation } from "../../utils"; -import { disposeOnUnmount, observer } from "mobx-react"; -import { Spinner } from "../spinner"; -import { Table, TableCell, TableHead, TableRow } from "../table"; -import { Button } from "../button"; -import type { ReleaseStore } from "./release.store"; -import { Notifications } from "../notifications"; -import { ThemeStore } from "../../theme.store"; -import { apiManager } from "../../../common/k8s-api/api-manager"; -import { SubTitle } from "../layout/sub-title"; -import { secretsStore } from "../+config-secrets/secrets.store"; -import { Secret } from "../../../common/k8s-api/endpoints"; -import { getDetailsUrl } from "../kube-detail-params"; -import { Checkbox } from "../checkbox"; -import { MonacoEditor } from "../monaco-editor"; -import { withInjectables } from "@ogre-tools/injectable-react"; -import releaseStoreInjectable from "./release-store.injectable"; -import createUpgradeChartTabInjectable - from "../dock/create-upgrade-chart-tab/create-upgrade-chart-tab.injectable"; - -interface Props { - release: HelmRelease; - hideDetails(): void; -} - -interface Dependencies { - releaseStore: ReleaseStore - createUpgradeChartTab: (release: HelmRelease) => void -} - -@observer -class NonInjectedReleaseDetails extends Component { - @observable details: IReleaseDetails | null = null; - @observable values = ""; - @observable valuesLoading = false; - @observable showOnlyUserSuppliedValues = true; - @observable saving = false; - @observable releaseSecret: Secret; - @observable error?: string = undefined; - - componentDidMount() { - disposeOnUnmount(this, [ - reaction(() => this.props.release, release => { - if (!release) return; - this.loadDetails(); - this.loadValues(); - this.releaseSecret = null; - }), - reaction(() => secretsStore.getItems(), () => { - if (!this.props.release) return; - const { getReleaseSecret } = this.props.releaseStore; - const { release } = this.props; - const secret = getReleaseSecret(release); - - if (this.releaseSecret) { - if (isEqual(this.releaseSecret.getLabels(), secret.getLabels())) return; - this.loadDetails(); - } - this.releaseSecret = secret; - }), - reaction(() => this.showOnlyUserSuppliedValues, () => { - this.loadValues(); - }), - ]); - } - - constructor(props: Props & Dependencies) { - super(props); - makeObservable(this); - } - - async loadDetails() { - const { release } = this.props; - - try { - this.details = null; - this.details = await getRelease(release.getName(), release.getNs()); - } catch (error) { - this.error = `Failed to get release details: ${error}`; - } - } - - async loadValues() { - const { release } = this.props; - - try { - this.valuesLoading = true; - this.values = (await getReleaseValues(release.getName(), release.getNs(), !this.showOnlyUserSuppliedValues)) ?? ""; - } catch (error) { - Notifications.error(`Failed to load values for ${release.getName()}: ${error}`); - this.values = ""; - } finally { - this.valuesLoading = false; - } - } - - updateValues = async () => { - const { release } = this.props; - const name = release.getName(); - const namespace = release.getNs(); - const data = { - chart: release.getChart(), - repo: await release.getRepo(), - version: release.getVersion(), - values: this.values, - }; - - this.saving = true; - - try { - await this.props.releaseStore.update(name, namespace, data); - Notifications.ok( -

Release {name} successfully updated!

, - ); - } catch (err) { - Notifications.error(err); - } - this.saving = false; - }; - - upgradeVersion = () => { - const { release, hideDetails } = this.props; - - this.props.createUpgradeChartTab(release); - hideDetails(); - }; - - renderValues() { - const { values, valuesLoading, saving } = this; - - return ( -
- -
- this.showOnlyUserSuppliedValues = value} - disabled={valuesLoading} - /> - this.values = text} - > - {valuesLoading && } - -
-
- ); - } - - renderNotes() { - if (!this.details.info?.notes) return null; - const { notes } = this.details.info; - - return ( -
- {notes} -
- ); - } - - renderResources() { - const { resources } = this.details; - - if (!resources) return null; - const groups = groupBy(resources, item => item.kind); - const tables = Object.entries(groups).map(([kind, items]) => { - return ( - - - - - Name - {items[0].getNs() && Namespace} - Age - - {items.map(item => { - const name = item.getName(); - const namespace = item.getNs(); - const api = apiManager.getApi(api => api.kind === kind && api.apiVersionWithGroup == item.apiVersion); - const detailsUrl = api ? getDetailsUrl(api.getUrl({ name, namespace })) : ""; - - return ( - - - {detailsUrl ? {name} : name} - - {namespace && {namespace}} - {item.getAge()} - - ); - })} -
-
- ); - }); - - return ( -
- {tables} -
- ); - } - - renderContent() { - const { release } = this.props; - - if (!release) return null; - - if (this.error) { - return ( -
- {this.error} -
- ); - } - - if (!this.details) { - return ; - } - - return ( -
- -
- {release.getChart()} -
-
- - {release.getUpdated()} ago ({release.updated}) - - - {release.getNs()} - - -
- - {release.getVersion()} - -
-
- - - - {this.renderValues()} - - {this.renderNotes()} - - {this.renderResources()} -
- ); - } - - render() { - const { release, hideDetails } = this.props; - const title = release ? `Release: ${release.getName()}` : ""; - const toolbar = ; - - return ( - - {this.renderContent()} - - ); - } -} - -export const ReleaseDetails = withInjectables( - NonInjectedReleaseDetails, - - { - getProps: (di, props) => ({ - releaseStore: di.inject(releaseStoreInjectable), - createUpgradeChartTab: di.inject(createUpgradeChartTabInjectable), - ...props, - }), - }, -); diff --git a/src/renderer/components/+apps-releases/release-menu.tsx b/src/renderer/components/+apps-releases/release-menu.tsx deleted file mode 100644 index a4d4587e86..0000000000 --- a/src/renderer/components/+apps-releases/release-menu.tsx +++ /dev/null @@ -1,97 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import React from "react"; -import type { HelmRelease } from "../../../common/k8s-api/endpoints/helm-releases.api"; -import { cssNames } from "../../utils"; -import type { ReleaseStore } from "./release.store"; -import { MenuActions, MenuActionsProps } from "../menu/menu-actions"; -import { MenuItem } from "../menu"; -import { Icon } from "../icon"; -import { withInjectables } from "@ogre-tools/injectable-react"; -import releaseStoreInjectable from "./release-store.injectable"; -import createUpgradeChartTabInjectable - from "../dock/create-upgrade-chart-tab/create-upgrade-chart-tab.injectable"; -import releaseRollbackDialogModelInjectable - from "./release-rollback-dialog-model/release-rollback-dialog-model.injectable"; - -interface Props extends MenuActionsProps { - release: HelmRelease; - hideDetails?(): void; -} - -interface Dependencies { - releaseStore: ReleaseStore - createUpgradeChartTab: (release: HelmRelease) => void - openRollbackDialog: (release: HelmRelease) => void -} - -class NonInjectedHelmReleaseMenu extends React.Component { - remove = () => { - return this.props.releaseStore.remove(this.props.release); - }; - - upgrade = () => { - const { release, hideDetails } = this.props; - - this.props.createUpgradeChartTab(release); - hideDetails?.(); - }; - - rollback = () => { - this.props.openRollbackDialog(this.props.release); - }; - - renderContent() { - const { release, toolbar } = this.props; - - if (!release) return null; - const hasRollback = release && release.getRevision() > 1; - - return ( - <> - {hasRollback && ( - - - Rollback - - )} - - - Upgrade - - - ); - } - - render() { - const { className, release, ...menuProps } = this.props; - - return ( -

Remove Helm Release {release.name}?

} - > - {this.renderContent()} -
- ); - } -} - -export const HelmReleaseMenu = withInjectables( - NonInjectedHelmReleaseMenu, - - { - getProps: (di, props) => ({ - releaseStore: di.inject(releaseStoreInjectable), - createUpgradeChartTab: di.inject(createUpgradeChartTabInjectable), - openRollbackDialog: di.inject(releaseRollbackDialogModelInjectable).open, - - ...props, - }), - }, -); diff --git a/src/renderer/components/+apps-releases/release-rollback-dialog-model/release-rollback-dialog-model.ts b/src/renderer/components/+apps-releases/release-rollback-dialog-model/release-rollback-dialog-model.ts deleted file mode 100644 index 21ec78534a..0000000000 --- a/src/renderer/components/+apps-releases/release-rollback-dialog-model/release-rollback-dialog-model.ts +++ /dev/null @@ -1,31 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ -import { computed, observable, makeObservable, action } from "mobx"; -import type { HelmRelease } from "../../../../common/k8s-api/endpoints/helm-releases.api"; - -export class ReleaseRollbackDialogModel { - release: HelmRelease | null = null; - - constructor() { - makeObservable(this, { - isOpen: computed, - release: observable, - open: action, - close: action, - }); - } - - get isOpen() { - return !!this.release; - } - - open = (release: HelmRelease) => { - this.release = release; - }; - - close = () => { - this.release = null; - }; -} diff --git a/src/renderer/components/+apps-releases/release-rollback-dialog.tsx b/src/renderer/components/+apps-releases/release-rollback-dialog.tsx deleted file mode 100644 index d8f731f902..0000000000 --- a/src/renderer/components/+apps-releases/release-rollback-dialog.tsx +++ /dev/null @@ -1,127 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import "./release-rollback-dialog.scss"; - -import React from "react"; -import { observable, makeObservable } from "mobx"; -import { observer } from "mobx-react"; -import { Dialog, DialogProps } from "../dialog"; -import { Wizard, WizardStep } from "../wizard"; -import { getReleaseHistory, HelmRelease, IReleaseRevision } from "../../../common/k8s-api/endpoints/helm-releases.api"; -import { Select, SelectOption } from "../select"; -import { Notifications } from "../notifications"; -import orderBy from "lodash/orderBy"; -import { withInjectables } from "@ogre-tools/injectable-react"; -import releaseStoreInjectable from "./release-store.injectable"; -import releaseRollbackDialogModelInjectable - from "./release-rollback-dialog-model/release-rollback-dialog-model.injectable"; -import type { ReleaseRollbackDialogModel } from "./release-rollback-dialog-model/release-rollback-dialog-model"; - -interface Props extends DialogProps { -} - -interface Dependencies { - rollbackRelease: (releaseName: string, namespace: string, revisionNumber: number) => Promise - model: ReleaseRollbackDialogModel -} - -@observer -class NonInjectedReleaseRollbackDialog extends React.Component { - @observable isLoading = false; - @observable revision: IReleaseRevision; - @observable revisions = observable.array(); - - constructor(props: Props & Dependencies) { - super(props); - makeObservable(this); - } - - get release(): HelmRelease { - return this.props.model.release; - } - - onOpen = async () => { - this.isLoading = true; - let releases = await getReleaseHistory(this.release.getName(), this.release.getNs()); - - releases = orderBy(releases, "revision", "desc"); // sort - this.revisions.replace(releases); - this.revision = this.revisions[0]; - this.isLoading = false; - }; - - rollback = async () => { - const revisionNumber = this.revision.revision; - - try { - await this.props.rollbackRelease(this.release.getName(), this.release.getNs(), revisionNumber); - this.props.model.close(); - } catch (err) { - Notifications.error(err); - } - }; - - renderContent() { - const { revision, revisions } = this; - - if (!revision) { - return

No revisions to rollback.

; - } - - return ( -
- Revision - ) => ( + <> + + {" "} + {value.getName()} + + )} + onChange={({ value }: SelectOption ) => { + if (!selectedRoleRef || bindingName === selectedRoleRef.getName()) { + setBindingName(value.getName()); + } + + setSelectedRoleRef(value); + }} + /> + + + + + + + Users + selectedUsers.add(newUser)} + items={Array.from(selectedUsers)} + remove={({ oldItem }) => selectedUsers.delete(oldItem)} + /> + + Groups + selectedGroups.add(newGroup)} + items={Array.from(selectedGroups)} + remove={({ oldItem }) => selectedGroups.delete(oldItem)} + /> + + Service Accounts + + + + + ); +}); + +export const AddClusterRoleDialog = withInjectables(NonInjectedAddClusterRoleDialog, { + getProps: (di, props) => ({ + clusterRoleStore: di.inject(clusterRoleStoreInjectable), + closeAddClusterRoleDialog: di.inject(closeAddClusterRoleDialogInjectable), + state: di.inject(addClusterRoleDialogStateInjectable), + ...props, + }), +}); diff --git a/src/renderer/components/+cluster-roles/close-add-dialog.injectable.ts b/src/renderer/components/+cluster-roles/close-add-dialog.injectable.ts new file mode 100644 index 0000000000..af4143ad79 --- /dev/null +++ b/src/renderer/components/+cluster-roles/close-add-dialog.injectable.ts @@ -0,0 +1,28 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { runInAction } from "mobx"; +import { bind } from "../../utils"; +import type { ClusterRoleAddDialogState } from "./add-dialog.state.injectable"; +import ClusterRoleDialogStateInjectable from "./add-dialog.state.injectable"; + +interface Dependencies { + state: ClusterRoleAddDialogState; +} + +function closeAddClusterRoleDialog({ state }: Dependencies): void { + runInAction(() => { + state.isOpen = false; + }); +} + +const closeAddClusterRoleDialogInjectable = getInjectable({ + instantiate: (di) => bind(closeAddClusterRoleDialog, null, { + state: di.inject(ClusterRoleDialogStateInjectable), + }), + lifecycle: lifecycleEnum.singleton, +}); + +export default closeAddClusterRoleDialogInjectable; diff --git a/src/renderer/components/+user-management/+cluster-roles/details.scss b/src/renderer/components/+cluster-roles/details.scss similarity index 100% rename from src/renderer/components/+user-management/+cluster-roles/details.scss rename to src/renderer/components/+cluster-roles/details.scss diff --git a/src/renderer/components/+cluster-roles/details.tsx b/src/renderer/components/+cluster-roles/details.tsx new file mode 100644 index 0000000000..68e9edcd3d --- /dev/null +++ b/src/renderer/components/+cluster-roles/details.tsx @@ -0,0 +1,86 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import "./details.scss"; + +import { observer } from "mobx-react"; +import React from "react"; + +import { DrawerTitle } from "../drawer"; +import type { KubeObjectDetailsProps } from "../kube-object-details"; +import { KubeObjectMeta } from "../kube-object-meta"; +import { ClusterRole } from "../../../common/k8s-api/endpoints"; +import { withInjectables } from "@ogre-tools/injectable-react"; +import logger from "../../../common/logger"; + +export interface ClusterRoleDetailsProps extends KubeObjectDetailsProps { +} + +interface Dependencies { + +} + +const NonInjectedClusterRoleDetails = observer(({ object: clusterRole }: Dependencies & ClusterRoleDetailsProps) => { + if (!clusterRole) { + return null; + } + + if (!(clusterRole instanceof ClusterRole)) { + logger.error("[ClusterRoleDetails]: passed object that is not an instanceof ClusterRole", clusterRole); + + return null; + } + + return ( +
+ + + + { + clusterRole.getRules() + .map(({ resourceNames, apiGroups, resources, verbs }, index) => ( +
+ {resources && ( + <> +
Resources
+
{resources.join(", ")}
+ + )} + {verbs && ( + <> +
Verbs
+
{verbs.join(", ")}
+ + )} + {apiGroups && ( + <> +
Api Groups
+
+ {apiGroups + .map(apiGroup => apiGroup === "" ? `'${apiGroup}'` : apiGroup) + .join(", ")} +
+ + )} + {resourceNames && ( + <> +
Resource Names
+
{resourceNames.join(", ")}
+ + )} +
+ )) + } +
+ ); +}); + +export const ClusterRoleDetails = withInjectables(NonInjectedClusterRoleDetails, { + getProps: (di, props) => ({ + + ...props, + }), +}); + diff --git a/src/renderer/components/+user-management/+cluster-roles/index.ts b/src/renderer/components/+cluster-roles/index.ts similarity index 100% rename from src/renderer/components/+user-management/+cluster-roles/index.ts rename to src/renderer/components/+cluster-roles/index.ts diff --git a/src/renderer/components/+cluster-roles/open-add-dialog.injectable.ts b/src/renderer/components/+cluster-roles/open-add-dialog.injectable.ts new file mode 100644 index 0000000000..068036c52f --- /dev/null +++ b/src/renderer/components/+cluster-roles/open-add-dialog.injectable.ts @@ -0,0 +1,28 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { runInAction } from "mobx"; +import { bind } from "../../utils"; +import type { ClusterRoleAddDialogState } from "./add-dialog.state.injectable"; +import ClusterRoleAddDialogStateInjectable from "./add-dialog.state.injectable"; + +interface Dependencies { + state: ClusterRoleAddDialogState; +} + +function openClusterRoleAddDialog({ state }: Dependencies): void { + runInAction(() => { + state.isOpen = true; + }); +} + +const openAddClusterRoleDialogInjectable = getInjectable({ + instantiate: (di) => bind(openClusterRoleAddDialog, null, { + state: di.inject(ClusterRoleAddDialogStateInjectable), + }), + lifecycle: lifecycleEnum.singleton, +}); + +export default openAddClusterRoleDialogInjectable; diff --git a/src/renderer/components/+cluster-roles/store.injectable.ts b/src/renderer/components/+cluster-roles/store.injectable.ts new file mode 100644 index 0000000000..63c47886f8 --- /dev/null +++ b/src/renderer/components/+cluster-roles/store.injectable.ts @@ -0,0 +1,14 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import apiManagerInjectable from "../../../common/k8s-api/api-manager.injectable"; +import type { ClusterRoleStore } from "./store"; + +const clusterRoleStoreInjectable = getInjectable({ + instantiate: (di) => di.inject(apiManagerInjectable).getStore("/apis/rbac.authorization.k8s.io/v1/clusterroles") as ClusterRoleStore, + lifecycle: lifecycleEnum.singleton, +}); + +export default clusterRoleStoreInjectable; diff --git a/src/renderer/components/+cluster-roles/store.ts b/src/renderer/components/+cluster-roles/store.ts new file mode 100644 index 0000000000..6e53ea7780 --- /dev/null +++ b/src/renderer/components/+cluster-roles/store.ts @@ -0,0 +1,21 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import type { ClusterRole, ClusterRoleApi } from "../../../common/k8s-api/endpoints"; +import { KubeObjectStore } from "../../../common/k8s-api/kube-object.store"; +import { autoBind } from "../../utils"; + +export class ClusterRoleStore extends KubeObjectStore { + constructor(public readonly api:ClusterRoleApi) { + super(); + autoBind(this); + } + + protected sortItems(items: ClusterRole[]) { + return super.sortItems(items, [ + clusterRole => clusterRole.kind, + clusterRole => clusterRole.getName(), + ]); + } +} diff --git a/src/renderer/components/+user-management/+cluster-roles/view.scss b/src/renderer/components/+cluster-roles/view.scss similarity index 100% rename from src/renderer/components/+user-management/+cluster-roles/view.scss rename to src/renderer/components/+cluster-roles/view.scss diff --git a/src/renderer/components/+cluster-roles/view.tsx b/src/renderer/components/+cluster-roles/view.tsx new file mode 100644 index 0000000000..22c5060c03 --- /dev/null +++ b/src/renderer/components/+cluster-roles/view.tsx @@ -0,0 +1,75 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import "./view.scss"; + +import { observer } from "mobx-react"; +import React from "react"; +import type { RouteComponentProps } from "react-router"; +import { KubeObjectListLayout } from "../kube-object-list-layout"; +import { KubeObjectStatusIcon } from "../kube-object-status-icon"; +import { AddClusterRoleDialog } from "./add-dialog"; +import type { ClusterRoleStore } from "./store"; +import type { ClusterRolesRouteParams } from "../../../common/routes"; +import { withInjectables } from "@ogre-tools/injectable-react"; +import clusterRoleStoreInjectable from "./store.injectable"; +import openAddClusterRoleDialogInjectable from "./open-add-dialog.injectable"; + +enum columnId { + name = "name", + namespace = "namespace", + age = "age", +} + +export interface ClusterRolesProps extends RouteComponentProps { +} + +interface Dependencies { + clusterRoleStore: ClusterRoleStore; + openAddClusterRoleDialog: () => void; +} + +const NonInjectedClusterRoles = observer(({ clusterRoleStore, openAddClusterRoleDialog }: Dependencies & ClusterRolesProps) => ( + <> + clusterRole.getName(), + [columnId.age]: clusterRole => clusterRole.getTimeDiffFromNow(), + }} + searchFilters={[ + clusterRole => clusterRole.getSearchFields(), + ]} + renderHeaderTitle="Cluster Roles" + renderTableHeader={[ + { title: "Name", className: "name", sortBy: columnId.name, id: columnId.name }, + { className: "warning", showWithColumn: columnId.name }, + { title: "Age", className: "age", sortBy: columnId.age, id: columnId.age }, + ]} + renderTableContents={clusterRole => [ + clusterRole.getName(), + , + clusterRole.getAge(), + ]} + addRemoveButtons={{ + onAdd: openAddClusterRoleDialog, + addTooltip: "Create new ClusterRole", + }} + /> + + +)); + +export const ClusterRoles = withInjectables(NonInjectedClusterRoles, { + getProps: (di, props) => ({ + clusterRoleStore: di.inject(clusterRoleStoreInjectable), + openAddClusterRoleDialog: di.inject(openAddClusterRoleDialogInjectable), + ...props, + }), +}); + diff --git a/src/renderer/components/+cluster/cluster-overview-store/cluster-overview-store.injectable.ts b/src/renderer/components/+cluster/cluster-overview-store/cluster-overview-store.injectable.ts deleted file mode 100644 index 914a6e8c7f..0000000000 --- a/src/renderer/components/+cluster/cluster-overview-store/cluster-overview-store.injectable.ts +++ /dev/null @@ -1,41 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ -import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; -import { - ClusterOverviewStorageState, - ClusterOverviewStore, - MetricNodeRole, - MetricType, -} from "./cluster-overview-store"; -import createStorageInjectable from "../../../utils/create-storage/create-storage.injectable"; -import apiManagerInjectable from "../../kube-object-menu/dependencies/api-manager.injectable"; - -const clusterOverviewStoreInjectable = getInjectable({ - instantiate: (di) => { - const createStorage = di.inject(createStorageInjectable); - - const storage = createStorage( - "cluster_overview", - { - metricType: MetricType.CPU, // setup defaults - metricNodeRole: MetricNodeRole.WORKER, - }, - ); - - const store = new ClusterOverviewStore({ - storage, - }); - - const apiManager = di.inject(apiManagerInjectable); - - apiManager.registerStore(store); - - return store; - }, - - lifecycle: lifecycleEnum.singleton, -}); - -export default clusterOverviewStoreInjectable; diff --git a/src/renderer/components/+cluster/cluster-overview-store/cluster-overview-store.ts b/src/renderer/components/+cluster/cluster-overview-store/cluster-overview-store.ts deleted file mode 100644 index 90380f1070..0000000000 --- a/src/renderer/components/+cluster/cluster-overview-store/cluster-overview-store.ts +++ /dev/null @@ -1,112 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import { action, observable, reaction, when, makeObservable } from "mobx"; -import { KubeObjectStore } from "../../../../common/k8s-api/kube-object.store"; -import { Cluster, clusterApi, getMetricsByNodeNames, IClusterMetrics } from "../../../../common/k8s-api/endpoints"; -import { autoBind, StorageHelper } from "../../../utils"; -import { IMetricsReqParams, normalizeMetrics } from "../../../../common/k8s-api/endpoints/metrics.api"; -import { nodesStore } from "../../+nodes/nodes.store"; - -export enum MetricType { - MEMORY = "memory", - CPU = "cpu", -} - -export enum MetricNodeRole { - MASTER = "master", - WORKER = "worker", -} - -export interface ClusterOverviewStorageState { - metricType: MetricType; - metricNodeRole: MetricNodeRole, -} - -interface Dependencies { - storage: StorageHelper -} - -export class ClusterOverviewStore extends KubeObjectStore implements ClusterOverviewStorageState { - api = clusterApi; - - @observable metrics: Partial = {}; - @observable metricsLoaded = false; - - get metricType(): MetricType { - return this.dependencies.storage.get().metricType; - } - - set metricType(value: MetricType) { - this.dependencies.storage.merge({ metricType: value }); - } - - get metricNodeRole(): MetricNodeRole { - return this.dependencies.storage.get().metricNodeRole; - } - - set metricNodeRole(value: MetricNodeRole) { - this.dependencies.storage.merge({ metricNodeRole: value }); - } - - constructor(private dependencies: Dependencies ) { - super(); - makeObservable(this); - autoBind(this); - - this.init(); - } - - private init() { - // TODO: refactor, seems not a correct place to be - // auto-refresh metrics on user-action - reaction(() => this.metricNodeRole, () => { - if (!this.metricsLoaded) return; - this.resetMetrics(); - this.loadMetrics(); - }); - - // check which node type to select - reaction(() => nodesStore.items.length, () => { - const { masterNodes, workerNodes } = nodesStore; - - if (!masterNodes.length) this.metricNodeRole = MetricNodeRole.WORKER; - if (!workerNodes.length) this.metricNodeRole = MetricNodeRole.MASTER; - }); - } - - @action - async loadMetrics(params?: IMetricsReqParams) { - await when(() => nodesStore.isLoaded); - const { masterNodes, workerNodes } = nodesStore; - const nodes = this.metricNodeRole === MetricNodeRole.MASTER && masterNodes.length ? masterNodes : workerNodes; - - this.metrics = await getMetricsByNodeNames(nodes.map(node => node.getName()), params); - this.metricsLoaded = true; - } - - getMetricsValues(source: Partial): [number, string][] { - switch (this.metricType) { - case MetricType.CPU: - return normalizeMetrics(source.cpuUsage).data.result[0].values; - case MetricType.MEMORY: - return normalizeMetrics(source.memoryUsage).data.result[0].values; - default: - return []; - } - } - - @action - resetMetrics() { - this.metrics = {}; - this.metricsLoaded = false; - } - - reset() { - super.reset(); - this.resetMetrics(); - this.dependencies.storage?.reset(); - } -} diff --git a/src/renderer/components/+cluster/cluster-overview.tsx b/src/renderer/components/+cluster/cluster-overview.tsx deleted file mode 100644 index 13a669f294..0000000000 --- a/src/renderer/components/+cluster/cluster-overview.tsx +++ /dev/null @@ -1,117 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import styles from "./cluster-overview.module.scss"; - -import React from "react"; -import { reaction } from "mobx"; -import { disposeOnUnmount, observer } from "mobx-react"; -import { nodesStore } from "../+nodes/nodes.store"; -import { podsStore } from "../+workloads-pods/pods.store"; -import { Disposer, getHostedClusterId, interval } from "../../utils"; -import { TabLayout } from "../layout/tab-layout"; -import { Spinner } from "../spinner"; -import { ClusterIssues } from "./cluster-issues"; -import { ClusterMetrics } from "./cluster-metrics"; -import type { ClusterOverviewStore } from "./cluster-overview-store/cluster-overview-store"; -import { ClusterPieCharts } from "./cluster-pie-charts"; -import { getActiveClusterEntity } from "../../api/catalog-entity-registry"; -import { ClusterMetricsResourceType } from "../../../common/cluster-types"; -import { ClusterStore } from "../../../common/cluster-store/cluster-store"; -import { eventStore } from "../+events/event.store"; -import { withInjectables } from "@ogre-tools/injectable-react"; -import kubeWatchApiInjectable from "../../kube-watch-api/kube-watch-api.injectable"; -import type { KubeObjectStore } from "../../../common/k8s-api/kube-object.store"; -import type { KubeObject } from "../../../common/k8s-api/kube-object"; -import clusterOverviewStoreInjectable from "./cluster-overview-store/cluster-overview-store.injectable"; - -interface Dependencies { - subscribeStores: (stores: KubeObjectStore[]) => Disposer, - clusterOverviewStore: ClusterOverviewStore -} - -@observer -class NonInjectedClusterOverview extends React.Component { - private metricPoller = interval(60, () => this.loadMetrics()); - - loadMetrics() { - const cluster = ClusterStore.getInstance().getById(getHostedClusterId()); - - if (cluster.available) { - this.props.clusterOverviewStore.loadMetrics(); - } - } - - componentDidMount() { - this.metricPoller.start(true); - - disposeOnUnmount(this, [ - this.props.subscribeStores([ - podsStore, - eventStore, - nodesStore, - ]), - - reaction( - () => this.props.clusterOverviewStore.metricNodeRole, // Toggle Master/Worker node switcher - () => this.metricPoller.restart(true), - ), - ]); - } - - componentWillUnmount() { - this.metricPoller.stop(); - } - - renderMetrics(isMetricsHidden: boolean) { - if (isMetricsHidden) { - return null; - } - - return ( - <> - - - - ); - } - - renderClusterOverview(isLoaded: boolean, isMetricsHidden: boolean) { - if (!isLoaded) { - return ; - } - - return ( - <> - {this.renderMetrics(isMetricsHidden)} - - - ); - } - - render() { - const isLoaded = nodesStore.isLoaded && eventStore.isLoaded; - const isMetricHidden = getActiveClusterEntity()?.isMetricHidden(ClusterMetricsResourceType.Cluster); - - return ( - -
- {this.renderClusterOverview(isLoaded, isMetricHidden)} -
-
- ); - } -} - -export const ClusterOverview = withInjectables( - NonInjectedClusterOverview, - - { - getProps: (di) => ({ - subscribeStores: di.inject(kubeWatchApiInjectable).subscribeStores, - clusterOverviewStore: di.inject(clusterOverviewStoreInjectable), - }), - }, -); diff --git a/src/renderer/components/+cluster/cluster-issues.module.scss b/src/renderer/components/+cluster/issues.module.scss similarity index 100% rename from src/renderer/components/+cluster/cluster-issues.module.scss rename to src/renderer/components/+cluster/issues.module.scss diff --git a/src/renderer/components/+cluster/cluster-issues.tsx b/src/renderer/components/+cluster/issues.tsx similarity index 51% rename from src/renderer/components/+cluster/cluster-issues.tsx rename to src/renderer/components/+cluster/issues.tsx index 3939ea7de6..15bfd70886 100644 --- a/src/renderer/components/+cluster/cluster-issues.tsx +++ b/src/renderer/components/+cluster/issues.tsx @@ -3,24 +3,29 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import styles from "./cluster-issues.module.scss"; +import styles from "./issues.module.scss"; import React from "react"; import { observer } from "mobx-react"; -import { computed, makeObservable } from "mobx"; import { Icon } from "../icon"; import { SubHeader } from "../layout/sub-header"; import { Table, TableCell, TableHead, TableRow } from "../table"; -import { nodesStore } from "../+nodes/nodes.store"; -import { eventStore } from "../+events/event.store"; -import { boundMethod, cssNames, prevDefault } from "../../utils"; +import type { NodeStore } from "../+nodes/store"; +import type { EventStore } from "../+events/store"; +import { cssNames, prevDefault } from "../../utils"; import type { ItemObject } from "../../../common/item.store"; import { Spinner } from "../spinner"; -import { ThemeStore } from "../../theme.store"; +import type { Theme } from "../../themes/store"; import { kubeSelectedUrlParam, toggleDetails } from "../kube-detail-params"; -import { apiManager } from "../../../common/k8s-api/api-manager"; +import type { ApiManager } from "../../../common/k8s-api/api-manager"; +import { withInjectables } from "@ogre-tools/injectable-react"; +import apiManagerInjectable from "../../../common/k8s-api/api-manager.injectable"; +import nodeStoreInjectable from "../+nodes/store.injectable"; +import eventStoreInjectable from "../+events/store.injectable"; +import type { IComputedValue } from "mobx"; +import activeThemeInjectable from "../../themes/active-theme.injectable"; -interface Props { +export interface ClusterIssuesProps { className?: string; } @@ -38,63 +43,39 @@ enum sortBy { age = "age", } -@observer -export class ClusterIssues extends React.Component { - private sortCallbacks = { - [sortBy.type]: (warning: IWarning) => warning.kind, - [sortBy.object]: (warning: IWarning) => warning.getName(), - [sortBy.age]: (warning: IWarning) => warning.timeDiffFromNow, - }; +interface Dependencies { + apiManager: ApiManager; + nodeStore: NodeStore; + eventStore: EventStore; + activeTheme: IComputedValue; +} - constructor(props: Props) { - super(props); - makeObservable(this); - } - - @computed get warnings() { - const warnings: IWarning[] = []; - - // Node bad conditions - nodesStore.items.forEach(node => { - const { kind, selfLink, getId, getName, getAge, getTimeDiffFromNow } = node; - - node.getWarningConditions().forEach(({ message }) => { - warnings.push({ - age: getAge(), - getId, - getName, - timeDiffFromNow: getTimeDiffFromNow(), - kind, +const NonInjectedClusterIssues = observer(({ apiManager, nodeStore, eventStore, className, activeTheme }: Dependencies & ClusterIssuesProps) => { + const warnings: IWarning[] = [ + ...nodeStore.items.flatMap(node => ( + node.getWarningConditions() + .map(({ message }) => ({ + age: node.getAge(), + getId: () => node.getId(), + getName: () => node.getName(), + timeDiffFromNow: node.getTimeDiffFromNow(), + kind: node.kind, message, - selfLink, - }); - }); - }); + selfLink: node.selfLink, + })) + )), + ...eventStore.getWarnings().map(warning => ({ + getId: () => warning.involvedObject.uid, + getName: () => warning.involvedObject.name, + timeDiffFromNow: warning.getTimeDiffFromNow(), + age: warning.getAge(), + message: warning.message, + kind: warning.kind, + selfLink: apiManager.lookupApiLink(warning.involvedObject, warning), + })), + ]; - // Warning events for Workloads - const events = eventStore.getWarnings(); - - events.forEach(error => { - const { message, involvedObject, getAge, getTimeDiffFromNow } = error; - const { uid, name, kind } = involvedObject; - - warnings.push({ - getId: () => uid, - getName: () => name, - timeDiffFromNow: getTimeDiffFromNow(), - age: getAge(), - message, - kind, - selfLink: apiManager.lookupApiLink(involvedObject, error), - }); - }); - - return warnings; - } - - @boundMethod - getTableRow(uid: string) { - const { warnings } = this; + const getTableRow = (uid: string) => { const warning = warnings.find(warn => warn.getId() == uid); const { getId, getName, message, kind, selfLink, age } = warning; @@ -119,11 +100,9 @@ export class ClusterIssues extends React.Component { ); - } - - renderContent() { - const { warnings } = this; + }; + const renderContent = () => { if (!eventStore.isLoaded) { return ( @@ -151,11 +130,15 @@ export class ClusterIssues extends React.Component { items={warnings} virtual selectable - sortable={this.sortCallbacks} + sortable={{ + [sortBy.type]: (warning: IWarning) => warning.kind, + [sortBy.object]: (warning: IWarning) => warning.getName(), + [sortBy.age]: (warning: IWarning) => warning.timeDiffFromNow, + }} sortByDefault={{ sortBy: sortBy.object, orderBy: "asc" }} sortSyncWithUrl={false} - getTableRow={this.getTableRow} - className={cssNames("box grow", ThemeStore.getInstance().activeTheme.type)} + getTableRow={getTableRow} + className={cssNames("box grow", activeTheme.get().type)} > Message @@ -166,13 +149,22 @@ export class ClusterIssues extends React.Component { ); - } + }; + + return ( +
+ {renderContent()} +
+ ); +}); + +export const ClusterIssues = withInjectables(NonInjectedClusterIssues, { + getProps: (di, props) => ({ + apiManager: di.inject(apiManagerInjectable), + nodeStore: di.inject(nodeStoreInjectable), + eventStore: di.inject(eventStoreInjectable), + activeTheme: di.inject(activeThemeInjectable), + ...props, + }), +}); - render() { - return ( -
- {this.renderContent()} -
- ); - } -} diff --git a/src/renderer/components/+cluster/cluster-metric-switchers.tsx b/src/renderer/components/+cluster/metric-switchers.tsx similarity index 51% rename from src/renderer/components/+cluster/cluster-metric-switchers.tsx rename to src/renderer/components/+cluster/metric-switchers.tsx index 1d0ca2f33c..0c2708652e 100644 --- a/src/renderer/components/+cluster/cluster-metric-switchers.tsx +++ b/src/renderer/components/+cluster/metric-switchers.tsx @@ -5,20 +5,30 @@ import React from "react"; import { observer } from "mobx-react"; -import { nodesStore } from "../+nodes/nodes.store"; import { cssNames } from "../../utils"; import { Radio, RadioGroup } from "../radio"; -import { ClusterOverviewStore, MetricNodeRole, MetricType } from "./cluster-overview-store/cluster-overview-store"; -import clusterOverviewStoreInjectable from "./cluster-overview-store/cluster-overview-store.injectable"; -import { withInjectables } from "@ogre-tools/injectable-react"; +import type { Node } from "../../../common/k8s-api/endpoints"; +import { MetricNodeRole, MetricType } from "./overview.state"; -interface Dependencies { - clusterOverviewStore: ClusterOverviewStore +export interface ClusterMetricSwitchersProps { + metricsValues: [number, string][]; + metricsNodeRole: MetricNodeRole; + setMetricsNodeRole: (val: MetricNodeRole) => void; + metricsType: MetricType; + setMetricsType: (val: MetricType) => void; + masterNodes: Node[]; + workerNodes: Node[]; } -const NonInjectedClusterMetricSwitchers = observer(({ clusterOverviewStore }: Dependencies) => { - const { masterNodes, workerNodes } = nodesStore; - const metricsValues = clusterOverviewStore.getMetricsValues(clusterOverviewStore.metrics); +export const ClusterMetricSwitchers = observer(({ + masterNodes, + workerNodes, + metricsValues, + metricsType, + setMetricsType, + metricsNodeRole, + setMetricsNodeRole, +}: ClusterMetricSwitchersProps) => { const disableRoles = !masterNodes.length || !workerNodes.length; const disableMetrics = !metricsValues.length; @@ -28,8 +38,8 @@ const NonInjectedClusterMetricSwitchers = observer(({ clusterOverviewStore }: De clusterOverviewStore.metricNodeRole = metric} + value={metricsNodeRole} + onChange={setMetricsNodeRole} > @@ -39,8 +49,8 @@ const NonInjectedClusterMetricSwitchers = observer(({ clusterOverviewStore }: De clusterOverviewStore.metricType = value} + value={metricsType} + onChange={setMetricsType} > @@ -49,14 +59,3 @@ const NonInjectedClusterMetricSwitchers = observer(({ clusterOverviewStore }: De
); }); - -export const ClusterMetricSwitchers = withInjectables( - NonInjectedClusterMetricSwitchers, - - { - getProps: (di) => ({ - clusterOverviewStore: di.inject(clusterOverviewStoreInjectable), - }), - }, -); - diff --git a/src/renderer/components/+cluster/cluster-metrics.module.scss b/src/renderer/components/+cluster/metrics.module.scss similarity index 100% rename from src/renderer/components/+cluster/cluster-metrics.module.scss rename to src/renderer/components/+cluster/metrics.module.scss diff --git a/src/renderer/components/+cluster/cluster-metrics.tsx b/src/renderer/components/+cluster/metrics.tsx similarity index 51% rename from src/renderer/components/+cluster/cluster-metrics.tsx rename to src/renderer/components/+cluster/metrics.tsx index 4633f854ad..8529961654 100644 --- a/src/renderer/components/+cluster/cluster-metrics.tsx +++ b/src/renderer/components/+cluster/metrics.tsx @@ -3,40 +3,63 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import styles from "./cluster-metrics.module.scss"; +import styles from "./metrics.module.scss"; import React from "react"; import { observer } from "mobx-react"; import type { ChartOptions, ChartPoint } from "chart.js"; -import { ClusterOverviewStore, MetricType } from "./cluster-overview-store/cluster-overview-store"; import { BarChart } from "../chart"; import { bytesToUnits, cssNames } from "../../utils"; import { Spinner } from "../spinner"; import { ZebraStripes } from "../chart/zebra-stripes.plugin"; -import { ClusterNoMetrics } from "./cluster-no-metrics"; -import { ClusterMetricSwitchers } from "./cluster-metric-switchers"; -import { getMetricLastPoints } from "../../../common/k8s-api/endpoints/metrics.api"; -import { withInjectables } from "@ogre-tools/injectable-react"; -import clusterOverviewStoreInjectable - from "./cluster-overview-store/cluster-overview-store.injectable"; +import { ClusterNoMetrics } from "./no-metrics"; +import { ClusterMetricSwitchers } from "./metric-switchers"; +import { getMetricLastPoints, normalizeMetrics } from "../../../common/k8s-api/endpoints/metrics.api"; +import { MetricNodeRole, MetricType } from "./overview.state"; +import type { IClusterMetrics, Node } from "../../../common/k8s-api/endpoints"; -interface Dependencies { - clusterOverviewStore: ClusterOverviewStore +export interface ClusterMetricsProps { + metrics: IClusterMetrics | null; + metricsNodeRole: MetricNodeRole; + setMetricsNodeRole: (val: MetricNodeRole) => void; + metricsType: MetricType; + setMetricsType: (val: MetricType) => void; + masterNodes: Node[]; + workerNodes: Node[]; } -const NonInjectedClusterMetrics = observer(({ clusterOverviewStore: { metricType, metricNodeRole, getMetricsValues, metricsLoaded, metrics }}: Dependencies) => { - const { memoryCapacity, cpuCapacity } = getMetricLastPoints(metrics); - const metricValues = getMetricsValues(metrics); +function getMetricsValues(metricsType: MetricType, source: IClusterMetrics | null): [number, string][] { + switch (metricsType) { + case MetricType.CPU: + return normalizeMetrics(source?.cpuUsage).data.result[0].values; + case MetricType.MEMORY: + return normalizeMetrics(source?.memoryUsage).data.result[0].values; + default: + return []; + } +} + +export const ClusterMetrics = observer(({ + metricsType, + metrics, + setMetricsType, + metricsNodeRole, + setMetricsNodeRole, + masterNodes, + workerNodes, +}: ClusterMetricsProps) => { + const { memoryCapacity, cpuCapacity } = getMetricLastPoints(metrics ?? {}); + const metricsValues = getMetricsValues(metricsType, metrics); const colors = { cpu: "#3D90CE", memory: "#C93DCE" }; - const data = metricValues.map(value => ({ + const data = metricsValues.map(value => ({ x: value[0], y: parseFloat(value[1]).toFixed(3), })); const datasets = [{ - id: metricType + metricNodeRole, - label: `${metricType.toUpperCase()} usage`, - borderColor: colors[metricType], + id: metricsType + metricsNodeRole, + label: `${metricsType.toUpperCase()} usage`, + borderColor: colors[metricsType], data, }]; const cpuOptions: ChartOptions = { @@ -77,10 +100,10 @@ const NonInjectedClusterMetrics = observer(({ clusterOverviewStore: { metricType }, }, }; - const options = metricType === MetricType.CPU ? cpuOptions : memoryOptions; + const options = metricsType === MetricType.CPU ? cpuOptions : memoryOptions; const renderMetrics = () => { - if (!metricValues.length && !metricsLoaded) { + if (!metricsValues.length && !metrics) { return ; } @@ -90,7 +113,7 @@ const NonInjectedClusterMetrics = observer(({ clusterOverviewStore: { metricType return ( - + {renderMetrics()} ); }); - -export const ClusterMetrics = withInjectables( - NonInjectedClusterMetrics, - - { - getProps: (di) => ({ - clusterOverviewStore: di.inject(clusterOverviewStoreInjectable), - }), - }, -); diff --git a/src/renderer/components/+cluster/cluster-no-metrics.tsx b/src/renderer/components/+cluster/no-metrics.tsx similarity index 100% rename from src/renderer/components/+cluster/cluster-no-metrics.tsx rename to src/renderer/components/+cluster/no-metrics.tsx diff --git a/src/renderer/components/+cluster/cluster-overview.module.scss b/src/renderer/components/+cluster/overview.module.scss similarity index 100% rename from src/renderer/components/+cluster/cluster-overview.module.scss rename to src/renderer/components/+cluster/overview.module.scss diff --git a/src/renderer/components/+cluster/overview.state.injectable.ts b/src/renderer/components/+cluster/overview.state.injectable.ts new file mode 100644 index 0000000000..ed307ae988 --- /dev/null +++ b/src/renderer/components/+cluster/overview.state.injectable.ts @@ -0,0 +1,23 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import type { StorageLayer } from "../../utils"; +import createStorageInjectable from "../../utils/create-storage/create-storage.injectable"; +import { ClusterOverviewStorageState, MetricNodeRole, MetricType } from "./overview.state"; + +let storage: StorageLayer; + +const clusterOverviewStateInjectable = getInjectable({ + setup: async (di) => { + storage = await di.inject(createStorageInjectable)("cluster_overview", { + metricType: MetricType.CPU, + metricNodeRole: MetricNodeRole.WORKER, + }); + }, + instantiate: () => storage, + lifecycle: lifecycleEnum.singleton, +}); + +export default clusterOverviewStateInjectable; diff --git a/src/renderer/components/+cluster/overview.state.ts b/src/renderer/components/+cluster/overview.state.ts new file mode 100644 index 0000000000..789f29f06e --- /dev/null +++ b/src/renderer/components/+cluster/overview.state.ts @@ -0,0 +1,19 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +export enum MetricType { + MEMORY = "memory", + CPU = "cpu", +} + +export enum MetricNodeRole { + MASTER = "master", + WORKER = "worker", +} + +export interface ClusterOverviewStorageState { + metricType: MetricType; + metricNodeRole: MetricNodeRole, +} diff --git a/src/renderer/components/+cluster/overview.tsx b/src/renderer/components/+cluster/overview.tsx new file mode 100644 index 0000000000..a6a86a7b54 --- /dev/null +++ b/src/renderer/components/+cluster/overview.tsx @@ -0,0 +1,128 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import styles from "./overview.module.scss"; + +import React, { useEffect, useState } from "react"; +import { when } from "mobx"; +import { observer } from "mobx-react"; +import { disposer, interval } from "../../utils"; +import { TabLayout } from "../layout/tab-layout"; +import { Spinner } from "../spinner"; +import { ClusterIssues } from "./issues"; +import { ClusterMetrics } from "./metrics"; +import { ClusterPieCharts } from "./pie-charts"; +import { ClusterMetricsResourceType } from "../../../common/cluster-types"; +import { withInjectables } from "@ogre-tools/injectable-react"; +import type { PodStore } from "../+pods/store"; +import type { EventStore } from "../+events/store"; +import type { NodeStore } from "../+nodes/store"; +import podStoreInjectable from "../+pods/store.injectable"; +import eventStoreInjectable from "../+events/store.injectable"; +import nodeStoreInjectable from "../+nodes/store.injectable"; +import { getMetricsByNodeNames, IClusterMetrics } from "../../../common/k8s-api/endpoints"; +import { MetricNodeRole, MetricType } from "./overview.state"; +import isMetricHiddenInjectable from "../../utils/is-metrics-hidden.injectable"; +import type { KubeWatchApi } from "../../kube-watch-api/kube-watch-api"; +import kubeWatchApiInjectable from "../../kube-watch-api/kube-watch-api.injectable"; + +export interface ClusterOverviewProps { + clusterIsAvailable: boolean; +} + +interface Dependencies { + podStore: PodStore; + eventStore: EventStore; + nodeStore: NodeStore; + isMetricHidden: boolean; + kubeWatchApi: KubeWatchApi; +} + +const NonInjectedClusterOverview = observer(({ kubeWatchApi, isMetricHidden, podStore, eventStore, nodeStore, clusterIsAvailable }: Dependencies & ClusterOverviewProps) => { + const [metrics, setMetrics] = useState(null); + const [metricsNodeRole, setMetricsNodeRole] = useState(MetricNodeRole.MASTER); + const [metricsType, setMetricsType] = useState(MetricType.CPU); + const [loadMetricsPoller] = useState(interval(60, async () => { + if (!clusterIsAvailable) { + return; + } + + await when(() => nodeStore.isLoaded); + + const nodes = metricsNodeRole === MetricNodeRole.MASTER + ? nodeStore.masterNodes + : nodeStore.workerNodes; + + setMetrics(await getMetricsByNodeNames(nodes.map(node => node.getName()))); + })); + + useEffect(() => { + loadMetricsPoller.start(); + + return disposer( + kubeWatchApi.subscribeStores([ + podStore, + eventStore, + nodeStore, + ]), + () => loadMetricsPoller.stop(), + ); + }, []); + + const changeMetricsNodeRole = (val: MetricNodeRole) => { + setMetricsNodeRole(val); + loadMetricsPoller.restart(true); + }; + + const renderClusterOverview = () => ( + <> + {!isMetricHidden && ( + <> + + + + )} + + + ); + + return ( + +
+ { + (!nodeStore.isLoaded || !eventStore.isLoaded) + ? + : renderClusterOverview() + } +
+
+ ); +}); + +export const ClusterOverview = withInjectables(NonInjectedClusterOverview, { + getProps: (di, props) => ({ + podStore: di.inject(podStoreInjectable), + eventStore: di.inject(eventStoreInjectable), + nodeStore: di.inject(nodeStoreInjectable), + isMetricHidden: di.inject(isMetricHiddenInjectable, { + metricType: ClusterMetricsResourceType.Cluster, + }), + kubeWatchApi: di.inject(kubeWatchApiInjectable), + ...props, + }), +}); diff --git a/src/renderer/components/+cluster/cluster-pie-charts.module.scss b/src/renderer/components/+cluster/pie-charts.module.scss similarity index 100% rename from src/renderer/components/+cluster/cluster-pie-charts.module.scss rename to src/renderer/components/+cluster/pie-charts.module.scss diff --git a/src/renderer/components/+cluster/cluster-pie-charts.tsx b/src/renderer/components/+cluster/pie-charts.tsx similarity index 81% rename from src/renderer/components/+cluster/cluster-pie-charts.tsx rename to src/renderer/components/+cluster/pie-charts.tsx index ff97a26ce5..ecdbf48d8c 100644 --- a/src/renderer/components/+cluster/cluster-pie-charts.tsx +++ b/src/renderer/components/+cluster/pie-charts.tsx @@ -3,31 +3,45 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import styles from "./cluster-pie-charts.module.scss"; +import styles from "./pie-charts.module.scss"; import React from "react"; import { observer } from "mobx-react"; -import { ClusterOverviewStore, MetricNodeRole } from "./cluster-overview-store/cluster-overview-store"; import { Spinner } from "../spinner"; import { Icon } from "../icon"; -import { nodesStore } from "../+nodes/nodes.store"; import { ChartData, PieChart } from "../chart"; -import { ClusterNoMetrics } from "./cluster-no-metrics"; +import { ClusterNoMetrics } from "./no-metrics"; import { bytesToUnits, cssNames } from "../../utils"; -import { ThemeStore } from "../../theme.store"; +import type { Theme } from "../../themes/store"; import { getMetricLastPoints } from "../../../common/k8s-api/endpoints/metrics.api"; +import type { IClusterMetrics, Node } from "../../../common/k8s-api/endpoints"; +import { MetricNodeRole } from "./overview.state"; +import type { IComputedValue } from "mobx"; import { withInjectables } from "@ogre-tools/injectable-react"; -import clusterOverviewStoreInjectable from "./cluster-overview-store/cluster-overview-store.injectable"; +import activeThemeInjectable from "../../themes/active-theme.injectable"; function createLabels(rawLabelData: [string, number | undefined][]): string[] { return rawLabelData.map(([key, value]) => `${key}: ${value?.toFixed(2) || "N/A"}`); } -interface Dependencies { - clusterOverviewStore: ClusterOverviewStore +export interface ClusterPieChartsProps { + metrics: IClusterMetrics | null; + metricsNodeRole: MetricNodeRole; + masterNodes: Node[]; + workerNodes: Node[]; } -const NonInjectedClusterPieCharts = observer(({ clusterOverviewStore }: Dependencies) => { +interface Dependencies { + activeTheme: IComputedValue; +} + +const NonInjectedClusterPieCharts = observer(({ + activeTheme, + masterNodes, + metrics, + metricsNodeRole, + workerNodes, +}: Dependencies & ClusterPieChartsProps) => { const renderLimitWarning = () => { return (
@@ -37,14 +51,13 @@ const NonInjectedClusterPieCharts = observer(({ clusterOverviewStore }: Dependen ); }; - const renderCharts = () => { - const data = getMetricLastPoints(clusterOverviewStore.metrics); + const renderCharts = (data: Partial>) => { const { memoryUsage, memoryRequests, memoryAllocatableCapacity, memoryCapacity, memoryLimits } = data; const { cpuUsage, cpuRequests, cpuAllocatableCapacity, cpuCapacity, cpuLimits } = data; const { podUsage, podAllocatableCapacity, podCapacity } = data; const cpuLimitsOverload = cpuLimits > cpuAllocatableCapacity; const memoryLimitsOverload = memoryLimits > memoryAllocatableCapacity; - const defaultColor = ThemeStore.getInstance().activeTheme.colors.pieChartDefaultColor; + const defaultColor = activeTheme.get().colors.pieChartDefaultColor; if (!memoryCapacity || !cpuCapacity || !podCapacity || !memoryAllocatableCapacity || !cpuAllocatableCapacity || !podAllocatableCapacity) return null; const cpuData: ChartData = { @@ -203,9 +216,8 @@ const NonInjectedClusterPieCharts = observer(({ clusterOverviewStore }: Dependen ); }; - const renderContent = ({ metricNodeRole, metricsLoaded }: ClusterOverviewStore) => { - const { masterNodes, workerNodes } = nodesStore; - const nodes = metricNodeRole === MetricNodeRole.MASTER ? masterNodes : workerNodes; + const renderContent = () => { + const nodes = metricsNodeRole === MetricNodeRole.MASTER ? masterNodes : workerNodes; if (!nodes.length) { return ( @@ -216,35 +228,34 @@ const NonInjectedClusterPieCharts = observer(({ clusterOverviewStore }: Dependen ); } - if (!metricsLoaded) { + if (!metrics) { return (
); } - const { memoryCapacity, cpuCapacity, podCapacity } = getMetricLastPoints(clusterOverviewStore.metrics); + + const data = getMetricLastPoints(metrics); + const { memoryCapacity, cpuCapacity, podCapacity } = data; if (!memoryCapacity || !cpuCapacity || !podCapacity) { return ; } - return renderCharts(); + return renderCharts(data); }; return (
- {renderContent(clusterOverviewStore)} + {renderContent()}
); }); -export const ClusterPieCharts = withInjectables( - NonInjectedClusterPieCharts, - - { - getProps: (di) => ({ - clusterOverviewStore: di.inject(clusterOverviewStoreInjectable), - }), - }, -); +export const ClusterPieCharts = withInjectables(NonInjectedClusterPieCharts, { + getProps: (di, props) => ({ + activeTheme: di.inject(activeThemeInjectable), + ...props, + }), +}); diff --git a/src/renderer/components/+cluster/store.injectable.ts b/src/renderer/components/+cluster/store.injectable.ts new file mode 100644 index 0000000000..6568a32e79 --- /dev/null +++ b/src/renderer/components/+cluster/store.injectable.ts @@ -0,0 +1,14 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import apiManagerInjectable from "../../../common/k8s-api/api-manager.injectable"; +import type { ClusterStore } from "./store"; + +const clusterStoreInjectable = getInjectable({ + instantiate: (di) => di.inject(apiManagerInjectable).getStore("/apis/cluster.k8s.io/v1alpha1/clusters") as ClusterStore, + lifecycle: lifecycleEnum.singleton, +}); + +export default clusterStoreInjectable; diff --git a/src/renderer/components/+cluster/store.ts b/src/renderer/components/+cluster/store.ts new file mode 100644 index 0000000000..72942a6944 --- /dev/null +++ b/src/renderer/components/+cluster/store.ts @@ -0,0 +1,62 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { KubeObjectStore } from "../../../common/k8s-api/kube-object.store"; +import type { Cluster, ClusterApi, IClusterMetrics } from "../../../common/k8s-api/endpoints"; +import { autoBind } from "../../utils"; +import type { IMetricsReqParams } from "../../../common/k8s-api/endpoints/metrics.api"; +import { MetricNodeRole, MetricType } from "./overview.state"; + +export class ClusterStore extends KubeObjectStore { + /** + * @deprecated no longer used + */ + metrics: Partial = {}; + + /** + * @deprecated no longer used + */ + metricsLoaded = false; + + /** + * @deprecated no longer used + */ + metricType = MetricType.CPU; + + /** + * @deprecated no longer used + */ + metricNodeRole = MetricNodeRole.MASTER; + + constructor(public readonly api:ClusterApi) { + super(); + autoBind(this); + } + + /** + * @deprecated no longer used + */ + loadMetrics(params?: IMetricsReqParams) { + void params; + + return Promise.resolve(); + } + + /** + * @deprecated no longer used + */ + getMetricsValues(source: Partial): [number, string][] { + void source; + + return []; + } + + /** + * @deprecated no longer used + */ + resetMetrics() { + return; + } +} diff --git a/src/renderer/components/+config-autoscalers/hpa-details.tsx b/src/renderer/components/+config-autoscalers/hpa-details.tsx deleted file mode 100644 index 476ef07ade..0000000000 --- a/src/renderer/components/+config-autoscalers/hpa-details.tsx +++ /dev/null @@ -1,144 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import "./hpa-details.scss"; - -import React from "react"; -import { observer } from "mobx-react"; -import { Link } from "react-router-dom"; -import { DrawerItem, DrawerTitle } from "../drawer"; -import { Badge } from "../badge"; -import type { KubeObjectDetailsProps } from "../kube-object-details"; -import { cssNames } from "../../utils"; -import { HorizontalPodAutoscaler, HpaMetricType, IHpaMetric } from "../../../common/k8s-api/endpoints/hpa.api"; -import { Table, TableCell, TableHead, TableRow } from "../table"; -import { apiManager } from "../../../common/k8s-api/api-manager"; -import { KubeObjectMeta } from "../kube-object-meta"; -import { getDetailsUrl } from "../kube-detail-params"; -import logger from "../../../common/logger"; - -export interface HpaDetailsProps extends KubeObjectDetailsProps { -} - -@observer -export class HpaDetails extends React.Component { - renderMetrics() { - const { object: hpa } = this.props; - - const renderName = (metric: IHpaMetric) => { - switch (metric.type) { - case HpaMetricType.Resource: { - const addition = metric.resource.targetAverageUtilization - ? "(as a percentage of request)" - : ""; - - return <>Resource {metric.resource.name} on Pods {addition}; - } - case HpaMetricType.Pods: - return <>{metric.pods.metricName} on Pods; - - case HpaMetricType.Object: { - const { target } = metric.object; - const { kind, name } = target; - const objectUrl = getDetailsUrl(apiManager.lookupApiLink(target, hpa)); - - return ( - <> - {metric.object.metricName} on{" "} - {kind}/{name} - - ); - } - case HpaMetricType.External: - return ( - <> - {metric.external.metricName} on{" "} - {JSON.stringify(metric.external.selector)} - - ); - } - }; - - return ( - - - Name - Current / Target - - { - hpa.getMetrics() - .map((metric, index) => ( - - {renderName(metric)} - {hpa.getMetricValues(metric)} - - )) - } -
- ); - } - - render() { - const { object: hpa } = this.props; - - if (!hpa) { - return null; - } - - if (!(hpa instanceof HorizontalPodAutoscaler)) { - logger.error("[HpaDetails]: passed object that is not an instanceof HorizontalPodAutoscaler", hpa); - - return null; - } - - const { scaleTargetRef } = hpa.spec; - - return ( -
- - - - {scaleTargetRef && ( - - {scaleTargetRef.kind}/{scaleTargetRef.name} - - )} - - - - {hpa.getMinPods()} - - - - {hpa.getMaxPods()} - - - - {hpa.getReplicas()} - - - - {hpa.getConditions().map(({ type, tooltip, isReady }) => { - if (!isReady) return null; - - return ( - - ); - })} - - - -
- {this.renderMetrics()} -
-
- ); - } -} diff --git a/src/renderer/components/+config-autoscalers/hpa.store.ts b/src/renderer/components/+config-autoscalers/hpa.store.ts deleted file mode 100644 index 313a4a7d7c..0000000000 --- a/src/renderer/components/+config-autoscalers/hpa.store.ts +++ /dev/null @@ -1,15 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import { KubeObjectStore } from "../../../common/k8s-api/kube-object.store"; -import { HorizontalPodAutoscaler, hpaApi } from "../../../common/k8s-api/endpoints/hpa.api"; -import { apiManager } from "../../../common/k8s-api/api-manager"; - -export class HPAStore extends KubeObjectStore { - api = hpaApi; -} - -export const hpaStore = new HPAStore(); -apiManager.registerStore(hpaStore); diff --git a/src/renderer/components/+config-autoscalers/hpa.tsx b/src/renderer/components/+config-autoscalers/hpa.tsx deleted file mode 100644 index 34c57729fa..0000000000 --- a/src/renderer/components/+config-autoscalers/hpa.tsx +++ /dev/null @@ -1,103 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import "./hpa.scss"; - -import React from "react"; -import { observer } from "mobx-react"; -import type { RouteComponentProps } from "react-router"; -import { KubeObjectListLayout } from "../kube-object-list-layout"; -import type { HorizontalPodAutoscaler } from "../../../common/k8s-api/endpoints/hpa.api"; -import { hpaStore } from "./hpa.store"; -import { Badge } from "../badge"; -import { cssNames } from "../../utils"; -import { KubeObjectStatusIcon } from "../kube-object-status-icon"; -import type { HpaRouteParams } from "../../../common/routes"; - -enum columnId { - name = "name", - namespace = "namespace", - metrics = "metrics", - minPods = "min-pods", - maxPods = "max-pods", - replicas = "replicas", - age = "age", - status = "status", -} - -interface Props extends RouteComponentProps { -} - -@observer -export class HorizontalPodAutoscalers extends React.Component { - getTargets(hpa: HorizontalPodAutoscaler) { - const metrics = hpa.getMetrics(); - - if (metrics.length === 0) { - return

--

; - } - - const metricsRemain = metrics.length > 1 ? `+${metrics.length - 1} more...` : ""; - - return

{hpa.getMetricValues(metrics[0])} {metricsRemain}

; - } - - render() { - return ( - item.getName(), - [columnId.namespace]: item => item.getNs(), - [columnId.minPods]: item => item.getMinPods(), - [columnId.maxPods]: item => item.getMaxPods(), - [columnId.replicas]: item => item.getReplicas(), - [columnId.age]: item => item.getTimeDiffFromNow(), - }} - searchFilters={[ - item => item.getSearchFields(), - ]} - renderHeaderTitle="Horizontal Pod Autoscalers" - renderTableHeader={[ - { title: "Name", className: "name", sortBy: columnId.name }, - { className: "warning", showWithColumn: columnId.name }, - { title: "Namespace", className: "namespace", sortBy: columnId.namespace, id: columnId.namespace }, - { title: "Metrics", className: "metrics", id: columnId.metrics }, - { title: "Min Pods", className: "min-pods", sortBy: columnId.minPods, id: columnId.minPods }, - { title: "Max Pods", className: "max-pods", sortBy: columnId.maxPods, id: columnId.maxPods }, - { title: "Replicas", className: "replicas", sortBy: columnId.replicas, id: columnId.replicas }, - { title: "Age", className: "age", sortBy: columnId.age, id: columnId.age }, - { title: "Status", className: "status scrollable", id: columnId.status }, - ]} - renderTableContents={hpa => [ - hpa.getName(), - , - hpa.getNs(), - this.getTargets(hpa), - hpa.getMinPods(), - hpa.getMaxPods(), - hpa.getReplicas(), - hpa.getAge(), - hpa.getConditions().map(({ type, tooltip, isReady }) => { - if (!isReady) return null; - - return ( - - ); - }), - ]} - /> - ); - } -} diff --git a/src/renderer/components/+config-limit-ranges/limit-ranges.store.ts b/src/renderer/components/+config-limit-ranges/limit-ranges.store.ts deleted file mode 100644 index 4a58e575ea..0000000000 --- a/src/renderer/components/+config-limit-ranges/limit-ranges.store.ts +++ /dev/null @@ -1,15 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import { KubeObjectStore } from "../../../common/k8s-api/kube-object.store"; -import { apiManager } from "../../../common/k8s-api/api-manager"; -import { LimitRange, limitRangeApi } from "../../../common/k8s-api/endpoints/limit-range.api"; - -export class LimitRangesStore extends KubeObjectStore { - api = limitRangeApi; -} - -export const limitRangeStore = new LimitRangesStore(); -apiManager.registerStore(limitRangeStore); diff --git a/src/renderer/components/+config-limit-ranges/limit-ranges.tsx b/src/renderer/components/+config-limit-ranges/limit-ranges.tsx deleted file mode 100644 index 814b63ecad..0000000000 --- a/src/renderer/components/+config-limit-ranges/limit-ranges.tsx +++ /dev/null @@ -1,59 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import "./limit-ranges.scss"; - -import type { RouteComponentProps } from "react-router"; -import { observer } from "mobx-react"; -import { KubeObjectListLayout } from "../kube-object-list-layout"; -import { limitRangeStore } from "./limit-ranges.store"; -import React from "react"; -import { KubeObjectStatusIcon } from "../kube-object-status-icon"; -import type { LimitRangeRouteParams } from "../../../common/routes"; - -enum columnId { - name = "name", - namespace = "namespace", - age = "age", -} - -interface Props extends RouteComponentProps { -} - -@observer -export class LimitRanges extends React.Component { - render() { - return ( - item.getName(), - [columnId.namespace]: item => item.getNs(), - [columnId.age]: item => item.getTimeDiffFromNow(), - }} - searchFilters={[ - item => item.getName(), - item => item.getNs(), - ]} - renderHeaderTitle="Limit Ranges" - renderTableHeader={[ - { title: "Name", className: "name", sortBy: columnId.name, id: columnId.name }, - { className: "warning", showWithColumn: columnId.name }, - { title: "Namespace", className: "namespace", sortBy: columnId.namespace, id: columnId.namespace }, - { title: "Age", className: "age", sortBy: columnId.age, id: columnId.age }, - ]} - renderTableContents={limitRange => [ - limitRange.getName(), - , - limitRange.getNs(), - limitRange.getAge(), - ]} - /> - ); - } -} diff --git a/src/renderer/components/+config-maps/config-map-details.tsx b/src/renderer/components/+config-maps/config-map-details.tsx deleted file mode 100644 index bdf153ff0e..0000000000 --- a/src/renderer/components/+config-maps/config-map-details.tsx +++ /dev/null @@ -1,117 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import "./config-map-details.scss"; - -import React from "react"; -import { autorun, makeObservable, observable } from "mobx"; -import { disposeOnUnmount, observer } from "mobx-react"; -import { DrawerTitle } from "../drawer"; -import { Notifications } from "../notifications"; -import { Input } from "../input"; -import { Button } from "../button"; -import { configMapsStore } from "./config-maps.store"; -import type { KubeObjectDetailsProps } from "../kube-object-details"; -import { ConfigMap } from "../../../common/k8s-api/endpoints"; -import { KubeObjectMeta } from "../kube-object-meta"; -import logger from "../../../common/logger"; - -interface Props extends KubeObjectDetailsProps { -} - -@observer -export class ConfigMapDetails extends React.Component { - @observable isSaving = false; - @observable data = observable.map(); - - constructor(props: Props) { - super(props); - makeObservable(this); - } - - async componentDidMount() { - disposeOnUnmount(this, [ - autorun(() => { - const { object: configMap } = this.props; - - if (configMap) { - this.data.replace(configMap.data); // refresh - } - }), - ]); - } - - save = async () => { - const { object: configMap } = this.props; - - try { - this.isSaving = true; - await configMapsStore.update(configMap, { - ...configMap, - data: Object.fromEntries(this.data), - }); - Notifications.ok( -

- <>ConfigMap {configMap.getName()} successfully updated. -

, - ); - } catch (error) { - Notifications.error(`Failed to save config map: ${error}`); - } finally { - this.isSaving = false; - } - }; - - render() { - const { object: configMap } = this.props; - - if (!configMap) { - return null; - } - - if (!(configMap instanceof ConfigMap)) { - logger.error("[ConfigMapDetails]: passed object that is not an instanceof ConfigMap", configMap); - - return null; - } - - const data = Array.from(this.data.entries()); - - return ( -
- - { - data.length > 0 && ( - <> - - { - data.map(([name, value]) => ( -
-
{name}
-
- this.data.set(name, v)} - /> -
-
- )) - } -
- ); - } -} diff --git a/src/renderer/components/+config-maps/config-maps.store.ts b/src/renderer/components/+config-maps/config-maps.store.ts deleted file mode 100644 index 9465079e9a..0000000000 --- a/src/renderer/components/+config-maps/config-maps.store.ts +++ /dev/null @@ -1,15 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import { KubeObjectStore } from "../../../common/k8s-api/kube-object.store"; -import { ConfigMap, configMapApi } from "../../../common/k8s-api/endpoints/configmap.api"; -import { apiManager } from "../../../common/k8s-api/api-manager"; - -export class ConfigMapsStore extends KubeObjectStore { - api = configMapApi; -} - -export const configMapsStore = new ConfigMapsStore(); -apiManager.registerStore(configMapsStore); diff --git a/src/renderer/components/+config-maps/config-maps.tsx b/src/renderer/components/+config-maps/config-maps.tsx index 8edbb6833b..03195c1cd8 100644 --- a/src/renderer/components/+config-maps/config-maps.tsx +++ b/src/renderer/components/+config-maps/config-maps.tsx @@ -8,10 +8,12 @@ import "./config-maps.scss"; import React from "react"; import { observer } from "mobx-react"; import type { RouteComponentProps } from "react-router"; -import { configMapsStore } from "./config-maps.store"; +import type { ConfigMapStore } from "./store"; import { KubeObjectListLayout } from "../kube-object-list-layout"; import { KubeObjectStatusIcon } from "../kube-object-status-icon"; import type { ConfigMapsRouteParams } from "../../../common/routes"; +import { withInjectables } from "@ogre-tools/injectable-react"; +import configMapStoreInjectable from "./store.injectable"; enum columnId { name = "name", @@ -20,43 +22,50 @@ enum columnId { age = "age", } -interface Props extends RouteComponentProps { +export interface ConfigMapsProps extends RouteComponentProps { } -@observer -export class ConfigMaps extends React.Component { - render() { - return ( - item.getName(), - [columnId.namespace]: item => item.getNs(), - [columnId.keys]: item => item.getKeys(), - [columnId.age]: item => item.getTimeDiffFromNow(), - }} - searchFilters={[ - item => item.getSearchFields(), - item => item.getKeys(), - ]} - renderHeaderTitle="Config Maps" - renderTableHeader={[ - { title: "Name", className: "name", sortBy: columnId.name, id: columnId.name }, - { className: "warning", showWithColumn: columnId.name }, - { title: "Namespace", className: "namespace", sortBy: columnId.namespace, id: columnId.namespace }, - { title: "Keys", className: "keys", sortBy: columnId.keys, id: columnId.keys }, - { title: "Age", className: "age", sortBy: columnId.age, id: columnId.age }, - ]} - renderTableContents={configMap => [ - configMap.getName(), - , - configMap.getNs(), - configMap.getKeys().join(", "), - configMap.getAge(), - ]} - /> - ); - } +interface Dependencies { + configMapStore: ConfigMapStore; } + +const NonInjectedConfigMaps = observer(({ configMapStore }: Dependencies & ConfigMapsProps) => ( + item.getName(), + [columnId.namespace]: item => item.getNs(), + [columnId.keys]: item => item.getKeys(), + [columnId.age]: item => item.getTimeDiffFromNow(), + }} + searchFilters={[ + item => item.getSearchFields(), + item => item.getKeys(), + ]} + renderHeaderTitle="Config Maps" + renderTableHeader={[ + { title: "Name", className: "name", sortBy: columnId.name, id: columnId.name }, + { className: "warning", showWithColumn: columnId.name }, + { title: "Namespace", className: "namespace", sortBy: columnId.namespace, id: columnId.namespace }, + { title: "Keys", className: "keys", sortBy: columnId.keys, id: columnId.keys }, + { title: "Age", className: "age", sortBy: columnId.age, id: columnId.age }, + ]} + renderTableContents={configMap => [ + configMap.getName(), + , + configMap.getNs(), + configMap.getKeys().join(", "), + configMap.getAge(), + ]} + /> +)); + +export const ConfigMaps = withInjectables(NonInjectedConfigMaps, { + getProps: (di, props) => ({ + configMapStore: di.inject(configMapStoreInjectable), + ...props, + }), +}); diff --git a/src/renderer/components/+config-maps/config-map-details.scss b/src/renderer/components/+config-maps/details.scss similarity index 100% rename from src/renderer/components/+config-maps/config-map-details.scss rename to src/renderer/components/+config-maps/details.scss diff --git a/src/renderer/components/+config-maps/details.tsx b/src/renderer/components/+config-maps/details.tsx new file mode 100644 index 0000000000..d9f9d8091c --- /dev/null +++ b/src/renderer/components/+config-maps/details.tsx @@ -0,0 +1,112 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import "./details.scss"; + +import React, { useEffect, useState } from "react"; +import { observable } from "mobx"; +import { observer } from "mobx-react"; +import { DrawerTitle } from "../drawer"; +import { Notifications } from "../notifications"; +import { Input } from "../input"; +import { Button } from "../button"; +import type { ConfigMapStore } from "./store"; +import type { KubeObjectDetailsProps } from "../kube-object-details"; +import { ConfigMap } from "../../../common/k8s-api/endpoints"; +import { KubeObjectMeta } from "../kube-object-meta"; +import logger from "../../../common/logger"; +import { withInjectables } from "@ogre-tools/injectable-react"; +import configMapStoreInjectable from "./store.injectable"; + +export interface ConfigMapDetailsProps extends KubeObjectDetailsProps { +} + +interface Dependencies { + configMapStore: ConfigMapStore; +} + +const NonInjectedConfigMapDetails = observer(({ configMapStore, object: configMap }: Dependencies & ConfigMapDetailsProps) => { + const [isSaving, setIsSaving] = useState(false); + const [data] = useState(observable.map()); + + useEffect(() => { + if (configMap) { + data.replace(configMap.data); + } + }, [configMap]); + + if (!configMap) { + return null; + } + + if (!(configMap instanceof ConfigMap)) { + logger.error("[ConfigMapDetails]: passed object that is not an instanceof ConfigMap", configMap); + + return null; + } + + const save = async () => { + try { + setIsSaving(true); + await configMapStore.update(configMap, { + ...configMap, + data: Object.fromEntries(data), + }); + Notifications.ok( +

+ <>ConfigMap {configMap.getName()} successfully updated. +

, + ); + } catch (error) { + Notifications.error(`Failed to save config map: ${error}`); + } finally { + setIsSaving(false); + } + }; + + const dataList = Array.from(data.entries()); + + return ( +
+ + { + dataList.length > 0 && ( + <> + + { + dataList.map(([name, value]) => ( +
+
{name}
+
+ data.set(name, v)} + /> +
+
+ )) + } +
+ ); +}); + +export const ConfigMapDetails = withInjectables(NonInjectedConfigMapDetails, { + getProps: (di, props) => ({ + configMapStore: di.inject(configMapStoreInjectable), + ...props, + }), +}); diff --git a/src/renderer/components/+config-maps/index.ts b/src/renderer/components/+config-maps/index.ts index f6750d8855..dc2491d3f8 100644 --- a/src/renderer/components/+config-maps/index.ts +++ b/src/renderer/components/+config-maps/index.ts @@ -4,4 +4,4 @@ */ export * from "./config-maps"; -export * from "./config-map-details"; +export * from "./details"; diff --git a/src/renderer/components/+config-maps/store.injectable.ts b/src/renderer/components/+config-maps/store.injectable.ts new file mode 100644 index 0000000000..c83744f4dd --- /dev/null +++ b/src/renderer/components/+config-maps/store.injectable.ts @@ -0,0 +1,14 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import apiManagerInjectable from "../../../common/k8s-api/api-manager.injectable"; +import type { ConfigMapStore } from "./store"; + +const configMapStoreInjectable = getInjectable({ + instantiate: (di) => di.inject(apiManagerInjectable).getStore("/api/v1/configmaps") as ConfigMapStore, + lifecycle: lifecycleEnum.singleton, +}); + +export default configMapStoreInjectable; diff --git a/src/renderer/components/+config-maps/store.ts b/src/renderer/components/+config-maps/store.ts new file mode 100644 index 0000000000..9e8d72148f --- /dev/null +++ b/src/renderer/components/+config-maps/store.ts @@ -0,0 +1,13 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { KubeObjectStore } from "../../../common/k8s-api/kube-object.store"; +import type { ConfigMap, ConfigMapApi } from "../../../common/k8s-api/endpoints/configmap.api"; + +export class ConfigMapStore extends KubeObjectStore { + constructor(public readonly api: ConfigMapApi) { + super(); + } +} diff --git a/src/renderer/components/+config-pod-disruption-budgets/pod-disruption-budgets-details.tsx b/src/renderer/components/+config-pod-disruption-budgets/pod-disruption-budgets-details.tsx deleted file mode 100644 index eee9861ef2..0000000000 --- a/src/renderer/components/+config-pod-disruption-budgets/pod-disruption-budgets-details.tsx +++ /dev/null @@ -1,69 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import "./pod-disruption-budgets-details.scss"; - -import React from "react"; -import { observer } from "mobx-react"; -import { DrawerItem } from "../drawer"; -import { Badge } from "../badge"; -import type { KubeObjectDetailsProps } from "../kube-object-details"; -import { PodDisruptionBudget } from "../../../common/k8s-api/endpoints"; -import { KubeObjectMeta } from "../kube-object-meta"; -import logger from "../../../common/logger"; - -interface Props extends KubeObjectDetailsProps { -} - -@observer -export class PodDisruptionBudgetDetails extends React.Component { - - render() { - const { object: pdb } = this.props; - - if (!pdb) { - return null; - } - - if (!(pdb instanceof PodDisruptionBudget)) { - logger.error("[PodDisruptionBudgetDetails]: passed object that is not an instanceof PodDisruptionBudget", pdb); - - return null; - } - - const selectors = pdb.getSelectors(); - - return ( -
- - - {selectors.length > 0 && - - { - selectors.map(label => ) - } - - } - - - {pdb.getMinAvailable()} - - - - {pdb.getMaxUnavailable()} - - - - {pdb.getCurrentHealthy()} - - - - {pdb.getDesiredHealthy()} - - -
- ); - } -} diff --git a/src/renderer/components/+config-pod-disruption-budgets/pod-disruption-budgets.store.ts b/src/renderer/components/+config-pod-disruption-budgets/pod-disruption-budgets.store.ts deleted file mode 100644 index 960c3ca973..0000000000 --- a/src/renderer/components/+config-pod-disruption-budgets/pod-disruption-budgets.store.ts +++ /dev/null @@ -1,15 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import { KubeObjectStore } from "../../../common/k8s-api/kube-object.store"; -import { pdbApi, PodDisruptionBudget } from "../../../common/k8s-api/endpoints/poddisruptionbudget.api"; -import { apiManager } from "../../../common/k8s-api/api-manager"; - -export class PodDisruptionBudgetsStore extends KubeObjectStore { - api = pdbApi; -} - -export const podDisruptionBudgetsStore = new PodDisruptionBudgetsStore(); -apiManager.registerStore(podDisruptionBudgetsStore); diff --git a/src/renderer/components/+config-pod-disruption-budgets/pod-disruption-budgets.tsx b/src/renderer/components/+config-pod-disruption-budgets/pod-disruption-budgets.tsx deleted file mode 100644 index 2cf09db7a1..0000000000 --- a/src/renderer/components/+config-pod-disruption-budgets/pod-disruption-budgets.tsx +++ /dev/null @@ -1,76 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import "./pod-disruption-budgets.scss"; - -import * as React from "react"; -import { observer } from "mobx-react"; -import { podDisruptionBudgetsStore } from "./pod-disruption-budgets.store"; -import type { PodDisruptionBudget } from "../../../common/k8s-api/endpoints/poddisruptionbudget.api"; -import { KubeObjectStatusIcon } from "../kube-object-status-icon"; -import type { KubeObjectDetailsProps } from "../kube-object-details"; -import { KubeObjectListLayout } from "../kube-object-list-layout"; - -enum columnId { - name = "name", - namespace = "namespace", - minAvailable = "min-available", - maxUnavailable = "max-unavailable", - currentHealthy = "current-healthy", - desiredHealthy = "desired-healthy", - age = "age", -} - -interface Props extends KubeObjectDetailsProps { -} - -@observer -export class PodDisruptionBudgets extends React.Component { - render() { - return ( - pdb.getName(), - [columnId.namespace]: pdb => pdb.getNs(), - [columnId.minAvailable]: pdb => pdb.getMinAvailable(), - [columnId.maxUnavailable]: pdb => pdb.getMaxUnavailable(), - [columnId.currentHealthy]: pdb => pdb.getCurrentHealthy(), - [columnId.desiredHealthy]: pdb => pdb.getDesiredHealthy(), - [columnId.age]: pdb => pdb.getAge(), - }} - searchFilters={[ - pdb => pdb.getSearchFields(), - ]} - renderHeaderTitle="Pod Disruption Budgets" - renderTableHeader={[ - { title: "Name", className: "name", sortBy: columnId.name, id: columnId.name }, - { className: "warning", showWithColumn: columnId.name }, - { title: "Namespace", className: "namespace", sortBy: columnId.namespace, id: columnId.namespace }, - { title: "Min Available", className: "min-available", sortBy: columnId.minAvailable, id: columnId.minAvailable }, - { title: "Max Unavailable", className: "max-unavailable", sortBy: columnId.maxUnavailable, id: columnId.maxUnavailable }, - { title: "Current Healthy", className: "current-healthy", sortBy: columnId.currentHealthy, id: columnId.currentHealthy }, - { title: "Desired Healthy", className: "desired-healthy", sortBy: columnId.desiredHealthy, id: columnId.desiredHealthy }, - { title: "Age", className: "age", sortBy: columnId.age, id: columnId.age }, - ]} - renderTableContents={pdb => { - return [ - pdb.getName(), - , - pdb.getNs(), - pdb.getMinAvailable(), - pdb.getMaxUnavailable(), - pdb.getCurrentHealthy(), - pdb.getDesiredHealthy(), - pdb.getAge(), - ]; - }} - /> - ); - } -} diff --git a/src/renderer/components/+config-resource-quotas/add-quota-dialog.tsx b/src/renderer/components/+config-resource-quotas/add-quota-dialog.tsx deleted file mode 100644 index bac8d3858f..0000000000 --- a/src/renderer/components/+config-resource-quotas/add-quota-dialog.tsx +++ /dev/null @@ -1,219 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import "./add-quota-dialog.scss"; - -import React from "react"; -import { computed, observable, makeObservable } from "mobx"; -import { observer } from "mobx-react"; -import { Dialog, DialogProps } from "../dialog"; -import { Wizard, WizardStep } from "../wizard"; -import { Input } from "../input"; -import { systemName } from "../input/input_validators"; -import { IResourceQuotaValues, resourceQuotaApi } from "../../../common/k8s-api/endpoints/resource-quota.api"; -import { Select } from "../select"; -import { Icon } from "../icon"; -import { Button } from "../button"; -import { Notifications } from "../notifications"; -import { NamespaceSelect } from "../+namespaces/namespace-select"; -import { SubTitle } from "../layout/sub-title"; - -interface Props extends DialogProps { -} - -const dialogState = observable.object({ - isOpen: false, -}); - -@observer -export class AddQuotaDialog extends React.Component { - static defaultQuotas: IResourceQuotaValues = { - "limits.cpu": "", - "limits.memory": "", - "requests.cpu": "", - "requests.memory": "", - "requests.storage": "", - "persistentvolumeclaims": "", - "count/pods": "", - "count/persistentvolumeclaims": "", - "count/services": "", - "count/secrets": "", - "count/configmaps": "", - "count/replicationcontrollers": "", - "count/deployments.apps": "", - "count/replicasets.apps": "", - "count/statefulsets.apps": "", - "count/jobs.batch": "", - "count/cronjobs.batch": "", - "count/deployments.extensions": "", - }; - - public defaultNamespace = "default"; - - @observable quotaName = ""; - @observable quotaSelectValue = ""; - @observable quotaInputValue = ""; - @observable namespace = this.defaultNamespace; - @observable quotas = AddQuotaDialog.defaultQuotas; - - constructor(props: Props) { - super(props); - makeObservable(this); - } - - static open() { - dialogState.isOpen = true; - } - - static close() { - dialogState.isOpen = false; - } - - @computed get quotaEntries() { - return Object.entries(this.quotas) - .filter(([, value]) => !!value.trim()); - } - - @computed get quotaOptions() { - return Object.keys(this.quotas).map(quota => { - const isCompute = quota.endsWith(".cpu") || quota.endsWith(".memory"); - const isStorage = quota.endsWith(".storage") || quota === "persistentvolumeclaims"; - const isCount = quota.startsWith("count/"); - const icon = isCompute ? "memory" : isStorage ? "storage" : isCount ? "looks_one" : ""; - - return { - label: icon ? {quota} : quota, - value: quota, - }; - }); - } - - setQuota = () => { - if (!this.quotaSelectValue) return; - this.quotas[this.quotaSelectValue] = this.quotaInputValue; - this.quotaInputValue = ""; - }; - - close = () => { - AddQuotaDialog.close(); - }; - - reset = () => { - this.quotaName = ""; - this.quotaSelectValue = ""; - this.quotaInputValue = ""; - this.namespace = this.defaultNamespace; - this.quotas = AddQuotaDialog.defaultQuotas; - }; - - addQuota = async () => { - try { - const { quotaName, namespace } = this; - const quotas = this.quotaEntries.reduce((quotas, [name, value]) => { - quotas[name] = value; - - return quotas; - }, {}); - - await resourceQuotaApi.create({ namespace, name: quotaName }, { - spec: { - hard: quotas, - }, - }); - this.close(); - } catch (err) { - Notifications.error(err); - } - }; - - onInputQuota = (evt: React.KeyboardEvent) => { - switch (evt.key) { - case "Enter": - this.setQuota(); - evt.preventDefault(); // don't submit form - break; - } - }; - - render() { - const { ...dialogProps } = this.props; - const header =
Create ResourceQuota
; - - return ( - - - -
- this.quotaName = v.toLowerCase()} - className="box grow" - /> -
- - - this.namespace = value} - /> - - -
- this.quotaInputValue = v} - onKeyDown={this.onInputQuota} - className="box grow" - /> - -
-
- {this.quotaEntries.map(([quota, value]) => { - return ( -
-
{quota}
-
{value}
- this.quotas[quota] = ""} /> -
- ); - })} -
-
-
-
- ); - } -} diff --git a/src/renderer/components/+config-resource-quotas/resource-quotas.store.ts b/src/renderer/components/+config-resource-quotas/resource-quotas.store.ts deleted file mode 100644 index af03ef6318..0000000000 --- a/src/renderer/components/+config-resource-quotas/resource-quotas.store.ts +++ /dev/null @@ -1,15 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import { KubeObjectStore } from "../../../common/k8s-api/kube-object.store"; -import { ResourceQuota, resourceQuotaApi } from "../../../common/k8s-api/endpoints/resource-quota.api"; -import { apiManager } from "../../../common/k8s-api/api-manager"; - -export class ResourceQuotasStore extends KubeObjectStore { - api = resourceQuotaApi; -} - -export const resourceQuotaStore = new ResourceQuotasStore(); -apiManager.registerStore(resourceQuotaStore); diff --git a/src/renderer/components/+config-resource-quotas/resource-quotas.tsx b/src/renderer/components/+config-resource-quotas/resource-quotas.tsx deleted file mode 100644 index de7ef7a818..0000000000 --- a/src/renderer/components/+config-resource-quotas/resource-quotas.tsx +++ /dev/null @@ -1,66 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import "./resource-quotas.scss"; - -import React from "react"; -import { observer } from "mobx-react"; -import type { RouteComponentProps } from "react-router"; -import { KubeObjectListLayout } from "../kube-object-list-layout"; -import { AddQuotaDialog } from "./add-quota-dialog"; -import { resourceQuotaStore } from "./resource-quotas.store"; -import { KubeObjectStatusIcon } from "../kube-object-status-icon"; -import type { ResourceQuotaRouteParams } from "../../../common/routes"; - -enum columnId { - name = "name", - namespace = "namespace", - age = "age", -} - -interface Props extends RouteComponentProps { -} - -@observer -export class ResourceQuotas extends React.Component { - render() { - return ( - <> - item.getName(), - [columnId.namespace]: item => item.getNs(), - [columnId.age]: item => item.getTimeDiffFromNow(), - }} - searchFilters={[ - item => item.getSearchFields(), - item => item.getName(), - ]} - renderHeaderTitle="Resource Quotas" - renderTableHeader={[ - { title: "Name", className: "name", sortBy: columnId.name, id: columnId.name }, - { className: "warning", showWithColumn: columnId.name }, - { title: "Namespace", className: "namespace", sortBy: columnId.namespace, id: columnId.namespace }, - { title: "Age", className: "age", sortBy: columnId.age, id: columnId.age }, - ]} - renderTableContents={resourceQuota => [ - resourceQuota.getName(), - , - resourceQuota.getNs(), - resourceQuota.getAge(), - ]} - addRemoveButtons={{ - onAdd: () => AddQuotaDialog.open(), - addTooltip: "Create new ResourceQuota", - }} - /> - - - ); - } -} diff --git a/src/renderer/components/+config-secrets/__tests__/secret-details.test.tsx b/src/renderer/components/+config-secrets/__tests__/secret-details.test.tsx deleted file mode 100644 index df8c1c1cf8..0000000000 --- a/src/renderer/components/+config-secrets/__tests__/secret-details.test.tsx +++ /dev/null @@ -1,33 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import React from "react"; -import { render } from "@testing-library/react"; -import { SecretDetails } from "../secret-details"; -import { Secret, SecretType } from "../../../../common/k8s-api/endpoints"; - -jest.mock("../../kube-object-meta/kube-object-meta"); - - -describe("SecretDetails tests", () => { - it("should show the visibility toggle when the secret value is ''", () => { - const secret = new Secret({ - apiVersion: "v1", - kind: "secret", - metadata: { - name: "test", - resourceVersion: "1", - uid: "uid", - }, - data: { - foobar: "", - }, - type: SecretType.Opaque, - }); - const result = render(); - - expect(result.getByTestId("foobar-secret-entry").querySelector(".Icon")).toBeDefined(); - }); -}); diff --git a/src/renderer/components/+config-secrets/add-secret-dialog.tsx b/src/renderer/components/+config-secrets/add-secret-dialog.tsx deleted file mode 100644 index 56ed65013e..0000000000 --- a/src/renderer/components/+config-secrets/add-secret-dialog.tsx +++ /dev/null @@ -1,241 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import "./add-secret-dialog.scss"; - -import React from "react"; -import { observable, makeObservable } from "mobx"; -import { observer } from "mobx-react"; -import { Dialog, DialogProps } from "../dialog"; -import { Wizard, WizardStep } from "../wizard"; -import { Input } from "../input"; -import { systemName } from "../input/input_validators"; -import { Secret, secretsApi, SecretType } from "../../../common/k8s-api/endpoints"; -import { SubTitle } from "../layout/sub-title"; -import { NamespaceSelect } from "../+namespaces/namespace-select"; -import { Select, SelectOption } from "../select"; -import { Icon } from "../icon"; -import type { KubeObjectMetadata } from "../../../common/k8s-api/kube-object"; -import { base64 } from "../../utils"; -import { Notifications } from "../notifications"; -import upperFirst from "lodash/upperFirst"; -import { showDetails } from "../kube-detail-params"; - -interface Props extends Partial { -} - -interface ISecretTemplateField { - key: string; - value?: string; - required?: boolean; -} - -interface ISecretTemplate { - [field: string]: ISecretTemplateField[]; - annotations?: ISecretTemplateField[]; - labels?: ISecretTemplateField[]; - data?: ISecretTemplateField[]; -} - -type ISecretField = keyof ISecretTemplate; - -const dialogState = observable.object({ - isOpen: false, -}); - -@observer -export class AddSecretDialog extends React.Component { - constructor(props: Props) { - super(props); - makeObservable(this); - } - - static open() { - dialogState.isOpen = true; - } - - static close() { - dialogState.isOpen = false; - } - - private secretTemplate: { [p: string]: ISecretTemplate } = { - [SecretType.Opaque]: {}, - [SecretType.ServiceAccountToken]: { - annotations: [ - { key: "kubernetes.io/service-account.name", required: true }, - { key: "kubernetes.io/service-account.uid", required: true }, - ], - }, - }; - - get types() { - return Object.keys(this.secretTemplate) as SecretType[]; - } - - @observable secret = this.secretTemplate; - @observable name = ""; - @observable namespace = "default"; - @observable type = SecretType.Opaque; - - reset = () => { - this.name = ""; - this.secret = this.secretTemplate; - }; - - close = () => { - AddSecretDialog.close(); - }; - - private getDataFromFields = (fields: ISecretTemplateField[] = [], processValue?: (val: string) => string) => { - return fields.reduce((data, field) => { - const { key, value } = field; - - if (key) { - data[key] = processValue ? processValue(value) : value; - } - - return data; - }, {}); - }; - - createSecret = async () => { - const { name, namespace, type } = this; - const { data = [], labels = [], annotations = [] } = this.secret[type]; - const secret: Partial = { - type, - data: this.getDataFromFields(data, val => val ? base64.encode(val) : ""), - metadata: { - name, - namespace, - annotations: this.getDataFromFields(annotations), - labels: this.getDataFromFields(labels), - } as KubeObjectMetadata, - }; - - try { - const newSecret = await secretsApi.create({ namespace, name }, secret); - - showDetails(newSecret.selfLink); - this.close(); - } catch (err) { - Notifications.error(err); - } - }; - - addField = (field: ISecretField) => { - const fields = this.secret[this.type][field] || []; - - fields.push({ key: "", value: "" }); - this.secret[this.type][field] = fields; - }; - - removeField = (field: ISecretField, index: number) => { - const fields = this.secret[this.type][field] || []; - - fields.splice(index, 1); - }; - - renderFields(field: ISecretField) { - const fields = this.secret[this.type][field] || []; - - return ( - <> - - this.addField(field)} - /> - -
- {fields.map((item, index) => { - const { key = "", value = "", required } = item; - - return ( -
- item.key = v} - /> - item.value = v} - /> - this.removeField(field, index)} - /> -
- ); - })} -
- - ); - } - - render() { - const { ...dialogProps } = this.props; - const { namespace, name, type } = this; - const header =
Create Secret
; - - return ( - - - -
- - this.name = v} - /> -
-
-
- - this.namespace = value} - /> -
-
- - this.editData(name, value, !revealSecret)} - /> - {typeof decodedVal === "string" && ( - this.revealSecret.toggle(name)} - /> - )} -
-
- ); - }; - - renderData() { - const secrets = Object.entries(this.data); - - if (secrets.length === 0) { - return null; - } - - return ( - <> - - {secrets.map(this.renderSecret)} -
+ ); +}); + +export const CustomResourceDefinitionDetails = withInjectables(NonInjectedCustomResourceDefinitionDetails, { + getProps: (di, props) => ({ + + ...props, + }), +}); + diff --git a/src/renderer/components/+custom-resources/index.ts b/src/renderer/components/+custom-resource/index.ts similarity index 64% rename from src/renderer/components/+custom-resources/index.ts rename to src/renderer/components/+custom-resource/index.ts index b08e5e8eb9..4d054858a2 100644 --- a/src/renderer/components/+custom-resources/index.ts +++ b/src/renderer/components/+custom-resource/index.ts @@ -3,7 +3,7 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -export * from "./crd-list"; -export * from "./crd-details"; +export * from "./crd-list/crd-list"; +export * from "./details"; export * from "./crd-resources"; -export * from "./crd-resource-details"; +export * from "./resource-details"; diff --git a/src/renderer/components/+custom-resource/layout.tsx b/src/renderer/components/+custom-resource/layout.tsx new file mode 100644 index 0000000000..2dacc1f049 --- /dev/null +++ b/src/renderer/components/+custom-resource/layout.tsx @@ -0,0 +1,22 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import React from "react"; +import { observer } from "mobx-react"; +import { Redirect, Route, Switch } from "react-router"; +import { TabLayout } from "../layout/tab-layout"; +import { CrdList } from "./crd-list/crd-list"; +import { CrdResources } from "./crd-resources"; +import { crdURL, crdDefinitionsRoute, crdResourcesRoute } from "../../../common/routes"; + +export const CustomResourcesLayout = observer(() => ( + + + + + + + +)); diff --git a/src/renderer/components/+custom-resources/crd-resource-details.scss b/src/renderer/components/+custom-resource/resource-details.scss similarity index 100% rename from src/renderer/components/+custom-resources/crd-resource-details.scss rename to src/renderer/components/+custom-resource/resource-details.scss diff --git a/src/renderer/components/+custom-resources/crd-resource-details.tsx b/src/renderer/components/+custom-resource/resource-details.tsx similarity index 57% rename from src/renderer/components/+custom-resources/crd-resource-details.tsx rename to src/renderer/components/+custom-resource/resource-details.tsx index 8d99b141e5..b4be784c6d 100644 --- a/src/renderer/components/+custom-resources/crd-resource-details.tsx +++ b/src/renderer/components/+custom-resource/resource-details.tsx @@ -3,7 +3,7 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import "./crd-resource-details.scss"; +import "./resource-details.scss"; import React from "react"; import jsonPath from "jsonpath"; @@ -14,12 +14,13 @@ import { DrawerItem } from "../drawer"; import type { KubeObjectDetailsProps } from "../kube-object-details"; import { KubeObjectMeta } from "../kube-object-meta"; import { Input } from "../input"; -import { AdditionalPrinterColumnsV1, CustomResourceDefinition } from "../../../common/k8s-api/endpoints/crd.api"; +import { AdditionalPrinterColumnsV1, CustomResourceDefinition } from "../../../common/k8s-api/endpoints/custom-resource-definition.api"; import { parseJsonPath } from "../../utils/jsonPath"; import { KubeObject, KubeObjectMetadata, KubeObjectStatus } from "../../../common/k8s-api/kube-object"; import logger from "../../../common/logger"; +import { withInjectables } from "@ogre-tools/injectable-react"; -interface Props extends KubeObjectDetailsProps { +export interface CustomResourceDetailsProps extends KubeObjectDetailsProps { crd: CustomResourceDefinition; } @@ -43,17 +44,36 @@ function convertSpecValue(value: any): any { return value; } -@observer -export class CrdResourceDetails extends React.Component { - renderAdditionalColumns(resource: KubeObject, columns: AdditionalPrinterColumnsV1[]) { +interface Dependencies { + +} + +const NonInjectedCustomResourceDetails = observer(({ object: customResource, crd }: Dependencies & CustomResourceDetailsProps) => { + if (!customResource || !crd) { + return null; + } + + if (!(customResource instanceof KubeObject)) { + logger.error("[CrdResourceDetails]: passed object that is not an instanceof KubeObject", customResource); + + return null; + } + + if (!(crd instanceof CustomResourceDefinition)) { + logger.error("[CrdResourceDetails]: passed crd that is not an instanceof CustomResourceDefinition", crd); + + return null; + } + + const renderAdditionalColumns = (resource: KubeObject, columns: AdditionalPrinterColumnsV1[]) => { return columns.map(({ name, jsonPath: jp }) => ( {convertSpecValue(jsonPath.value(resource, parseJsonPath(jp.slice(1))))} )); - } + }; - renderStatus(customResource: KubeObject, columns: AdditionalPrinterColumnsV1[]) { + const renderStatus = (customResource: KubeObject, columns: AdditionalPrinterColumnsV1[]) => { const showStatus = !columns.find(column => column.name == "Status") && Array.isArray(customResource.status?.conditions); if (!showStatus) { @@ -77,35 +97,23 @@ export class CrdResourceDetails extends React.Component { {conditions} ); - } + }; - render() { - const { props: { object, crd }} = this; + const extraColumns = crd.getPrinterColumns(); - if (!object || !crd) { - return null; - } + return ( +
+ + {renderAdditionalColumns(customResource, extraColumns)} + {renderStatus(customResource, extraColumns)} +
+ ); +}); - if (!(object instanceof KubeObject)) { - logger.error("[CrdResourceDetails]: passed object that is not an instanceof KubeObject", object); +export const CustomResourceDetails = withInjectables(NonInjectedCustomResourceDetails, { + getProps: (di, props) => ({ - return null; - } + ...props, + }), +}); - if (!(crd instanceof CustomResourceDefinition)) { - logger.error("[CrdResourceDetails]: passed crd that is not an instanceof CustomResourceDefinition", crd); - - return null; - } - - const extraColumns = crd.getPrinterColumns(); - - return ( -
- - {this.renderAdditionalColumns(object, extraColumns)} - {this.renderStatus(object, extraColumns)} -
- ); - } -} diff --git a/src/renderer/components/+custom-resources/crd-resource.store.ts b/src/renderer/components/+custom-resource/resource.store.ts similarity index 80% rename from src/renderer/components/+custom-resources/crd-resource.store.ts rename to src/renderer/components/+custom-resource/resource.store.ts index 0ccfad18b4..fdfb4d70c9 100644 --- a/src/renderer/components/+custom-resources/crd-resource.store.ts +++ b/src/renderer/components/+custom-resource/resource.store.ts @@ -7,8 +7,11 @@ import type { KubeApi } from "../../../common/k8s-api/kube-api"; import { KubeObjectStore } from "../../../common/k8s-api/kube-object.store"; import type { KubeObject } from "../../../common/k8s-api/kube-object"; +/** + * @deprecated This type is never used + */ export class CRDResourceStore extends KubeObjectStore { - constructor(api: KubeApi) { - super(api); + constructor(public readonly api:KubeApi) { + super(); } } diff --git a/src/renderer/components/+custom-resource/routes.injectable.ts b/src/renderer/components/+custom-resource/routes.injectable.ts new file mode 100644 index 0000000000..6739ab0c3e --- /dev/null +++ b/src/renderer/components/+custom-resource/routes.injectable.ts @@ -0,0 +1,23 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { computed } from "mobx"; +import { crdURL, crdDefinitionsRoute } from "../../../common/routes"; +import type { TabLayoutRoute } from "../layout/tab-layout"; +import { CustomResourcesLayout } from "./layout"; + +const customResourceRoutesInjectable = getInjectable({ + instantiate: () => computed(() => [ + { + title: "Definitions", + component: CustomResourcesLayout, + url: crdURL(), + routePath: String(crdDefinitionsRoute.path), + }, + ] as TabLayoutRoute[]), + lifecycle: lifecycleEnum.singleton, +}); + +export default customResourceRoutesInjectable; diff --git a/src/renderer/components/+custom-resource/sidebar-item.tsx b/src/renderer/components/+custom-resource/sidebar-item.tsx new file mode 100644 index 0000000000..53ceeb2737 --- /dev/null +++ b/src/renderer/components/+custom-resource/sidebar-item.tsx @@ -0,0 +1,89 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { withInjectables } from "@ogre-tools/injectable-react"; +import type { IComputedValue } from "mobx"; +import { observer } from "mobx-react"; +import React, { useEffect } from "react"; +import { crdURL, crdRoute } from "../../../common/routes"; +import { isAllowedResource } from "../../../extensions/renderer-api/k8s-api"; +import type { KubeWatchApi } from "../../kube-watch-api/kube-watch-api"; +import kubeWatchApiInjectable from "../../kube-watch-api/kube-watch-api.injectable"; +import { isActiveRoute } from "../../navigation"; +import { Icon } from "../icon"; +import { SidebarItem } from "../layout/sidebar-item"; +import type { TabLayoutRoute } from "../layout/tab-layout"; +import { TabRouteTree } from "../layout/tab-route-tree"; +import { Spinner } from "../spinner"; +import customResourceRoutesInjectable from "./routes.injectable"; +import type { CustomResourceDefinitionStore } from "./store"; +import customResourceDefinitionStoreInjectable from "./store.injectable"; + +export interface CustomResourcesSidebarItemProps {} + +interface Dependencies { + routes: IComputedValue; + kubeWatchApi: KubeWatchApi; + customResourceDefinitionStore: CustomResourceDefinitionStore; +} + +const NonInjectedCustomResourcesSidebarItem = observer(({ customResourceDefinitionStore, kubeWatchApi, routes }: Dependencies & CustomResourcesSidebarItemProps) => { + useEffect(() => kubeWatchApi.subscribeStores([ + customResourceDefinitionStore, + ]), []); + + const tabRoutes = routes.get(); + + const renderCustomResources = () => { + if (customResourceDefinitionStore.isLoading) { + return ( +
+ +
+ ); + } + + return Object.entries(customResourceDefinitionStore.groups).map(([group, crds]) => { + const id = `crd-group:${group}`; + const crdGroupsPageUrl = crdURL({ query: { groups: group }}); + + return ( + + {crds.map((crd) => ( + + ))} + + ); + }); + }; + + return ( + } + > + + {renderCustomResources()} + + ); +}); + +export const CustomResourcesSidebarItem = withInjectables(NonInjectedCustomResourcesSidebarItem, { + getProps: (di, props) => ({ + routes: di.inject(customResourceRoutesInjectable), + customResourceDefinitionStore: di.inject(customResourceDefinitionStoreInjectable), + kubeWatchApi: di.inject(kubeWatchApiInjectable), + ...props, + }), +}); diff --git a/src/renderer/components/+custom-resource/store.injectable.ts b/src/renderer/components/+custom-resource/store.injectable.ts new file mode 100644 index 0000000000..ffce1e2e5c --- /dev/null +++ b/src/renderer/components/+custom-resource/store.injectable.ts @@ -0,0 +1,14 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import apiManagerInjectable from "../../../common/k8s-api/api-manager.injectable"; +import type { CustomResourceDefinitionStore } from "./store"; + +const customResourceDefinitionStoreInjectable = getInjectable({ + instantiate: (di) => di.inject(apiManagerInjectable).getStore("/apis/apiextensions.k8s.io/v1/customresourcedefinitions") as CustomResourceDefinitionStore, + lifecycle: lifecycleEnum.singleton, +}); + +export default customResourceDefinitionStoreInjectable; diff --git a/src/renderer/components/+custom-resources/crd.store.ts b/src/renderer/components/+custom-resource/store.ts similarity index 52% rename from src/renderer/components/+custom-resources/crd.store.ts rename to src/renderer/components/+custom-resource/store.ts index ae221b1d05..19efec2c8a 100644 --- a/src/renderer/components/+custom-resources/crd.store.ts +++ b/src/renderer/components/+custom-resource/store.ts @@ -6,38 +6,22 @@ import { computed, reaction, makeObservable } from "mobx"; import { KubeObjectStore } from "../../../common/k8s-api/kube-object.store"; import { autoBind } from "../../utils"; -import { crdApi, CustomResourceDefinition } from "../../../common/k8s-api/endpoints/crd.api"; -import { apiManager } from "../../../common/k8s-api/api-manager"; -import { KubeApi } from "../../../common/k8s-api/kube-api"; -import { CRDResourceStore } from "./crd-resource.store"; -import { KubeObject } from "../../../common/k8s-api/kube-object"; +import type { CustomResourceDefinition, CustomResourceDefinitionApi } from "../../../common/k8s-api/endpoints/custom-resource-definition.api"; +import type { KubeObject } from "../../../common/k8s-api/kube-object"; -function initStore(crd: CustomResourceDefinition) { - const objectConstructor = class extends KubeObject { - static readonly kind = crd.getResourceKind(); - static readonly namespaced = crd.isNamespaced(); - static readonly apiBase = crd.getResourceApiBase(); - }; - - const api = apiManager.getApi(objectConstructor.apiBase) - ?? new KubeApi({ objectConstructor }); - - if (!apiManager.getStore(api)) { - apiManager.registerStore(new CRDResourceStore(api)); - } +interface Dependencies { + initCustomResourceStore: (crd: CustomResourceDefinition) => void; } -export class CRDStore extends KubeObjectStore { - api = crdApi; - - constructor() { +export class CustomResourceDefinitionStore extends KubeObjectStore { + constructor(public readonly api:CustomResourceDefinitionApi, { initCustomResourceStore }: Dependencies) { super(); makeObservable(this); autoBind(this); // auto-init stores for crd-s - reaction(() => this.getItems(), items => items.forEach(initStore)); + reaction(() => this.getItems(), items => items.forEach(initCustomResourceStore)); } protected sortItems(items: CustomResourceDefinition[]) { @@ -70,7 +54,3 @@ export class CRDStore extends KubeObjectStore { )); } } - -export const crdStore = new CRDStore(); - -apiManager.registerStore(crdStore); diff --git a/src/renderer/components/+custom-resources/crd-details.tsx b/src/renderer/components/+custom-resources/crd-details.tsx deleted file mode 100644 index 788178544d..0000000000 --- a/src/renderer/components/+custom-resources/crd-details.tsx +++ /dev/null @@ -1,150 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import "./crd-details.scss"; - -import React from "react"; -import { Link } from "react-router-dom"; -import { observer } from "mobx-react"; -import { CustomResourceDefinition } from "../../../common/k8s-api/endpoints/crd.api"; -import { Badge } from "../badge"; -import { DrawerItem, DrawerTitle } from "../drawer"; -import type { KubeObjectDetailsProps } from "../kube-object-details"; -import { Table, TableCell, TableHead, TableRow } from "../table"; -import { Input } from "../input"; -import { KubeObjectMeta } from "../kube-object-meta"; -import { MonacoEditor } from "../monaco-editor"; -import logger from "../../../common/logger"; - -interface Props extends KubeObjectDetailsProps { -} - -@observer -export class CRDDetails extends React.Component { - render() { - const { object: crd } = this.props; - - if (!crd) { - return null; - } - - if (!(crd instanceof CustomResourceDefinition)) { - logger.error("[CRDDetails]: passed object that is not an instanceof CustomResourceDefinition", crd); - - return null; - } - - const { plural, singular, kind, listKind } = crd.getNames(); - const printerColumns = crd.getPrinterColumns(); - const validation = crd.getValidation(); - - return ( -
- - - - {crd.getGroup()} - - - {crd.getVersion()} - - - {crd.getStoredVersions()} - - - {crd.getScope()} - - - - {crd.getResourceTitle()} - - - - - - - { - crd.getConditions().map(condition => { - const { type, message, lastTransitionTime, status } = condition; - - return ( - -

{message}

-

Last transition time: {lastTransitionTime}

- - )} - /> - ); - }) - } -
- - - - plural - singular - kind - listKind - - - {plural} - {singular} - {kind} - {listKind} - -
- {printerColumns.length > 0 && - <> - - - - Name - Type - JSON Path - - { - printerColumns.map((column, index) => { - const { name, type, jsonPath } = column; - - return ( - - {name} - {type} - - - - - ); - }) - } -
- - } - {validation && - <> - - - - } -
- ); - } -} diff --git a/src/renderer/components/+custom-resources/crd-list.tsx b/src/renderer/components/+custom-resources/crd-list.tsx deleted file mode 100644 index 1806b99926..0000000000 --- a/src/renderer/components/+custom-resources/crd-list.tsx +++ /dev/null @@ -1,139 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import "./crd-list.scss"; - -import React from "react"; -import { computed, makeObservable } from "mobx"; -import { observer } from "mobx-react"; -import { Link } from "react-router-dom"; -import { stopPropagation } from "../../utils"; -import { KubeObjectListLayout } from "../kube-object-list-layout"; -import { crdStore } from "./crd.store"; -import type { CustomResourceDefinition } from "../../../common/k8s-api/endpoints/crd.api"; -import { Select, SelectOption } from "../select"; -import { createPageParam } from "../../navigation"; -import { Icon } from "../icon"; -import type { TableSortCallbacks } from "../table"; - -export const crdGroupsUrlParam = createPageParam({ - name: "groups", - defaultValue: [], -}); - -enum columnId { - kind = "kind", - group = "group", - version = "version", - scope = "scope", - age = "age", -} - -@observer -export class CrdList extends React.Component { - constructor(props: {}) { - super(props); - makeObservable(this); - } - - get selectedGroups(): string[] { - return crdGroupsUrlParam.get(); - } - - @computed get items() { - if (this.selectedGroups.length) { - return crdStore.items.filter(item => this.selectedGroups.includes(item.getGroup())); - } - - return crdStore.items; // show all by default - } - - toggleSelection(group: string) { - const groups = new Set(crdGroupsUrlParam.get()); - - if (groups.has(group)) { - groups.delete(group); - } else { - groups.add(group); - } - crdGroupsUrlParam.set([...groups]); - } - - render() { - const { items, selectedGroups } = this; - const sortingCallbacks: TableSortCallbacks = { - [columnId.kind]: crd => crd.getResourceKind(), - [columnId.group]: crd => crd.getGroup(), - [columnId.version]: crd => crd.getVersion(), - [columnId.scope]: crd => crd.getScope(), - }; - - return ( - already has and is always mounted - subscribeStores={false} - items={items} - sortingCallbacks={sortingCallbacks} - searchFilters={Object.values(sortingCallbacks)} - renderHeaderTitle="Custom Resources" - customizeHeader={({ filters, ...headerPlaceholders }) => { - let placeholder = <>All groups; - - if (selectedGroups.length == 1) placeholder = <>Group: {selectedGroups[0]}; - if (selectedGroups.length >= 2) placeholder = <>Groups: {selectedGroups.join(", ")}; - - return { - // todo: move to global filters - filters: ( - <> - {filters} - { ); - } - - renderReadme() { - if (this.readme === null) { + }; + const renderReadme = () => { + if (readme === null) { return ; } return (
- +
); - } - - renderContent() { - if (this.error) { + }; + const renderContent = () => { + if (error) { return (
-

{this.error}

+

{error}

); } - if (!this.selectedChart) { + if (!selectedChart) { return ; } return (
- {this.renderIntroduction()} - {this.renderReadme()} + {renderIntroduction()} + {renderReadme()}
); + }; + + if (!chart) { + return null; } - render() { - const { chart, hideDetails } = this.props; - const title = chart ? `Chart: ${chart.getFullName()}` : ""; + return ( + + {renderContent()} + + ); +}); - return ( - - {this.renderContent()} - - ); - } -} - -export const HelmChartDetails = withInjectables( - NonInjectedHelmChartDetails, - - { - getProps: (di, props) => ({ - createInstallChartTab: di.inject(createInstallChartTabInjectable), - ...props, - }), - }, -); +export const HelmChartDetails = withInjectables(NonInjectedHelmChartDetails, { + getProps: (di, props) => ({ + newInstallChartTab: di.inject(newInstallChartTabInjectable), + ...props, + }), +}); diff --git a/src/renderer/components/+apps-helm-charts/helm-charts.scss b/src/renderer/components/+helm-charts/helm-charts.scss similarity index 100% rename from src/renderer/components/+apps-helm-charts/helm-charts.scss rename to src/renderer/components/+helm-charts/helm-charts.scss diff --git a/src/renderer/components/+apps-helm-charts/helm-charts.tsx b/src/renderer/components/+helm-charts/helm-charts.tsx similarity index 96% rename from src/renderer/components/+apps-helm-charts/helm-charts.tsx rename to src/renderer/components/+helm-charts/helm-charts.tsx index 36b84d5267..9b2f7007e3 100644 --- a/src/renderer/components/+apps-helm-charts/helm-charts.tsx +++ b/src/renderer/components/+helm-charts/helm-charts.tsx @@ -8,9 +8,9 @@ import "./helm-charts.scss"; import React, { Component } from "react"; import type { RouteComponentProps } from "react-router"; import { observer } from "mobx-react"; -import { helmChartStore } from "./helm-chart.store"; -import type { HelmChart } from "../../../common/k8s-api/endpoints/helm-charts.api"; -import { HelmChartDetails } from "./helm-chart-details"; +import { helmChartStore } from "./store"; +import type { HelmChart } from "../../../common/k8s-api/endpoints/helm-chart.api"; +import { HelmChartDetails } from "./details"; import { navigation } from "../../navigation"; import { ItemListLayout } from "../item-object-list/item-list-layout"; import { helmChartsURL } from "../../../common/routes"; diff --git a/src/renderer/components/+apps-helm-charts/helm-placeholder.svg b/src/renderer/components/+helm-charts/helm-placeholder.svg similarity index 100% rename from src/renderer/components/+apps-helm-charts/helm-placeholder.svg rename to src/renderer/components/+helm-charts/helm-placeholder.svg diff --git a/src/renderer/components/+apps-helm-charts/index.ts b/src/renderer/components/+helm-charts/index.ts similarity index 100% rename from src/renderer/components/+apps-helm-charts/index.ts rename to src/renderer/components/+helm-charts/index.ts diff --git a/src/renderer/components/+apps-helm-charts/helm-chart.store.ts b/src/renderer/components/+helm-charts/store.ts similarity index 98% rename from src/renderer/components/+apps-helm-charts/helm-chart.store.ts rename to src/renderer/components/+helm-charts/store.ts index 3c01869d9f..dfcbec251f 100644 --- a/src/renderer/components/+apps-helm-charts/helm-chart.store.ts +++ b/src/renderer/components/+helm-charts/store.ts @@ -6,7 +6,7 @@ import semver from "semver"; import { observable, makeObservable } from "mobx"; import { autoBind, sortCompareChartVersions } from "../../utils"; -import { getChartDetails, HelmChart, listCharts } from "../../../common/k8s-api/endpoints/helm-charts.api"; +import { getChartDetails, HelmChart, listCharts } from "../../../common/k8s-api/endpoints/helm-chart.api"; import { ItemStore } from "../../../common/item.store"; import flatten from "lodash/flatten"; diff --git a/src/renderer/components/+helm-releases/create-release.injectable.ts b/src/renderer/components/+helm-releases/create-release.injectable.ts new file mode 100644 index 0000000000..660daf76ce --- /dev/null +++ b/src/renderer/components/+helm-releases/create-release.injectable.ts @@ -0,0 +1,28 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { + createRelease, + IReleaseCreatePayload, +} from "../../../common/k8s-api/endpoints/helm-release.api"; +import releasesInjectable from "./releases.injectable"; + +const createReleaseInjectable = getInjectable({ + instantiate: (di) => { + const releases = di.inject(releasesInjectable); + + return async (payload: IReleaseCreatePayload) => { + const release = await createRelease(payload); + + releases.invalidate(); + + return release; + }; + }, + + lifecycle: lifecycleEnum.singleton, +}); + +export default createReleaseInjectable; diff --git a/src/renderer/components/+helm-releases/delete-release.injectable.ts b/src/renderer/components/+helm-releases/delete-release.injectable.ts new file mode 100644 index 0000000000..0e4c8c1c19 --- /dev/null +++ b/src/renderer/components/+helm-releases/delete-release.injectable.ts @@ -0,0 +1,26 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { + deleteRelease, + HelmRelease, +} from "../../../common/k8s-api/endpoints/helm-release.api"; +import releasesInjectable from "./releases.injectable"; + +const deleteReleaseInjectable = getInjectable({ + instantiate: (di) => { + const releases = di.inject(releasesInjectable); + + return async (release: HelmRelease) => { + await deleteRelease(release.getName(), release.getNs()); + + releases.invalidate(); + }; + }, + + lifecycle: lifecycleEnum.singleton, +}); + +export default deleteReleaseInjectable; diff --git a/src/renderer/components/+apps-releases/release-details.scss b/src/renderer/components/+helm-releases/details.scss similarity index 97% rename from src/renderer/components/+apps-releases/release-details.scss rename to src/renderer/components/+helm-releases/details.scss index 20ce0a2cef..906fc1156f 100644 --- a/src/renderer/components/+apps-releases/release-details.scss +++ b/src/renderer/components/+helm-releases/details.scss @@ -3,7 +3,7 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -@import "release.mixins"; +@import "./release.mixins"; .ReleaseDetails { .DrawerItem { diff --git a/src/renderer/components/+helm-releases/details.tsx b/src/renderer/components/+helm-releases/details.tsx new file mode 100644 index 0000000000..93ab1e0802 --- /dev/null +++ b/src/renderer/components/+helm-releases/details.tsx @@ -0,0 +1,280 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import "./details.scss"; + +import React, { Component } from "react"; +import groupBy from "lodash/groupBy"; +import { computed, IComputedValue, makeObservable, observable } from "mobx"; +import { Link } from "react-router-dom"; +import kebabCase from "lodash/kebabCase"; +import type { HelmRelease, IReleaseDetails, IReleaseUpdateDetails, IReleaseUpdatePayload } from "../../../common/k8s-api/endpoints/helm-release.api"; +import { HelmReleaseMenu } from "./item-menu"; +import { Drawer, DrawerItem, DrawerTitle } from "../drawer"; +import { Badge } from "../badge"; +import { cssNames, stopPropagation } from "../../utils"; +import { Observer, observer } from "mobx-react"; +import { Spinner } from "../spinner"; +import { Table, TableCell, TableHead, TableRow } from "../table"; +import { Button } from "../button"; +import { Notifications } from "../notifications"; +import type { Theme } from "../../themes/store"; +import type { ApiManager } from "../../../common/k8s-api/api-manager"; +import { SubTitle } from "../layout/sub-title"; +import { getDetailsUrl } from "../kube-detail-params"; +import { Checkbox } from "../checkbox"; +import { MonacoEditor } from "../monaco-editor"; +import { IAsyncComputed, withInjectables } from "@ogre-tools/injectable-react"; +import createUpgradeChartTabInjectable from "../dock/upgrade-chart/create-tab.injectable"; +import updateReleaseInjectable from "./update-release.injectable"; +import releaseInjectable from "./details/release.injectable"; +import releaseDetailsInjectable from "./details/release-details.injectable"; +import releaseValuesInjectable from "./details/release-values.injectable"; +import userSuppliedValuesAreShownInjectable from "./details/user-supplied-values-are-shown.injectable"; +import activeThemeInjectable from "../../themes/active-theme.injectable"; +import apiManagerInjectable from "../../../common/k8s-api/api-manager.injectable"; + +export interface ReleaseDetailsProps { + hideDetails(): void; +} + +interface Dependencies { + release: IComputedValue; + releaseDetails: IAsyncComputed; + releaseValues: IAsyncComputed; + updateRelease: (name: string, namespace: string, payload: IReleaseUpdatePayload) => Promise; + createUpgradeChartTab: (release: HelmRelease) => void; + userSuppliedValuesAreShown: { toggle: () => void, value: boolean }; + apiManager: ApiManager; + activeTheme: IComputedValue; +} + +@observer +class NonInjectedReleaseDetails extends Component { + @observable saving = false; + + private nonSavedValues: string; + + constructor(props: ReleaseDetailsProps & Dependencies) { + super(props); + makeObservable(this); + } + + @computed get release() { + return this.props.release.get(); + } + + @computed get details() { + return this.props.releaseDetails.value.get(); + } + + updateValues = async () => { + const name = this.release.getName(); + const namespace = this.release.getNs(); + const data = { + chart: this.release.getChart(), + repo: await this.release.getRepo(), + version: this.release.getVersion(), + values: this.nonSavedValues, + }; + + this.saving = true; + + try { + await this.props.updateRelease(name, namespace, data); + Notifications.ok( +

Release {name} successfully updated!

, + ); + + this.props.releaseValues.invalidate(); + } catch (err) { + Notifications.error(err); + } + this.saving = false; + }; + + upgradeVersion = () => { + const { hideDetails } = this.props; + + this.props.createUpgradeChartTab(this.release); + hideDetails(); + }; + + renderValues() { + return ( + + {() => { + const { saving } = this; + + const releaseValuesArePending = + this.props.releaseValues.pending.get(); + + this.nonSavedValues = this.props.releaseValues.value.get(); + + return ( +
+ +
+ + (this.nonSavedValues = text)} + /> +
+
+ ); + }} +
+ ); + } + + renderNotes() { + if (!this.details.info?.notes) return null; + const { notes } = this.details.info; + + return ( +
+ {notes} +
+ ); + } + + renderResources() { + const { resources } = this.details; + + if (!resources) return null; + const groups = groupBy(resources, item => item.kind); + const tables = Object.entries(groups).map(([kind, items]) => { + return ( + + + + + Name + {items[0].getNs() && Namespace} + Age + + {items.map(item => { + const name = item.getName(); + const namespace = item.getNs(); + const api = this.props.apiManager.getApi(api => api.kind === kind && api.apiVersionWithGroup == item.apiVersion); + const detailsUrl = api ? getDetailsUrl(api.getUrl({ name, namespace })) : ""; + + return ( + + + {detailsUrl ? {name} : name} + + {namespace && {namespace}} + {item.getAge()} + + ); + })} +
+
+ ); + }); + + return ( +
+ {tables} +
+ ); + } + + renderContent() { + if (!this.release) return null; + + if (!this.details) { + return ; + } + + return ( +
+ +
+ {this.release.getChart()} +
+
+ + {this.release.getUpdated()} ago ({this.release.updated}) + + + {this.release.getNs()} + + +
+ + {this.release.getVersion()} + +
+
+ + + + {this.renderValues()} + + {this.renderNotes()} + + {this.renderResources()} +
+ ); + } + + render() { + const { hideDetails, activeTheme } = this.props; + const title = this.release ? `Release: ${this.release.getName()}` : ""; + const toolbar = ; + + return ( + + {this.renderContent()} + + ); + } +} + +export const ReleaseDetails = withInjectables(NonInjectedReleaseDetails, { + getProps: (di, props) => ({ + release: di.inject(releaseInjectable), + releaseDetails: di.inject(releaseDetailsInjectable), + releaseValues: di.inject(releaseValuesInjectable), + userSuppliedValuesAreShown: di.inject(userSuppliedValuesAreShownInjectable), + updateRelease: di.inject(updateReleaseInjectable), + createUpgradeChartTab: di.inject(createUpgradeChartTabInjectable), + activeTheme: di.inject(activeThemeInjectable), + apiManager: di.inject(apiManagerInjectable), + ...props, + }), +}); diff --git a/src/renderer/components/+helm-releases/details/release-details.injectable.ts b/src/renderer/components/+helm-releases/details/release-details.injectable.ts new file mode 100644 index 0000000000..6c3d9c6dc6 --- /dev/null +++ b/src/renderer/components/+helm-releases/details/release-details.injectable.ts @@ -0,0 +1,21 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { getRelease } from "../../../../common/k8s-api/endpoints/helm-release.api"; +import { asyncComputed } from "@ogre-tools/injectable-react"; +import releaseInjectable from "./release.injectable"; + +const releaseDetailsInjectable = getInjectable({ + instantiate: (di) => + asyncComputed(async () => { + const release = di.inject(releaseInjectable).get(); + + return await getRelease(release.name, release.namespace); + }), + + lifecycle: lifecycleEnum.singleton, +}); + +export default releaseDetailsInjectable; diff --git a/src/renderer/components/+helm-releases/details/release-route-parameters.injectable.ts b/src/renderer/components/+helm-releases/details/release-route-parameters.injectable.ts new file mode 100644 index 0000000000..d179c539be --- /dev/null +++ b/src/renderer/components/+helm-releases/details/release-route-parameters.injectable.ts @@ -0,0 +1,31 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { computed } from "mobx"; +import { matchPath } from "react-router"; +import observableHistoryInjectable from "../../../navigation/observable-history.injectable"; +import { releaseRoute, ReleaseRouteParams } from "../../../../common/routes"; + +const releaseRouteParametersInjectable = getInjectable({ + instantiate: (di) => { + const observableHistory = di.inject(observableHistoryInjectable); + + return computed(() => { + const releasePathParameters = matchPath(observableHistory.location.pathname, { + path: releaseRoute.path, + }); + + if (!releasePathParameters) { + return {}; + } + + return releasePathParameters.params; + }); + }, + + lifecycle: lifecycleEnum.singleton, +}); + +export default releaseRouteParametersInjectable; diff --git a/src/renderer/components/+helm-releases/details/release-values.injectable.ts b/src/renderer/components/+helm-releases/details/release-values.injectable.ts new file mode 100644 index 0000000000..836570daa4 --- /dev/null +++ b/src/renderer/components/+helm-releases/details/release-values.injectable.ts @@ -0,0 +1,30 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { getReleaseValues } from "../../../../common/k8s-api/endpoints/helm-release.api"; +import { asyncComputed } from "@ogre-tools/injectable-react"; +import releaseInjectable from "./release.injectable"; +import { Notifications } from "../../notifications"; +import userSuppliedValuesAreShownInjectable from "./user-supplied-values-are-shown.injectable"; + +const releaseValuesInjectable = getInjectable({ + instantiate: (di) => + asyncComputed(async () => { + const release = di.inject(releaseInjectable).get(); + const userSuppliedValuesAreShown = di.inject(userSuppliedValuesAreShownInjectable).value; + + try { + return await getReleaseValues(release.getName(), release.getNs(), !userSuppliedValuesAreShown) ?? ""; + } catch (error) { + Notifications.error(`Failed to load values for ${release.getName()}: ${error}`); + + return ""; + } + }), + + lifecycle: lifecycleEnum.singleton, +}); + +export default releaseValuesInjectable; diff --git a/src/renderer/components/+helm-releases/details/release.injectable.ts b/src/renderer/components/+helm-releases/details/release.injectable.ts new file mode 100644 index 0000000000..75b821890a --- /dev/null +++ b/src/renderer/components/+helm-releases/details/release.injectable.ts @@ -0,0 +1,30 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { matches } from "lodash/fp"; +import releasesInjectable from "../releases.injectable"; +import releaseRouteParametersInjectable from "./release-route-parameters.injectable"; +import { computed } from "mobx"; + +const releaseInjectable = getInjectable({ + instantiate: (di) => { + const releases = di.inject(releasesInjectable); + const releaseRouteParameters = di.inject(releaseRouteParametersInjectable); + + return computed(() => { + const { name, namespace } = releaseRouteParameters.get(); + + if (!name || !namespace) { + return null; + } + + return releases.value.get().find(matches({ name, namespace })); + }); + }, + + lifecycle: lifecycleEnum.singleton, +}); + +export default releaseInjectable; diff --git a/src/renderer/components/+helm-releases/details/user-supplied-values-are-shown.injectable.ts b/src/renderer/components/+helm-releases/details/user-supplied-values-are-shown.injectable.ts new file mode 100644 index 0000000000..374f982882 --- /dev/null +++ b/src/renderer/components/+helm-releases/details/user-supplied-values-are-shown.injectable.ts @@ -0,0 +1,27 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { observable } from "mobx"; + +const userSuppliedValuesAreShownInjectable = getInjectable({ + instantiate: () => { + const state = observable.box(false); + + return { + get value() { + return state.get(); + }, + + toggle: () => { + state.set(!state.get()); + }, + }; + }, + + lifecycle: lifecycleEnum.singleton, +}); + +export default userSuppliedValuesAreShownInjectable; + diff --git a/src/renderer/components/+apps-releases/index.ts b/src/renderer/components/+helm-releases/index.ts similarity index 100% rename from src/renderer/components/+apps-releases/index.ts rename to src/renderer/components/+helm-releases/index.ts diff --git a/src/renderer/components/+helm-releases/item-menu.tsx b/src/renderer/components/+helm-releases/item-menu.tsx new file mode 100644 index 0000000000..c5e9f392ad --- /dev/null +++ b/src/renderer/components/+helm-releases/item-menu.tsx @@ -0,0 +1,78 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import React from "react"; +import type { HelmRelease } from "../../../common/k8s-api/endpoints/helm-release.api"; +import { cssNames, noop } from "../../utils"; +import { MenuActions, MenuActionsProps } from "../menu/menu-actions"; +import { MenuItem } from "../menu"; +import { Icon } from "../icon"; +import { withInjectables } from "@ogre-tools/injectable-react"; +import newUpgradeChartTabInjectable from "../dock/upgrade-chart/create-tab.injectable"; +import { observer } from "mobx-react"; +import openHelmReleaseRollbackDialogInjectable from "./rollback-dialog/open.injectable"; +import deleteReleaseInjectable from "./delete-release.injectable"; + +export interface HelmReleaseMenuProps extends MenuActionsProps { + release: HelmRelease | null | undefined; + hideDetails?: () => void; +} + +interface Dependencies { + newUpgradeChartTab: (release: HelmRelease) => void; + deleteRelease: (release: HelmRelease) => Promise; + openRollbackReleaseDialog: (release: HelmRelease) => void; +} + +const NonInjectedHelmReleaseMenu = observer(({ + newUpgradeChartTab, + release, + hideDetails = noop, + deleteRelease, + openRollbackReleaseDialog, + toolbar, + className, + ...menuProps +}: Dependencies & HelmReleaseMenuProps) => { + if (!release) { + return null; + } + + const remove = () => deleteRelease(release); + const upgrade = () => { + newUpgradeChartTab(release); + hideDetails(); + }; + const rollback = () => openRollbackReleaseDialog(release); + + return ( +

Remove Helm Release {release.name}?

} + > + {release.getRevision() > 1 && ( + + + Rollback + + )} + + + Upgrade + +
+ ); +}); + +export const HelmReleaseMenu = withInjectables(NonInjectedHelmReleaseMenu, { + getProps: (di, props) => ({ + newUpgradeChartTab: di.inject(newUpgradeChartTabInjectable), + deleteRelease: di.inject(deleteReleaseInjectable), + openRollbackReleaseDialog: di.inject(openHelmReleaseRollbackDialogInjectable), + ...props, + }), +}); diff --git a/src/renderer/components/+apps-releases/release.mixins.scss b/src/renderer/components/+helm-releases/release.mixins.scss similarity index 100% rename from src/renderer/components/+apps-releases/release.mixins.scss rename to src/renderer/components/+helm-releases/release.mixins.scss diff --git a/src/renderer/components/+helm-releases/releases.injectable.ts b/src/renderer/components/+helm-releases/releases.injectable.ts new file mode 100644 index 0000000000..179f7670b8 --- /dev/null +++ b/src/renderer/components/+helm-releases/releases.injectable.ts @@ -0,0 +1,28 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { asyncComputed } from "@ogre-tools/injectable-react"; +import { listReleases } from "../../../common/k8s-api/endpoints/helm-release.api"; +import frameContextInjectable from "../../cluster-frame-context/cluster-frame-context.injectable"; + +const releasesInjectable = getInjectable({ + instantiate: (di) => { + const context = di.inject(frameContextInjectable); + + return asyncComputed(async () => { + const releaseArrays = await ( + context.hasSelectedAll + ? listReleases() + : Promise.all(context.contextNamespaces.map(listReleases)) + ); + + return releaseArrays.flat(); + }, []); + }, + + lifecycle: lifecycleEnum.singleton, +}); + +export default releasesInjectable; diff --git a/src/renderer/components/+apps-releases/releases.scss b/src/renderer/components/+helm-releases/releases.scss similarity index 100% rename from src/renderer/components/+apps-releases/releases.scss rename to src/renderer/components/+helm-releases/releases.scss diff --git a/src/renderer/components/+helm-releases/releases.tsx b/src/renderer/components/+helm-releases/releases.tsx new file mode 100644 index 0000000000..3330bc076e --- /dev/null +++ b/src/renderer/components/+helm-releases/releases.tsx @@ -0,0 +1,212 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import "./releases.scss"; + +import React, { useEffect } from "react"; +import kebabCase from "lodash/kebabCase"; +import { observer } from "mobx-react"; +import type { RouteComponentProps } from "react-router"; +import type { HelmRelease } from "../../../common/k8s-api/endpoints/helm-release.api"; +import { ReleaseDetails } from "./details"; +import { ReleaseRollbackDialog } from "./rollback-dialog/rollback-dialog"; +import { ItemListLayout } from "../item-object-list/item-list-layout"; +import { HelmReleaseMenu } from "./item-menu"; +import { NamespaceSelectFilter } from "../+namespaces/namespace-select-filter"; +import type { ReleaseRouteParams } from "../../../common/routes"; +import { releaseURL } from "../../../common/routes"; +import { withInjectables } from "@ogre-tools/injectable-react"; +import selectSingleNamespaceInjectable from "../+namespaces/select-single-namespace.injectable"; +import type { IComputedValue } from "mobx"; +import type { RemovableHelmRelease } from "./removable-releases"; +import type { ObservableHistory } from "mobx-observable-history"; +import releasesInjectable from "./releases.injectable"; +import removableReleasesInjectable from "./removable-releases.injectable"; +import observableHistoryInjectable from "../../navigation/observable-history.injectable"; +import type { ItemStore } from "../../../common/item.store"; +import { Spinner } from "../spinner"; + +enum columnId { + name = "name", + namespace = "namespace", + revision = "revision", + chart = "chart", + version = "version", + appVersion = "app-version", + status = "status", + updated = "update", +} + +export interface HelmReleasesProps extends RouteComponentProps { +} + +interface Dependencies { + releases: IComputedValue; + releasesArePending: IComputedValue; + selectSingleNamespace: (ns: string) => void; + navigation: ObservableHistory; +} + +const NonInjectedHelmReleases = observer(({ releases, releasesArePending, navigation, selectSingleNamespace, match }: Dependencies & HelmReleasesProps) => { + const { params: { namespace, name }} = match; + const selectedRelease = releases.get().find(release => release.getName() === name && release.getNs() === namespace); + + // TODO: Implement ItemListLayout without stateful stores + const legacyReleaseStore = { + get items() { + return releases.get(); + }, + + loadAll: () => Promise.resolve(), + isLoaded: true, + failedLoading: false, + + getTotalCount: () => releases.get().length, + + toggleSelection: (item) => { + item.toggle(); + }, + + isSelectedAll: () => + releases.get().every((release) => release.isSelected), + + toggleSelectionAll: () => { + releases.get().forEach((release) => release.toggle()); + }, + + isSelected: (item) => item.isSelected, + + get selectedItems() { + return releases.get().filter((release) => release.isSelected); + }, + + removeSelectedItems() { + return Promise.all( + releases.get().filter((release) => release.isSelected).map((release) => release.delete()), + ); + }, + } as ItemStore; + + useEffect(() => { + if (namespace) { + selectSingleNamespace(namespace); + } + }, []); + + const showDetails = (item: HelmRelease) => { + navigation.push(releaseURL({ + params: { + name: item.getName(), + namespace: item.getNs(), + }, + })); + }; + const hideDetails = () => { + navigation.push(releaseURL()); + }; + const onDetails = (item: HelmRelease) => { + if (item === selectedRelease) { + hideDetails(); + } else { + showDetails(item); + } + }; + const renderRemoveDialogMessage = (selectedItems: HelmRelease[]) => ( +
+ <>Remove {selectedItems.map(item => item.getName()).join(", ")}? +

+ Note: StatefulSet Volumes won't be deleted automatically +

+
+ ); + + if (releasesArePending.get()) { + // TODO: Make Spinner "center" work properly + return
; + } + + return ( + <> + release.getName(), + [columnId.namespace]: release => release.getNs(), + [columnId.revision]: release => release.getRevision(), + [columnId.chart]: release => release.getChart(), + [columnId.status]: release => release.getStatus(), + [columnId.updated]: release => release.getUpdated(false, false), + }} + searchFilters={[ + release => release.getName(), + release => release.getNs(), + release => release.getChart(), + release => release.getStatus(), + release => release.getVersion(), + ]} + customizeHeader={({ filters, searchProps, ...headerPlaceholders }) => ({ + filters: ( + <> + {filters} + + + ), + searchProps: { + ...searchProps, + placeholder: "Search Releases...", + }, + ...headerPlaceholders, + })} + renderHeaderTitle="Releases" + renderTableHeader={[ + { title: "Name", className: "name", sortBy: columnId.name, id: columnId.name }, + { title: "Namespace", className: "namespace", sortBy: columnId.namespace, id: columnId.namespace }, + { title: "Chart", className: "chart", sortBy: columnId.chart, id: columnId.chart }, + { title: "Revision", className: "revision", sortBy: columnId.revision, id: columnId.revision }, + { title: "Version", className: "version", id: columnId.version }, + { title: "App Version", className: "app-version", id: columnId.appVersion }, + { title: "Status", className: "status", sortBy: columnId.status, id: columnId.status }, + { title: "Updated", className: "updated", sortBy: columnId.updated, id: columnId.updated }, + ]} + renderTableContents={release => [ + release.getName(), + release.getNs(), + release.getChart(), + release.getRevision(), + release.getVersion(), + release.appVersion, + { title: release.getStatus(), className: kebabCase(release.getStatus()) }, + release.getUpdated(), + ]} + renderItemMenu={release => ( + + )} + customizeRemoveDialog={selectedItems => ({ + message: renderRemoveDialogMessage(selectedItems), + })} + onDetails={onDetails} + /> + + + + ); +}); + +export const HelmReleases = withInjectables(NonInjectedHelmReleases, { + getProps: (di, props) => ({ + selectSingleNamespace: di.inject(selectSingleNamespaceInjectable), + releases: di.inject(removableReleasesInjectable), + releasesArePending: di.inject(releasesInjectable).pending, + navigation: di.inject(observableHistoryInjectable), + ...props, + }), +}); diff --git a/src/renderer/components/+helm-releases/removable-releases.injectable.ts b/src/renderer/components/+helm-releases/removable-releases.injectable.ts new file mode 100644 index 0000000000..f66db543e9 --- /dev/null +++ b/src/renderer/components/+helm-releases/removable-releases.injectable.ts @@ -0,0 +1,22 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { observable } from "mobx"; +import releasesInjectable from "./releases.injectable"; +import deleteReleaseInjectable from "./delete-release.injectable"; +import { removableReleases } from "./removable-releases"; + +const removableReleasesInjectable = getInjectable({ + instantiate: (di) => + removableReleases({ + releases: di.inject(releasesInjectable), + deleteRelease: di.inject(deleteReleaseInjectable), + releaseSelectionStatus: observable.map(), + }), + + lifecycle: lifecycleEnum.singleton, +}); + +export default removableReleasesInjectable; diff --git a/src/renderer/components/+helm-releases/removable-releases.ts b/src/renderer/components/+helm-releases/removable-releases.ts new file mode 100644 index 0000000000..a9cf107a28 --- /dev/null +++ b/src/renderer/components/+helm-releases/removable-releases.ts @@ -0,0 +1,48 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import type { IAsyncComputed } from "@ogre-tools/injectable-react"; +import { computed, ObservableMap } from "mobx"; +import type { HelmRelease } from "../../../common/k8s-api/endpoints/helm-release.api"; + +interface Dependencies { + releases: IAsyncComputed; + releaseSelectionStatus: ObservableMap; + deleteRelease: (release: HelmRelease) => Promise; +} + +export interface RemovableHelmRelease extends HelmRelease { + toggle: () => void; + isSelected: boolean; + delete: () => Promise; +} + +export const removableReleases = ({ + releases, + releaseSelectionStatus, + deleteRelease, +}: Dependencies) => { + const isSelected = (release: HelmRelease) => + releaseSelectionStatus.get(release.getId()) || false; + + return computed(() => + releases.value.get().map( + (release): RemovableHelmRelease => ({ + ...release, + + toggle: () => { + releaseSelectionStatus.set(release.getId(), !isSelected(release)); + }, + + get isSelected() { + return isSelected(release); + }, + + delete: async () => { + await deleteRelease(release); + }, + }), + ), + ); +}; diff --git a/src/renderer/components/+helm-releases/rollback-dialog/close.injectable.ts b/src/renderer/components/+helm-releases/rollback-dialog/close.injectable.ts new file mode 100644 index 0000000000..6c45727925 --- /dev/null +++ b/src/renderer/components/+helm-releases/rollback-dialog/close.injectable.ts @@ -0,0 +1,25 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { bind } from "../../../utils"; +import type { HelmReleaseScaleDialogState } from "./state.injectable"; +import helmReleaseRollbackDialogStateInjectable from "./state.injectable"; + +interface Dependencies { + helmreleaseScaleDialogState: HelmReleaseScaleDialogState; +} + +function closeHelmReleaseScaleDialog({ helmreleaseScaleDialogState }: Dependencies): void { + helmreleaseScaleDialogState.helmRelease = null; +} + +const closeHelmReleaseRollbackDialogInjectable = getInjectable({ + instantiate: (di) => bind(closeHelmReleaseScaleDialog, null, { + helmreleaseScaleDialogState: di.inject(helmReleaseRollbackDialogStateInjectable), + }), + lifecycle: lifecycleEnum.singleton, +}); + +export default closeHelmReleaseRollbackDialogInjectable; diff --git a/src/renderer/components/+helm-releases/rollback-dialog/open.injectable.ts b/src/renderer/components/+helm-releases/rollback-dialog/open.injectable.ts new file mode 100644 index 0000000000..6c824bb1e7 --- /dev/null +++ b/src/renderer/components/+helm-releases/rollback-dialog/open.injectable.ts @@ -0,0 +1,26 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import type { HelmRelease } from "../../../../common/k8s-api/endpoints"; +import { bind } from "../../../utils"; +import type { HelmReleaseScaleDialogState } from "./state.injectable"; +import helmReleaseRollbackDialogStateInjectable from "./state.injectable"; + +interface Dependencies { + helmreleaseScaleDialogState: HelmReleaseScaleDialogState; +} + +function openHelmReleaseScaleDialog({ helmreleaseScaleDialogState }: Dependencies, helmrelease: HelmRelease): void { + helmreleaseScaleDialogState.helmRelease = helmrelease; +} + +const openHelmReleaseRollbackDialogInjectable = getInjectable({ + instantiate: (di) => bind(openHelmReleaseScaleDialog, null, { + helmreleaseScaleDialogState: di.inject(helmReleaseRollbackDialogStateInjectable), + }), + lifecycle: lifecycleEnum.singleton, +}); + +export default openHelmReleaseRollbackDialogInjectable; diff --git a/src/renderer/components/+apps-releases/release-rollback-dialog.scss b/src/renderer/components/+helm-releases/rollback-dialog/rollback-dialog.scss similarity index 100% rename from src/renderer/components/+apps-releases/release-rollback-dialog.scss rename to src/renderer/components/+helm-releases/rollback-dialog/rollback-dialog.scss diff --git a/src/renderer/components/+helm-releases/rollback-dialog/rollback-dialog.tsx b/src/renderer/components/+helm-releases/rollback-dialog/rollback-dialog.tsx new file mode 100644 index 0000000000..0a20070fd4 --- /dev/null +++ b/src/renderer/components/+helm-releases/rollback-dialog/rollback-dialog.tsx @@ -0,0 +1,101 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import "./rollback-dialog.scss"; + +import React, { useState } from "react"; +import { observer } from "mobx-react"; +import { Dialog, DialogProps } from "../../dialog"; +import { Wizard, WizardStep } from "../../wizard"; +import { getReleaseHistory, HelmRelease, IReleaseRevision } from "../../../../common/k8s-api/endpoints/helm-release.api"; +import { Select, SelectOption } from "../../select"; +import { Notifications } from "../../notifications"; +import orderBy from "lodash/orderBy"; +import { withInjectables } from "@ogre-tools/injectable-react"; +import helmReleaseRollbackDialogStateInjectable from "./state.injectable"; +import closeHelmReleaseRollbackDialogInjectable from "./close.injectable"; +import rollbackReleaseInjectable from "../rollback-release.injectable"; + +export interface ReleaseRollbackDialogProps extends Omit { +} + +interface Dependencies { + helmRelease: HelmRelease | null; + closeReleaseRollbackDialog: () => void; + rollbackRelease: (releaseName: string, namespace: string, revisionNumber: number) => Promise; +} + +const NonInjectedReleaseRollbackDialog = observer(({ helmRelease, closeReleaseRollbackDialog, rollbackRelease, className, ...dialogProps }: Dependencies & ReleaseRollbackDialogProps) => { + const [isLoading, setIsLoading] = useState(false); + const [revision, setRevision] = useState(undefined); + const [revisions, setRevisions] = useState([]); + const isOpen = Boolean(helmRelease); + + const onOpen = async () => { + setIsLoading(true); + + const revisions = orderBy(await getReleaseHistory(helmRelease.getName(), helmRelease.getNs()), "revision", "desc"); + + setRevisions(revisions); + setRevision(revisions[0]); + setIsLoading(false); + }; + const rollback = async () => { + try { + await rollbackRelease(helmRelease.getName(), helmRelease.getNs(), helmRelease.getRevision()); + closeReleaseRollbackDialog(); + } catch (err) { + Notifications.error(err); + } + }; + const renderContent = () => { + if (!revision) { + return

No revisions to rollback.

; + } + + return ( +
+ Revision + + + + + ); +}); + +export const AddNamespaceDialog = withInjectables(NonInjectedAddNamespaceDialog, { + getProps: (di, props) => ({ + namespaceStore: di.inject(namespaceStoreInjectable), + state: di.inject(addNamespaceDialogStateInjectable), + closeAddNamespaceDialog: di.inject(closeAddNamespaceDialogInjectable), + ...props, + }), +}); diff --git a/src/renderer/components/+namespaces/add-namespace-dialog-model/add-namespace-dialog-model.ts b/src/renderer/components/+namespaces/add-namespace-dialog-model/add-namespace-dialog-model.ts deleted file mode 100644 index 439a9b8ef9..0000000000 --- a/src/renderer/components/+namespaces/add-namespace-dialog-model/add-namespace-dialog-model.ts +++ /dev/null @@ -1,25 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ -import { observable, makeObservable, action } from "mobx"; - -export class AddNamespaceDialogModel { - isOpen = false; - - constructor() { - makeObservable(this, { - isOpen: observable, - open: action, - close: action, - }); - } - - open = () => { - this.isOpen = true; - }; - - close = () => { - this.isOpen = false; - }; -} diff --git a/src/renderer/components/+namespaces/add-namespace-dialog.tsx b/src/renderer/components/+namespaces/add-namespace-dialog.tsx deleted file mode 100644 index bec242e538..0000000000 --- a/src/renderer/components/+namespaces/add-namespace-dialog.tsx +++ /dev/null @@ -1,106 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import "./add-namespace-dialog.scss"; - -import React from "react"; -import { observable, makeObservable } from "mobx"; -import { observer } from "mobx-react"; -import { Dialog, DialogProps } from "../dialog"; -import { Wizard, WizardStep } from "../wizard"; -import type { Namespace } from "../../../common/k8s-api/endpoints"; -import { Input } from "../input"; -import { systemName } from "../input/input_validators"; -import { Notifications } from "../notifications"; -import { withInjectables } from "@ogre-tools/injectable-react"; -import namespaceStoreInjectable from "./namespace-store/namespace-store.injectable"; -import type { AddNamespaceDialogModel } from "./add-namespace-dialog-model/add-namespace-dialog-model"; -import addNamespaceDialogModelInjectable - from "./add-namespace-dialog-model/add-namespace-dialog-model.injectable"; - -interface Props extends DialogProps { - onSuccess?(ns: Namespace): void; - onError?(error: any): void; -} - -interface Dependencies { - createNamespace: (params: { name: string }) => Promise, - model: AddNamespaceDialogModel -} - -@observer -class NonInjectedAddNamespaceDialog extends React.Component { - @observable namespace = ""; - - constructor(props: Props & Dependencies) { - super(props); - makeObservable(this); - } - - reset = () => { - this.namespace = ""; - }; - - addNamespace = async () => { - const { namespace } = this; - const { onSuccess, onError } = this.props; - - try { - const created = await this.props.createNamespace({ name: namespace }); - - onSuccess?.(created); - this.props.model.close(); - } catch (err) { - Notifications.error(err); - onError?.(err); - } - }; - - render() { - const { model, createNamespace, ...dialogProps } = this.props; - const { namespace } = this; - const header =
Create Namespace
; - - return ( - - - - this.namespace = v.toLowerCase()} - /> - - - - ); - } -} - -export const AddNamespaceDialog = withInjectables( - NonInjectedAddNamespaceDialog, - - { - getProps: (di, props) => ({ - createNamespace: di.inject(namespaceStoreInjectable).create, - model: di.inject(addNamespaceDialogModelInjectable), - - ...props, - }), - }, -); diff --git a/src/renderer/components/+namespaces/filter-storage.injectable.ts b/src/renderer/components/+namespaces/filter-storage.injectable.ts new file mode 100644 index 0000000000..9186bd0edc --- /dev/null +++ b/src/renderer/components/+namespaces/filter-storage.injectable.ts @@ -0,0 +1,19 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import type { StorageLayer } from "../../utils"; +import createStorageInjectable from "../../utils/create-storage/create-storage.injectable"; + +let storage: StorageLayer; + +const namespaceSelectFilterStorageInjectable = getInjectable({ + setup: async (di) => { + storage = await di.inject(createStorageInjectable)("selected_namespaces", undefined); + }, + instantiate: () => storage, + lifecycle: lifecycleEnum.singleton, +}); + +export default namespaceSelectFilterStorageInjectable; diff --git a/src/renderer/components/+namespaces/filter-store.injectable.ts b/src/renderer/components/+namespaces/filter-store.injectable.ts new file mode 100644 index 0000000000..fb7520f4f2 --- /dev/null +++ b/src/renderer/components/+namespaces/filter-store.injectable.ts @@ -0,0 +1,25 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { computed } from "mobx"; +import currentClusterInjectable from "../current-cluster.injectable"; +import namespaceSelectFilterStorageInjectable from "./filter-storage.injectable"; +import { NamespaceSelectFilterManager } from "./filter-store"; +import namespacesInjectable from "./namespaces.injectable"; + +const namespaceFilterStoreInjectable = getInjectable({ + instantiate: (di) => { + const cluster = di.inject(currentClusterInjectable); + + return new NamespaceSelectFilterManager({ + storage: di.inject(namespaceSelectFilterStorageInjectable), + namespaces: di.inject(namespacesInjectable), + accessibleNamespaces: computed(() => [...(cluster.get()?.accessibleNamespaces ?? [])]), + }); + }, + lifecycle: lifecycleEnum.singleton, +}); + +export default namespaceFilterStoreInjectable; diff --git a/src/renderer/components/+namespaces/namespace-store/namespace.store.ts b/src/renderer/components/+namespaces/filter-store.ts similarity index 66% rename from src/renderer/components/+namespaces/namespace-store/namespace.store.ts rename to src/renderer/components/+namespaces/filter-store.ts index 00cfd6748a..2b34d5c67f 100644 --- a/src/renderer/components/+namespaces/namespace-store/namespace.store.ts +++ b/src/renderer/components/+namespaces/filter-store.ts @@ -3,32 +3,18 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import { action, comparer, computed, IReactionDisposer, makeObservable, reaction } from "mobx"; -import { autoBind, noop, StorageHelper, ToggleSet } from "../../../utils"; -import { KubeObjectStore, KubeObjectStoreLoadingParams } from "../../../../common/k8s-api/kube-object.store"; -import { Namespace, namespacesApi } from "../../../../common/k8s-api/endpoints/namespaces.api"; +import { action, comparer, computed, IComputedValue, IReactionDisposer, reaction } from "mobx"; +import { StorageLayer, ToggleSet } from "../../utils"; -interface Dependencies { - storage: StorageHelper +export interface NamespaceSelectFilterManagerDependencies { + storage: StorageLayer; + namespaces: IComputedValue; + accessibleNamespaces: IComputedValue; } -export class NamespaceStore extends KubeObjectStore { - api = namespacesApi; - - constructor(private dependencies: Dependencies) { - super(); - makeObservable(this); - autoBind(this); - - this.init(); - } - - private async init() { - await this.contextReady; - await this.dependencies.storage.whenReady; - +export class NamespaceSelectFilterManager { + constructor(protected readonly dependencies: NamespaceSelectFilterManagerDependencies) { this.selectNamespaces(this.initialNamespaces); - this.autoLoadAllowedNamespaces(); } public onContextChange(callback: (namespaces: string[]) => void, opts: { fireImmediately?: boolean } = {}): IReactionDisposer { @@ -38,13 +24,6 @@ export class NamespaceStore extends KubeObjectStore { }); } - private autoLoadAllowedNamespaces(): IReactionDisposer { - return reaction(() => this.allowedNamespaces, namespaces => this.loadAll({ namespaces }), { - fireImmediately: true, - equals: comparer.shallow, - }); - } - private get initialNamespaces(): string[] { const { allowedNamespaces } = this; const selectedNamespaces = this.dependencies.storage.get(); // raw namespaces, undefined on first load @@ -73,10 +52,13 @@ export class NamespaceStore extends KubeObjectStore { } @computed get allowedNamespaces(): string[] { - return Array.from(new Set([ - ...(this.context?.allNamespaces ?? []), // allowed namespaces from cluster (main), updating every 30s - ...this.items.map(item => item.getName()), // loaded namespaces from k8s api - ].flat())); + const accessibleNamespaces = this.dependencies.accessibleNamespaces.get(); + + if (accessibleNamespaces.length > 0) { + return accessibleNamespaces; + } + + return this.dependencies.namespaces.get(); } /** @@ -107,37 +89,12 @@ export class NamespaceStore extends KubeObjectStore { return this.selectedNamespaces.length === 0; } - subscribe() { - /** - * if user has given static list of namespaces let's not start watches - * because watch adds stuff that's not wanted or will just fail - */ - if (this.context?.cluster.accessibleNamespaces.length > 0) { - return noop; - } - - return super.subscribe(); - } - - protected async loadItems(params: KubeObjectStoreLoadingParams): Promise { - const { allowedNamespaces } = this; - - let namespaces = await super.loadItems(params).catch(() => []); - - namespaces = namespaces.filter(namespace => allowedNamespaces.includes(namespace.getName())); - - if (!namespaces.length && allowedNamespaces.length > 0) { - return allowedNamespaces.map(getDummyNamespace); - } - - return namespaces; - } - - @action selectNamespaces = (namespace: string | string[]) => { + @action + selectNamespaces(namespace: string | string[]) { const namespaces = Array.from(new Set([namespace].flat())); this.dependencies.storage.set(namespaces); - }; + } @action clearSelected(namespaces?: string | string[]) { @@ -224,23 +181,4 @@ export class NamespaceStore extends KubeObjectStore { void selectAll; this.selectAll(); } - - @action - async remove(item: Namespace) { - await super.remove(item); - this.clearSelected(item.getName()); - } -} - -export function getDummyNamespace(name: string) { - return new Namespace({ - kind: Namespace.kind, - apiVersion: "v1", - metadata: { - name, - uid: "", - resourceVersion: "", - selfLink: `/api/v1/namespaces/${name}`, - }, - }); } diff --git a/src/renderer/components/+namespaces/index.ts b/src/renderer/components/+namespaces/index.ts index ce705883ea..da52c1eb72 100644 --- a/src/renderer/components/+namespaces/index.ts +++ b/src/renderer/components/+namespaces/index.ts @@ -5,4 +5,4 @@ export * from "./namespaces"; export * from "./namespace-details"; -export * from "./add-namespace-dialog"; +export * from "./add-dialog"; diff --git a/src/renderer/components/+namespaces/namespaces-mixins.scss b/src/renderer/components/+namespaces/mixins.scss similarity index 99% rename from src/renderer/components/+namespaces/namespaces-mixins.scss rename to src/renderer/components/+namespaces/mixins.scss index 8909da6662..ac0e462839 100644 --- a/src/renderer/components/+namespaces/namespaces-mixins.scss +++ b/src/renderer/components/+namespaces/mixins.scss @@ -3,7 +3,6 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ - @mixin namespaceStatus { &.active { color: var(--colorOk); diff --git a/src/renderer/components/+namespaces/namespace-details.tsx b/src/renderer/components/+namespaces/namespace-details.tsx index a630322a54..10c94aad75 100644 --- a/src/renderer/components/+namespaces/namespace-details.tsx +++ b/src/renderer/components/+namespaces/namespace-details.tsx @@ -5,141 +5,112 @@ import "./namespace-details.scss"; -import React from "react"; -import { computed, makeObservable, observable, reaction } from "mobx"; -import { disposeOnUnmount, observer } from "mobx-react"; +import React, { useEffect, useState } from "react"; +import { observer } from "mobx-react"; import { DrawerItem } from "../drawer"; -import { boundMethod, cssNames, Disposer } from "../../utils"; +import { cssNames } from "../../utils"; import { getMetricsForNamespace, IPodMetrics, Namespace } from "../../../common/k8s-api/endpoints"; import type { KubeObjectDetailsProps } from "../kube-object-details"; import { Link } from "react-router-dom"; import { Spinner } from "../spinner"; -import { resourceQuotaStore } from "../+config-resource-quotas/resource-quotas.store"; +import type { ResourceQuotaStore } from "../+resource-quotas/store"; import { KubeObjectMeta } from "../kube-object-meta"; -import { limitRangeStore } from "../+config-limit-ranges/limit-ranges.store"; +import type { LimitRangeStore } from "../+limit-ranges/store"; import { ResourceMetrics } from "../resource-metrics"; -import { PodCharts, podMetricTabs } from "../+workloads-pods/pod-charts"; +import { PodCharts, podMetricTabs } from "../+pods/charts"; import { ClusterMetricsResourceType } from "../../../common/cluster-types"; -import { getActiveClusterEntity } from "../../api/catalog-entity-registry"; import { getDetailsUrl } from "../kube-detail-params"; import logger from "../../../common/logger"; -import type { KubeObjectStore } from "../../../common/k8s-api/kube-object.store"; -import type { KubeObject } from "../../../common/k8s-api/kube-object"; import { withInjectables } from "@ogre-tools/injectable-react"; -import kubeWatchApiInjectable - from "../../kube-watch-api/kube-watch-api.injectable"; +import resourceQuotaStoreInjectable from "../+resource-quotas/store.injectable"; +import limitRangeStoreInjectable from "../+limit-ranges/store.injectable"; +import isMetricHiddenInjectable from "../../utils/is-metrics-hidden.injectable"; +import type { KubeWatchApi } from "../../kube-watch-api/kube-watch-api"; +import kubeWatchApiInjectable from "../../kube-watch-api/kube-watch-api.injectable"; -interface Props extends KubeObjectDetailsProps { +export interface NamespaceDetailsProps extends KubeObjectDetailsProps { } interface Dependencies { - subscribeStores: (stores: KubeObjectStore[]) => Disposer + resourceQuotaStore: ResourceQuotaStore; + limitRangeStore: LimitRangeStore; + isMetricHidden: boolean; + kubeWatchApi: KubeWatchApi; } -@observer -class NonInjectedNamespaceDetails extends React.Component { - @observable metrics: IPodMetrics = null; +const NonInjectedNamespaceDetails = observer(({ kubeWatchApi, isMetricHidden, resourceQuotaStore, limitRangeStore, object: namespace }: Dependencies & NamespaceDetailsProps) => { + const [metrics, setMetrics] = useState(null); - constructor(props: Props & Dependencies) { - super(props); - makeObservable(this); + if (!namespace) { + return null; } - componentDidMount() { - disposeOnUnmount(this, [ - reaction(() => this.props.object, () => { - this.metrics = null; - }), + if (!(namespace instanceof Namespace)) { + logger.error("[NamespaceDetails]: passed object that is not an instanceof Namespace", namespace); - this.props.subscribeStores([ - resourceQuotaStore, - limitRangeStore, - ]), - ]); + return null; } - @computed get quotas() { - const namespace = this.props.object.getName(); + useEffect(() => setMetrics(null), [namespace]); + useEffect(() => kubeWatchApi.subscribeStores([ + resourceQuotaStore, + limitRangeStore, + ]), []); - return resourceQuotaStore.getAllByNs(namespace); - } + const loadMetrics = async () => { + setMetrics(await getMetricsForNamespace(namespace.getName(), "")); + }; - @computed get limitranges() { - const namespace = this.props.object.getName(); + const quotas = resourceQuotaStore.getAllByNs(namespace.getName()); + const limitRanges = limitRangeStore.getAllByNs(namespace.getName()); + const status = namespace.getStatus(); - return limitRangeStore.getAllByNs(namespace); - } + return ( +
+ {!isMetricHidden && ( + + + + )} + - @boundMethod - async loadMetrics() { - this.metrics = await getMetricsForNamespace(this.props.object.getName(), ""); - } + + {status} + - render() { - const { object: namespace } = this.props; + + {resourceQuotaStore.isLoading && } + {quotas.map(quota => ( + + {quota.getName()} + + ))} + + + {limitRangeStore.isLoading && } + {limitRanges.map(limitRange => ( + + {limitRange.getName()} + + ))} + +
+ ); +}); - if (!namespace) { - return null; - } - - if (!(namespace instanceof Namespace)) { - logger.error("[NamespaceDetails]: passed object that is not an instanceof Namespace", namespace); - - return null; - } - - const status = namespace.getStatus(); - const isMetricHidden = getActiveClusterEntity()?.isMetricHidden(ClusterMetricsResourceType.Namespace); - - return ( -
- {!isMetricHidden && ( - - - - )} - - - - {status} - - - - {!this.quotas && resourceQuotaStore.isLoading && } - {this.quotas.map(quota => { - return ( - - {quota.getName()} - - ); - })} - - - {!this.limitranges && limitRangeStore.isLoading && } - {this.limitranges.map(limitrange => { - return ( - - {limitrange.getName()} - - ); - })} - -
- ); - } -} - -export const NamespaceDetails = withInjectables( - NonInjectedNamespaceDetails, - - { - getProps: (di, props) => ({ - subscribeStores: di.inject(kubeWatchApiInjectable).subscribeStores, - ...props, +export const NamespaceDetails = withInjectables(NonInjectedNamespaceDetails, { + getProps: (di, props) => ({ + resourceQuotaStore: di.inject(resourceQuotaStoreInjectable), + limitRangeStore: di.inject(limitRangeStoreInjectable), + isMetricHidden: di.inject(isMetricHiddenInjectable, { + metricType: ClusterMetricsResourceType.Namespace, }), - }, -); - + kubeWatchApi: di.inject(kubeWatchApiInjectable), + ...props, + }), +}); diff --git a/src/renderer/components/+namespaces/namespace-select-filter-model/namespace-select-filter-model.injectable.ts b/src/renderer/components/+namespaces/namespace-select-filter-model/namespace-select-filter-model.injectable.ts deleted file mode 100644 index c1ca1a15af..0000000000 --- a/src/renderer/components/+namespaces/namespace-select-filter-model/namespace-select-filter-model.injectable.ts +++ /dev/null @@ -1,17 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ -import { NamespaceSelectFilterModel } from "./namespace-select-filter-model"; -import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; -import namespaceStoreInjectable from "../namespace-store/namespace-store.injectable"; - -const NamespaceSelectFilterModelInjectable = getInjectable({ - instantiate: (di) => new NamespaceSelectFilterModel({ - namespaceStore: di.inject(namespaceStoreInjectable), - }), - - lifecycle: lifecycleEnum.singleton, -}); - -export default NamespaceSelectFilterModelInjectable; diff --git a/src/renderer/components/+namespaces/namespace-select-filter-model/namespace-select-filter-model.ts b/src/renderer/components/+namespaces/namespace-select-filter-model/namespace-select-filter-model.ts deleted file mode 100644 index dfdbd987ea..0000000000 --- a/src/renderer/components/+namespaces/namespace-select-filter-model/namespace-select-filter-model.ts +++ /dev/null @@ -1,95 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ -import { observable, makeObservable, action, untracked } from "mobx"; -import type { NamespaceStore } from "../namespace-store/namespace.store"; -import type { SelectOption } from "../../select"; -import { isMac } from "../../../../common/vars"; - -interface Dependencies { - namespaceStore: NamespaceStore; -} - -export class NamespaceSelectFilterModel { - constructor(private dependencies: Dependencies) { - makeObservable(this, { - menuIsOpen: observable, - closeMenu: action, - openMenu: action, - reset: action, - }); - } - - menuIsOpen = false; - - closeMenu = () => { - this.menuIsOpen = false; - }; - - openMenu = () => { - this.menuIsOpen = true; - }; - - get selectedNames() { - return untracked(() => this.dependencies.namespaceStore.selectedNames); - } - - isSelected = (namespace: string | string[]) => - this.dependencies.namespaceStore.hasContext(namespace); - - selectSingle = (namespace: string) => { - this.dependencies.namespaceStore.selectSingle(namespace); - }; - - selectAll = () => { - this.dependencies.namespaceStore.selectAll(); - }; - - onChange = ([{ value: namespace }]: SelectOption[]) => { - if (namespace) { - if (this.isMultiSelection) { - this.dependencies.namespaceStore.toggleSingle(namespace); - } else { - this.dependencies.namespaceStore.selectSingle(namespace); - } - } else { - this.dependencies.namespaceStore.selectAll(); - } - }; - - onClick = () => { - if (!this.menuIsOpen) { - this.openMenu(); - } else if (!this.isMultiSelection) { - this.closeMenu(); - } - }; - - private isMultiSelection = false; - - onKeyDown = (event: React.KeyboardEvent) => { - if (isSelectionKey(event)) { - this.isMultiSelection = true; - } - }; - - onKeyUp = (event: React.KeyboardEvent) => { - if (isSelectionKey(event)) { - this.isMultiSelection = false; - } - }; - - reset = () => { - this.isMultiSelection = false; - this.closeMenu(); - }; -} - -const isSelectionKey = (event: React.KeyboardEvent): boolean => { - if (isMac) { - return event.key === "Meta"; - } - - return event.key === "Control"; // windows or linux -}; diff --git a/src/renderer/components/+namespaces/namespace-select-filter.tsx b/src/renderer/components/+namespaces/namespace-select-filter.tsx index 2968ad7f2e..2f23b5092c 100644 --- a/src/renderer/components/+namespaces/namespace-select-filter.tsx +++ b/src/renderer/components/+namespaces/namespace-select-filter.tsx @@ -5,123 +5,158 @@ import "./namespace-select-filter.scss"; -import React from "react"; +import React, { useEffect, useState } from "react"; import { observer } from "mobx-react"; import { components, PlaceholderProps } from "react-select"; - +import { observable } from "mobx"; import { Icon } from "../icon"; import { NamespaceSelect } from "./namespace-select"; -import type { NamespaceStore } from "./namespace-store/namespace.store"; - import type { SelectOption, SelectProps } from "../select"; +import { isMac } from "../../../common/vars"; import { withInjectables } from "@ogre-tools/injectable-react"; -import type { NamespaceSelectFilterModel } from "./namespace-select-filter-model/namespace-select-filter-model"; -import namespaceSelectFilterModelInjectable from "./namespace-select-filter-model/namespace-select-filter-model.injectable"; -import namespaceStoreInjectable from "./namespace-store/namespace-store.injectable"; +import namespaceFilterStoreInjectable from "./filter-store.injectable"; +import type { NamespaceSelectFilterManager } from "./filter-store"; interface Dependencies { - model: NamespaceSelectFilterModel; + namespaceSelectStore: NamespaceSelectFilterManager; } -class NonInjectedNamespaceSelectFilter extends React.Component< - SelectProps & Dependencies -> { - render() { - return ( -
- - +this.props.model.selectedNames.has(right.value) - - +this.props.model.selectedNames.has(left.value) - } - /> -
- ); +const NonInjectedPlaceholder = observer(({ namespaceSelectStore, ...props }: Dependencies & PlaceholderProps) => { + const getPlaceholder = (): React.ReactNode => { + const namespaces = namespaceSelectStore.contextNamespaces; + + if (namespaceSelectStore.areAllSelectedImplicitly || !namespaces.length) { + return <>All namespaces; + } + + if (namespaces.length === 1) { + return <>Namespace: {namespaces[0]}; + } + + return <>Namespaces: {namespaces.join(", ")}; + }; + + return ( + + {getPlaceholder()} + + ); +}); + +export const Placeholder = withInjectables>(NonInjectedPlaceholder, { + getProps: (di, props) => ({ + namespaceSelectStore: di.inject(namespaceFilterStoreInjectable), + ...props, + }), +}); + +function isSelectionKey(e: React.KeyboardEvent): boolean { + if (isMac) { + return e.key === "Meta"; } + + // windows or linux + return e.key === "Control"; } -const formatOptionLabelFor = - (model: NamespaceSelectFilterModel) => - ({ value: namespace, label }: SelectOption) => { - if (namespace) { - const isSelected = model.isSelected(namespace); +export interface NamespaceSelectFilterProps extends SelectProps { - return ( -
- - {namespace} - {isSelected && } -
- ); - } - - return label; - }; - -export const NamespaceSelectFilter = withInjectables( - observer(NonInjectedNamespaceSelectFilter), - - { - getProps: (di, props) => ({ - model: di.inject(namespaceSelectFilterModelInjectable), - ...props, - }), - }, -); - -type CustomPlaceholderProps = PlaceholderProps; - -interface PlaceholderDependencies { - namespaceStore: NamespaceStore; } -const NonInjectedPlaceholder = observer( - ({ namespaceStore, ...props }: CustomPlaceholderProps & PlaceholderDependencies) => { - const getPlaceholder = (): React.ReactNode => { - const namespaces = namespaceStore.contextNamespaces; +interface Dependencies { + namespaceSelectStore: NamespaceSelectFilterManager; +} - if (namespaceStore.areAllSelectedImplicitly || !namespaces.length) { - return <>All namespaces; +const NonInjectedNamespaceSelectFilter = observer(({ namespaceStore }: Dependencies & NamespaceSelectFilterProps) => { + const [selected] = useState(observable.set()); + const [didToggle, setDidToggle] = useState(false); + const [isOpen, setIsOpen] = useState(false); + const [isMultiSelect, setIsMultiSelect] = useState(false); + + useEffect(() => { + if (isOpen) { + selected.replace(namespaceStore.selectedNames); + setDidToggle(false); + } + }, [isOpen]); + + const formatOptionLabel = ({ value: namespace, label }: SelectOption) => { + if (namespace) { + const isSelected = namespaceStore.hasContext(namespace); + + return ( +
+ + {namespace} + {isSelected && } +
+ ); + } + + return label; + }; + const onChange = ([{ value: namespace }]: SelectOption[]) => { + if (namespace) { + if (isMultiSelect) { + setDidToggle(true); + namespaceStore.toggleSingle(namespace); + } else { + namespaceStore.selectSingle(namespace); } + } else { + namespaceStore.selectAll(); + } + }; + const onKeyDown = (e: React.KeyboardEvent) => { + if (isSelectionKey(e)) { + setIsMultiSelect(true); + } + }; + const onKeyUp = (e: React.KeyboardEvent) => { + if (isSelectionKey(e)) { + setIsMultiSelect(false); + } - if (namespaces.length === 1) { - return <>Namespace: {namespaces[0]}; - } + if (!isMultiSelect && didToggle) { + setIsOpen(false); + } + }; + const onClick = () => { + if (!isOpen) { + setIsOpen(true); + } else if (!isMultiSelect) { + setIsOpen(!isOpen); + } + }; + const reset = () => { + setIsMultiSelect(false); + setIsOpen(false); + }; - return <>Namespaces: {namespaces.join(", ")}; - }; + return ( +
+ +selected.has(right.value) - +selected.has(left.value)} + /> +
+ ); +}); - return ( - - {getPlaceholder()} - - ); - }, -); - -const Placeholder = withInjectables( - NonInjectedPlaceholder, - - { - getProps: (di, props) => ({ - namespaceStore: di.inject(namespaceStoreInjectable), - ...props, - }), - }, -); +export const NamespaceSelectFilter = withInjectables(NonInjectedNamespaceSelectFilter, { + getProps: (di, props) => ({ + namespaceSelectStore: di.inject(namespaceFilterStoreInjectable), + ...props, + }), +}); diff --git a/src/renderer/components/+namespaces/namespace-select.tsx b/src/renderer/components/+namespaces/namespace-select.tsx index 5c09e0ae76..08234d0ad3 100644 --- a/src/renderer/components/+namespaces/namespace-select.tsx +++ b/src/renderer/components/+namespaces/namespace-select.tsx @@ -6,44 +6,28 @@ import "./namespace-select.scss"; import React from "react"; -import { computed, makeObservable } from "mobx"; import { observer } from "mobx-react"; import { Select, SelectOption, SelectProps } from "../select"; import { cssNames } from "../../utils"; import { Icon } from "../icon"; -import type { NamespaceStore } from "./namespace-store/namespace.store"; +import type { NamespaceStore } from "./store"; import { withInjectables } from "@ogre-tools/injectable-react"; -import namespaceStoreInjectable from "./namespace-store/namespace-store.injectable"; +import namespaceStoreInjectable from "./store.injectable"; -interface Props extends SelectProps { +export interface NamespaceSelectProps extends SelectProps { showIcons?: boolean; sort?: (a: SelectOption, b: SelectOption) => number; showAllNamespacesOption?: boolean; // show "All namespaces" option on the top (default: false) customizeOptions?(options: SelectOption[]): SelectOption[]; } -const defaultProps: Partial = { - showIcons: true, -}; - interface Dependencies { - namespaceStore: NamespaceStore + namespaceStore: NamespaceStore; } -@observer -class NonInjectedNamespaceSelect extends React.Component { - static defaultProps = defaultProps as object; - - constructor(props: Props & Dependencies) { - super(props); - makeObservable(this); - } - - // No subscribe here because the subscribe is in (the cluster frame root component) - - @computed.struct get options(): SelectOption[] { - const { customizeOptions, showAllNamespacesOption, sort } = this.props; - let options: SelectOption[] = this.props.namespaceStore.items.map(ns => ({ value: ns.getName() })); +const NonInjectedNamespaceSelect = observer(({ namespaceStore, showIcons = true, sort, showAllNamespacesOption, customizeOptions, className, ...selectProps }: Dependencies & NamespaceSelectProps) => { + const options = (() => { + let options: SelectOption[] = namespaceStore.items.map(ns => ({ value: ns.getName() })); if (sort) { options.sort(sort); @@ -58,43 +42,31 @@ class NonInjectedNamespaceSelect extends React.Component { } return options; - } + })(); - formatOptionLabel = (option: SelectOption) => { - const { showIcons } = this.props; - const { value, label } = option; - - return label || ( + const formatOptionLabel = ({ value, label }: SelectOption) => ( + label || ( <> {showIcons && } {value} - ); - }; + ) + ); - render() { - const { className, showIcons, customizeOptions, components = {}, namespaceStore, ...selectProps } = this.props; + return ( + - ); - } -} - -export const NamespaceSelect = withInjectables( - NonInjectedNamespaceSelect, - - { - getProps: (di, props) => ({ - namespaceStore: di.inject(namespaceStoreInjectable), - ...props, - }), - }, -); +export const NamespaceSelect = withInjectables(NonInjectedNamespaceSelect, { + getProps: (di, props) => ({ + namespaceStore: di.inject(namespaceStoreInjectable), + ...props, + }), +}); diff --git a/src/renderer/components/+namespaces/namespace-store/namespace-store.injectable.ts b/src/renderer/components/+namespaces/namespace-store/namespace-store.injectable.ts deleted file mode 100644 index d72a2263b5..0000000000 --- a/src/renderer/components/+namespaces/namespace-store/namespace-store.injectable.ts +++ /dev/null @@ -1,33 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ -import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; -import { NamespaceStore } from "./namespace.store"; -import apiManagerInjectable from "../../kube-object-menu/dependencies/api-manager.injectable"; -import createStorageInjectable from "../../../utils/create-storage/create-storage.injectable"; - -const namespaceStoreInjectable = getInjectable({ - instantiate: (di) => { - const createStorage = di.inject(createStorageInjectable); - - const storage = createStorage( - "selected_namespaces", - undefined, - ); - - const namespaceStore = new NamespaceStore({ - storage, - }); - - const apiManager = di.inject(apiManagerInjectable); - - apiManager.registerStore(namespaceStore); - - return namespaceStore; - }, - - lifecycle: lifecycleEnum.singleton, -}); - -export default namespaceStoreInjectable; diff --git a/src/renderer/components/+namespaces/namespaces.injectable.ts b/src/renderer/components/+namespaces/namespaces.injectable.ts new file mode 100644 index 0000000000..eb68ff11cf --- /dev/null +++ b/src/renderer/components/+namespaces/namespaces.injectable.ts @@ -0,0 +1,18 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { computed } from "mobx"; +import namespaceStoreInjectable from "./store.injectable"; + +const namespacesInjectable = getInjectable({ + instantiate: (di) => { + const store = di.inject(namespaceStoreInjectable); + + return computed(() => store.items.map(ns => ns.getName())); + }, + lifecycle: lifecycleEnum.singleton, +}); + +export default namespacesInjectable; diff --git a/src/renderer/components/+namespaces/namespaces.tsx b/src/renderer/components/+namespaces/namespaces.tsx index f997613c17..4c7f0a3d03 100644 --- a/src/renderer/components/+namespaces/namespaces.tsx +++ b/src/renderer/components/+namespaces/namespaces.tsx @@ -7,18 +7,17 @@ import "./namespaces.scss"; import React from "react"; import { NamespaceStatus } from "../../../common/k8s-api/endpoints"; -import { AddNamespaceDialog } from "./add-namespace-dialog"; -import { TabLayout } from "../layout/tab-layout"; +import { AddNamespaceDialog } from "./add-dialog"; import { Badge } from "../badge"; import type { RouteComponentProps } from "react-router"; import { KubeObjectListLayout } from "../kube-object-list-layout"; -import type { NamespaceStore } from "./namespace-store/namespace.store"; import { KubeObjectStatusIcon } from "../kube-object-status-icon"; import type { NamespacesRouteParams } from "../../../common/routes"; +import type { NamespaceStore } from "./store"; import { withInjectables } from "@ogre-tools/injectable-react"; -import namespaceStoreInjectable from "./namespace-store/namespace-store.injectable"; -import addNamespaceDialogModelInjectable - from "./add-namespace-dialog-model/add-namespace-dialog-model.injectable"; +import namespaceStoreInjectable from "./store.injectable"; +import { observer } from "mobx-react"; +import openAddNamespaceDialogInjectable from "./add-dialog-open.injectable"; enum columnId { name = "name", @@ -27,70 +26,63 @@ enum columnId { status = "status", } -interface Props extends RouteComponentProps { +export interface NamespacesProps extends RouteComponentProps { } interface Dependencies { - namespaceStore: NamespaceStore - openAddNamespaceDialog: () => void + namespaceStore: NamespaceStore; + openAddNamespaceDialog: () => void; } -class NonInjectedNamespaces extends React.Component { - render() { - return ( - - ns.getName(), - [columnId.labels]: ns => ns.getLabels(), - [columnId.age]: ns => ns.getTimeDiffFromNow(), - [columnId.status]: ns => ns.getStatus(), - }} - searchFilters={[ - item => item.getSearchFields(), - item => item.getStatus(), - ]} - renderHeaderTitle="Namespaces" - renderTableHeader={[ - { title: "Name", className: "name", sortBy: columnId.name, id: columnId.name }, - { className: "warning", showWithColumn: columnId.name }, - { title: "Labels", className: "labels scrollable", sortBy: columnId.labels, id: columnId.labels }, - { title: "Age", className: "age", sortBy: columnId.age, id: columnId.age }, - { title: "Status", className: "status", sortBy: columnId.status, id: columnId.status }, - ]} - renderTableContents={item => [ - item.getName(), - , - item.getLabels().map(label => ), - item.getAge(), - { title: item.getStatus(), className: item.getStatus().toLowerCase() }, - ]} - addRemoveButtons={{ - addTooltip: "Add Namespace", - onAdd: () => this.props.openAddNamespaceDialog(), - }} - customizeTableRowProps={item => ({ - disabled: item.getStatus() === NamespaceStatus.TERMINATING, - })} - /> - - - ); - } -} +const NonInjectedNamespaces = observer(({ namespaceStore, openAddNamespaceDialog }: Dependencies & NamespacesProps) => ( + <> + ns.getName(), + [columnId.labels]: ns => ns.getLabels(), + [columnId.age]: ns => ns.getTimeDiffFromNow(), + [columnId.status]: ns => ns.getStatus(), + }} + searchFilters={[ + item => item.getSearchFields(), + item => item.getStatus(), + ]} + renderHeaderTitle="Namespaces" + renderTableHeader={[ + { title: "Name", className: "name", sortBy: columnId.name, id: columnId.name }, + { className: "warning", showWithColumn: columnId.name }, + { title: "Labels", className: "labels scrollable", sortBy: columnId.labels, id: columnId.labels }, + { title: "Age", className: "age", sortBy: columnId.age, id: columnId.age }, + { title: "Status", className: "status", sortBy: columnId.status, id: columnId.status }, + ]} + renderTableContents={item => [ + item.getName(), + , + item.getLabels().map(label => ), + item.getAge(), + { title: item.getStatus(), className: item.getStatus().toLowerCase() }, + ]} + addRemoveButtons={{ + addTooltip: "Add Namespace", + onAdd: openAddNamespaceDialog, + }} + customizeTableRowProps={item => ({ + disabled: item.getStatus() === NamespaceStatus.TERMINATING, + })} + /> + + +)); -export const Namespaces = withInjectables( - NonInjectedNamespaces, +export const Namespaces = withInjectables(NonInjectedNamespaces, { + getProps: (di, props) => ({ + namespaceStore: di.inject(namespaceStoreInjectable), + openAddNamespaceDialog: di.inject(openAddNamespaceDialogInjectable), + ...props, + }), +}); - { - getProps: (di, props) => ({ - namespaceStore: di.inject(namespaceStoreInjectable), - openAddNamespaceDialog: di.inject(addNamespaceDialogModelInjectable).open, - ...props, - }), - }, -); diff --git a/src/renderer/components/+namespaces/select-single-namespace.injectable.ts b/src/renderer/components/+namespaces/select-single-namespace.injectable.ts new file mode 100644 index 0000000000..3d300c97c2 --- /dev/null +++ b/src/renderer/components/+namespaces/select-single-namespace.injectable.ts @@ -0,0 +1,13 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import namespaceFilterStoreInjectable from "./filter-store.injectable"; + +const selectSingleNamespaceInjectable = getInjectable({ + instantiate: (di) => di.inject(namespaceFilterStoreInjectable).selectSingle, + lifecycle: lifecycleEnum.singleton, +}); + +export default selectSingleNamespaceInjectable; diff --git a/src/renderer/components/+namespaces/selected-namespaces.injectable.ts b/src/renderer/components/+namespaces/selected-namespaces.injectable.ts new file mode 100644 index 0000000000..df5fcb04df --- /dev/null +++ b/src/renderer/components/+namespaces/selected-namespaces.injectable.ts @@ -0,0 +1,18 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { computed } from "mobx"; +import namespaceFilterStoreInjectable from "./filter-store.injectable"; + +const selectedNamespacesInjectable = getInjectable({ + instantiate: (di) => { + const store = di.inject(namespaceFilterStoreInjectable); + + return computed(() => [...store.contextNamespaces]); + }, + lifecycle: lifecycleEnum.singleton, +}); + +export default selectedNamespacesInjectable; diff --git a/src/renderer/components/+namespaces/store.injectable.ts b/src/renderer/components/+namespaces/store.injectable.ts new file mode 100644 index 0000000000..50a7f0bdf3 --- /dev/null +++ b/src/renderer/components/+namespaces/store.injectable.ts @@ -0,0 +1,14 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import apiManagerInjectable from "../../../common/k8s-api/api-manager.injectable"; +import type { NamespaceStore } from "./store"; + +const namespaceStoreInjectable = getInjectable({ + instantiate: (di) => di.inject(apiManagerInjectable).getStore("/api/v1/namespaces") as NamespaceStore, + lifecycle: lifecycleEnum.singleton, +}); + +export default namespaceStoreInjectable; diff --git a/src/renderer/components/+namespaces/store.ts b/src/renderer/components/+namespaces/store.ts new file mode 100644 index 0000000000..c9c1db37fd --- /dev/null +++ b/src/renderer/components/+namespaces/store.ts @@ -0,0 +1,50 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { makeObservable } from "mobx"; +import { autoBind, noop } from "../../utils"; +import { KubeObjectStore, KubeObjectStoreLoadingParams } from "../../../common/k8s-api/kube-object.store"; +import { Namespace, NamespaceApi } from "../../../common/k8s-api/endpoints/namespace.api"; + +export class NamespaceStore extends KubeObjectStore { + constructor(public readonly api:NamespaceApi) { + super(); + makeObservable(this); + autoBind(this); + } + + subscribe() { + /** + * if user has given static list of namespaces let's not start watches + * because watch adds stuff that's not wanted or will just fail + */ + if (this.context?.cluster.accessibleNamespaces.length > 0) { + return noop; + } + + return super.subscribe(); + } + + protected loadItems(params: KubeObjectStoreLoadingParams): Promise { + if (this.context?.cluster?.accessibleNamespaces.length > 0) { + return Promise.resolve(this.context.cluster.accessibleNamespaces.map(getDummyNamespace)); + } + + return super.loadItems(params); + } +} + +export function getDummyNamespace(name: string) { + return new Namespace({ + kind: Namespace.kind, + apiVersion: "v1", + metadata: { + name, + uid: "", + resourceVersion: "", + selfLink: `/api/v1/namespaces/${name}`, + }, + }); +} diff --git a/src/renderer/components/+network-endpoints/endpoint-details.tsx b/src/renderer/components/+network-endpoints/endpoint-details.tsx deleted file mode 100644 index 797ce59a91..0000000000 --- a/src/renderer/components/+network-endpoints/endpoint-details.tsx +++ /dev/null @@ -1,47 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import "./endpoint-details.scss"; - -import React from "react"; -import { observer } from "mobx-react"; -import { DrawerTitle } from "../drawer"; -import type { KubeObjectDetailsProps } from "../kube-object-details"; -import { Endpoint } from "../../../common/k8s-api/endpoints"; -import { KubeObjectMeta } from "../kube-object-meta"; -import { EndpointSubsetList } from "./endpoint-subset-list"; -import logger from "../../../common/logger"; - -interface Props extends KubeObjectDetailsProps { -} - -@observer -export class EndpointDetails extends React.Component { - render() { - const { object: endpoint } = this.props; - - if (!endpoint) { - return null; - } - - if (!(endpoint instanceof Endpoint)) { - logger.error("[EndpointDetails]: passed object that is not an instanceof Endpoint", endpoint); - - return null; - } - - return ( -
- - - { - endpoint.getEndpointSubsets().map((subset) => ( - - )) - } -
- ); - } -} diff --git a/src/renderer/components/+network-endpoints/endpoint-subset-list.tsx b/src/renderer/components/+network-endpoints/endpoint-subset-list.tsx deleted file mode 100644 index 53c0a98b37..0000000000 --- a/src/renderer/components/+network-endpoints/endpoint-subset-list.tsx +++ /dev/null @@ -1,169 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import "./endpoint-subset-list.scss"; - -import React from "react"; -import { observer } from "mobx-react"; -import { EndpointSubset, Endpoint, EndpointAddress } from "../../../common/k8s-api/endpoints"; -import { Table, TableCell, TableHead, TableRow } from "../table"; -import { boundMethod } from "../../utils"; -import { apiManager } from "../../../common/k8s-api/api-manager"; -import { Link } from "react-router-dom"; -import { getDetailsUrl } from "../kube-detail-params"; - -interface Props { - subset: EndpointSubset; - endpoint: Endpoint; -} - -@observer -export class EndpointSubsetList extends React.Component { - - getAddressTableRow(ip: string) { - const { subset } = this.props; - const address = subset.getAddresses().find(address => address.getId() == ip); - - return this.renderAddressTableRow(address); - } - - @boundMethod - getNotReadyAddressTableRow(ip: string) { - const { subset } = this.props; - const address = subset.getNotReadyAddresses().find(address => address.getId() == ip); - - return this.renderAddressTableRow(address); - } - - @boundMethod - renderAddressTable(addresses: EndpointAddress[], virtual: boolean) { - return ( -
-
Addresses
- - - IP - Hostname - Target - - { - !virtual && addresses.map(address => this.getAddressTableRow(address.getId())) - } -
-
- ); - } - - @boundMethod - renderAddressTableRow(address: EndpointAddress) { - const { endpoint } = this.props; - - return ( - - {address.ip} - {address.hostname} - - { address.targetRef && ( - - {address.targetRef.name} - - )} - - - ); - } - - render() { - const { subset } = this.props; - const addresses = subset.getAddresses(); - const notReadyAddresses = subset.getNotReadyAddresses(); - const addressesVirtual = addresses.length > 100; - const notReadyAddressesVirtual = notReadyAddresses.length > 100; - - return( -
- {addresses.length > 0 && ( -
-
Addresses
- - - IP - Hostname - Target - - { !addressesVirtual && addresses.map(address => this.getAddressTableRow(address.getId())) } -
-
- )} - - {notReadyAddresses.length > 0 && ( -
-
Not Ready Addresses
- - - IP - Hostname - Target - - { !notReadyAddressesVirtual && notReadyAddresses.map(address => this.getNotReadyAddressTableRow(address.getId())) } -
-
- )} - -
Ports
- - - Port - Name - Protocol - - { - subset.ports.map(port => { - return ( - - {port.port} - {port.name} - {port.protocol} - - ); - }) - } -
-
- ); - } -} diff --git a/src/renderer/components/+network-endpoints/endpoints.tsx b/src/renderer/components/+network-endpoints/endpoints.tsx deleted file mode 100644 index 0af44470c8..0000000000 --- a/src/renderer/components/+network-endpoints/endpoints.tsx +++ /dev/null @@ -1,67 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import "./endpoints.scss"; - -import React from "react"; -import { observer } from "mobx-react"; -import type { RouteComponentProps } from "react-router-dom"; -import { endpointStore } from "./endpoints.store"; -import { KubeObjectListLayout } from "../kube-object-list-layout"; -import { KubeObjectStatusIcon } from "../kube-object-status-icon"; -import type { EndpointRouteParams } from "../../../common/routes"; - -enum columnId { - name = "name", - namespace = "namespace", - endpoints = "endpoints", - age = "age", -} - -interface Props extends RouteComponentProps { -} - -@observer -export class Endpoints extends React.Component { - render() { - return ( - endpoint.getName(), - [columnId.namespace]: endpoint => endpoint.getNs(), - [columnId.age]: endpoint => endpoint.getTimeDiffFromNow(), - }} - searchFilters={[ - endpoint => endpoint.getSearchFields(), - ]} - renderHeaderTitle="Endpoints" - renderTableHeader={[ - { title: "Name", className: "name", sortBy: columnId.name, id: columnId.name }, - { className: "warning", showWithColumn: columnId.name }, - { title: "Namespace", className: "namespace", sortBy: columnId.namespace, id: columnId.namespace }, - { title: "Endpoints", className: "endpoints", id: columnId.endpoints }, - { title: "Age", className: "age", sortBy: columnId.age, id: columnId.age }, - ]} - renderTableContents={endpoint => [ - endpoint.getName(), - , - endpoint.getNs(), - endpoint.toString(), - endpoint.getAge(), - ]} - tableProps={{ - customRowHeights: (item, lineHeight, paddings) => { - const lines = item.getEndpointSubsets().length || 1; - - return lines * lineHeight + paddings; - }, - }} - /> - ); - } -} diff --git a/src/renderer/components/+network-ingresses/ingress-details.tsx b/src/renderer/components/+network-ingresses/ingress-details.tsx deleted file mode 100644 index 0e13a90ddf..0000000000 --- a/src/renderer/components/+network-ingresses/ingress-details.tsx +++ /dev/null @@ -1,171 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import "./ingress-details.scss"; - -import React from "react"; -import { disposeOnUnmount, observer } from "mobx-react"; -import { makeObservable, observable, reaction } from "mobx"; -import { DrawerItem, DrawerTitle } from "../drawer"; -import { ILoadBalancerIngress, Ingress } from "../../../common/k8s-api/endpoints"; -import { Table, TableCell, TableHead, TableRow } from "../table"; -import { ResourceMetrics } from "../resource-metrics"; -import type { KubeObjectDetailsProps } from "../kube-object-details"; -import { IngressCharts } from "./ingress-charts"; -import { KubeObjectMeta } from "../kube-object-meta"; -import { getBackendServiceNamePort, getMetricsForIngress, IIngressMetrics } from "../../../common/k8s-api/endpoints/ingress.api"; -import { getActiveClusterEntity } from "../../api/catalog-entity-registry"; -import { ClusterMetricsResourceType } from "../../../common/cluster-types"; -import { boundMethod } from "../../utils"; -import logger from "../../../common/logger"; - -interface Props extends KubeObjectDetailsProps { -} - -@observer -export class IngressDetails extends React.Component { - @observable metrics: IIngressMetrics = null; - - constructor(props: Props) { - super(props); - makeObservable(this); - } - - componentDidMount() { - disposeOnUnmount(this, [ - reaction(() => this.props.object, () => { - this.metrics = null; - }), - ]); - } - - @boundMethod - async loadMetrics() { - const { object: ingress } = this.props; - - this.metrics = await getMetricsForIngress(ingress.getName(), ingress.getNs()); - } - - renderPaths(ingress: Ingress) { - const { spec: { rules }} = ingress; - - if (!rules || !rules.length) return null; - - return rules.map((rule, index) => { - return ( -
- {rule.host && ( -
- <>Host: {rule.host} -
- )} - {rule.http && ( - - - Path - Backends - - { - rule.http.paths.map((path, index) => { - const { serviceName, servicePort } = getBackendServiceNamePort(path.backend); - const backend = `${serviceName}:${servicePort}`; - - return ( - - {path.path || ""} - -

{backend}

-
-
- ); - }) - } -
- )} -
- ); - }); - } - - renderIngressPoints(ingressPoints: ILoadBalancerIngress[]) { - if (!ingressPoints || ingressPoints.length === 0) return null; - - return ( -
- - - Hostname - IP - - {ingressPoints.map(({ hostname, ip }, index) => { - return ( - - {hostname ? hostname : "-"} - {ip ? ip : "-"} - - ); - }) - }) -
-
- ); - } - - render() { - const { object: ingress } = this.props; - - if (!ingress) { - return null; - } - - if (!(ingress instanceof Ingress)) { - logger.error("[IngressDetails]: passed object that is not an instanceof Ingress", ingress); - - return null; - } - - const { spec, status } = ingress; - const ingressPoints = status?.loadBalancer?.ingress; - const { metrics } = this; - const metricTabs = [ - "Network", - "Duration", - ]; - const isMetricHidden = getActiveClusterEntity()?.isMetricHidden(ClusterMetricsResourceType.Ingress); - const { serviceName, servicePort } = ingress.getServiceNamePort(); - - return ( -
- {!isMetricHidden && ( - - - - )} - - - {ingress.getPorts()} - - {spec.tls && - - {spec.tls.map((tls, index) =>

{tls.secretName}

)} -
- } - {serviceName && servicePort && - - {serviceName}:{servicePort} - - } - - {this.renderPaths(ingress)} - - - {this.renderIngressPoints(ingressPoints)} -
- ); - } -} diff --git a/src/renderer/components/+network-ingresses/ingresses.tsx b/src/renderer/components/+network-ingresses/ingresses.tsx deleted file mode 100644 index c6b41d920d..0000000000 --- a/src/renderer/components/+network-ingresses/ingresses.tsx +++ /dev/null @@ -1,71 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import "./ingresses.scss"; - -import React from "react"; -import { observer } from "mobx-react"; -import type { RouteComponentProps } from "react-router-dom"; -import { ingressStore } from "./ingress.store"; -import { KubeObjectListLayout } from "../kube-object-list-layout"; -import { KubeObjectStatusIcon } from "../kube-object-status-icon"; -import type { IngressRouteParams } from "../../../common/routes"; - -enum columnId { - name = "name", - namespace = "namespace", - loadBalancers ="load-balancers", - rules = "rules", - age = "age", -} - -interface Props extends RouteComponentProps { -} - -@observer -export class Ingresses extends React.Component { - render() { - return ( - ingress.getName(), - [columnId.namespace]: ingress => ingress.getNs(), - [columnId.age]: ingress => ingress.getTimeDiffFromNow(), - }} - searchFilters={[ - ingress => ingress.getSearchFields(), - ingress => ingress.getPorts(), - ]} - renderHeaderTitle="Ingresses" - renderTableHeader={[ - { title: "Name", className: "name", sortBy: columnId.name, id: columnId.name }, - { className: "warning", showWithColumn: columnId.name }, - { title: "Namespace", className: "namespace", sortBy: columnId.namespace, id: columnId.namespace }, - { title: "LoadBalancers", className: "loadbalancers", id: columnId.loadBalancers }, - { title: "Rules", className: "rules", id: columnId.rules }, - { title: "Age", className: "age", sortBy: columnId.age, id: columnId.age }, - ]} - renderTableContents={ingress => [ - ingress.getName(), - , - ingress.getNs(), - ingress.getLoadBalancers().map(lb =>

{lb}

), - ingress.getRoutes().map(route =>

{route}

), - ingress.getAge(), - ]} - tableProps={{ - customRowHeights: (item, lineHeight, paddings) => { - const lines = item.getRoutes().length || 1; - - return lines * lineHeight + paddings; - }, - }} - /> - ); - } -} diff --git a/src/renderer/components/+network-policies/__tests__/network-policy-details.test.tsx b/src/renderer/components/+network-policies/__tests__/network-policy-details.test.tsx index 2724dfa68c..f8ca945de4 100644 --- a/src/renderer/components/+network-policies/__tests__/network-policy-details.test.tsx +++ b/src/renderer/components/+network-policies/__tests__/network-policy-details.test.tsx @@ -4,50 +4,99 @@ */ import React from "react"; -import { findByTestId, findByText, render } from "@testing-library/react"; -import { NetworkPolicy, NetworkPolicySpec } from "../../../../common/k8s-api/endpoints"; -import { NetworkPolicyDetails } from "../network-policy-details"; - -jest.mock("../../kube-object-meta"); +import { findByTestId, findByText } from "@testing-library/react"; +import { NetworkPolicy } from "../../../../common/k8s-api/endpoints"; +import { NetworkPolicyDetails } from "../details"; +import type { ConfigurableDependencyInjectionContainer } from "@ogre-tools/injectable"; +import { getDiForUnitTesting } from "../../../getDiForUnitTesting"; +import { type DiRender, renderFor } from "../../test-utils/renderFor"; +import lookupApiLinkInjectable from "../../../../common/k8s-api/lookup-api-link.injectable"; +import localeTimezoneInjectable from "../../locale-date/locale-timezone.injectable"; +import getStatusItemsForKubeObjectInjectable from "../../kube-object-status-icon/status-items-for-object.injectable"; +import { computed } from "mobx"; describe("NetworkPolicyDetails", () => { + let render: DiRender; + let di: ConfigurableDependencyInjectionContainer; + + beforeEach(() => { + di = getDiForUnitTesting(); + render = renderFor(di); + di.override(lookupApiLinkInjectable, () => () => ""); + di.override(localeTimezoneInjectable, () => computed(() => "Europe/Helsinki")); + di.override(getStatusItemsForKubeObjectInjectable, () => () => []); + }); + it("should render w/o errors", () => { - const policy = new NetworkPolicy({ metadata: {} as any, spec: {}} as any); + const policy = new NetworkPolicy({ + kind: NetworkPolicy.kind, + apiVersion: "networking.k8s.io/v1", + metadata: { + name: "foobar", + resourceVersion: "1", + creationTimestamp: "", + selfLink: "", + uid: "2", + }, + spec: { + podSelector: {}, + }, + }); const { container } = render(); expect(container).toBeInstanceOf(HTMLElement); }); it("should render egress nodeSelector", async () => { - const spec: NetworkPolicySpec = { - egress: [{ - to: [{ - namespaceSelector: { - matchLabels: { - foo: "bar", + const policy = new NetworkPolicy({ + kind: NetworkPolicy.kind, + apiVersion: "networking.k8s.io/v1", + metadata: { + creationTimestamp: "", + name: "foobar", + resourceVersion: "1", + selfLink: "", + uid: "2", + }, + spec: { + egress: [{ + to: [{ + namespaceSelector: { + matchLabels: { + foo: "bar", + }, }, - }, + }], }], - }], - podSelector: {}, - }; - const policy = new NetworkPolicy({ metadata: {} as any, spec } as any); + podSelector: {}, + }, + }); const { container } = render(); expect(await findByTestId(container, "egress-0")).toBeInstanceOf(HTMLElement); expect(await findByText(container, "foo: bar")).toBeInstanceOf(HTMLElement); }); - it("should not crash if egress nodeSelector doesn't have matchLabels", async () => { - const spec: NetworkPolicySpec = { - egress: [{ - to: [{ - namespaceSelector: {}, + it("should not crash if egress nodeSelector doesn't have matchLabels", () => { + const policy = new NetworkPolicy({ + kind: NetworkPolicy.kind, + apiVersion: "networking.k8s.io/v1", + metadata: { + creationTimestamp: "", + name: "foobar", + resourceVersion: "1", + selfLink: "", + uid: "2", + }, + spec: { + egress: [{ + to: [{ + namespaceSelector: {}, + }], }], - }], - podSelector: {}, - }; - const policy = new NetworkPolicy({ metadata: {} as any, spec } as any); + podSelector: {}, + }, + }); const { container } = render(); expect(container).toBeInstanceOf(HTMLElement); diff --git a/src/renderer/components/+network-policies/network-policy-details.module.scss b/src/renderer/components/+network-policies/details.module.scss similarity index 100% rename from src/renderer/components/+network-policies/network-policy-details.module.scss rename to src/renderer/components/+network-policies/details.module.scss diff --git a/src/renderer/components/+network-policies/details.tsx b/src/renderer/components/+network-policies/details.tsx new file mode 100644 index 0000000000..5bcec0b4a1 --- /dev/null +++ b/src/renderer/components/+network-policies/details.tsx @@ -0,0 +1,203 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import styles from "./details.module.scss"; + +import React from "react"; +import { DrawerItem, DrawerTitle } from "../drawer"; +import { IPolicyIpBlock, NetworkPolicy, NetworkPolicyPeer, NetworkPolicyPort } from "../../../common/k8s-api/endpoints/network-policy.api"; +import { Badge } from "../badge"; +import { SubTitle } from "../layout/sub-title"; +import { observer } from "mobx-react"; +import type { KubeObjectDetailsProps } from "../kube-object-details"; +import { KubeObjectMeta } from "../kube-object-meta"; +import logger from "../../../common/logger"; +import type { LabelMatchExpression, LabelSelector } from "../../../common/k8s-api/kube-object"; +import { isEmpty } from "lodash"; +import { withInjectables } from "@ogre-tools/injectable-react"; + +export interface NetworkPolicyDetailsProps extends KubeObjectDetailsProps { +} + +interface Dependencies { + +} + +function renderIPolicyIpBlock(ipBlock: IPolicyIpBlock | undefined) { + if (!ipBlock) { + return null; + } + + const { cidr, except = [] } = ipBlock; + + if (!cidr) { + return null; + } + + const items = [`cidr: ${cidr}`]; + + if (except.length > 0) { + items.push(`except: ${except.join(", ")}`); + } + + return ( + + {items.join(", ")} + + ); +} + +function renderMatchLabels(matchLabels: Record | undefined) { + if (!matchLabels) { + return null; + } + + return Object.entries(matchLabels) + .map(([key, value]) =>
  • {key}: {value}
  • ); +} + +function renderMatchExpressions(matchExpressions: LabelMatchExpression[] | undefined) { + if (!matchExpressions) { + return null; + } + + return matchExpressions.map(expr => { + switch (expr.operator) { + case "DoesNotExist": + case "Exists": + return
  • {expr.key} ({expr.operator})
  • ; + case "In": + case "NotIn": + return ( +
  • + {expr.key}({expr.operator}) +
      + {expr.values.map((value, index) =>
    • {value}
    • )} +
    +
  • + ); + } + }); +} + +function renderIPolicySelector(name: string, selector: LabelSelector | undefined) { + if (!selector) { + return null; + } + + const { matchLabels, matchExpressions } = selector; + + return ( + +
      + {renderMatchLabels(matchLabels)} + {renderMatchExpressions(matchExpressions)} + { + (isEmpty(matchLabels) && isEmpty(matchExpressions)) && ( +
    • (empty)
    • + ) + } +
    +
    + ); +} + +function renderNetworkPolicyPeers(name: string, peers: NetworkPolicyPeer[] | undefined) { + if (!peers) { + return null; + } + + return ( + <> + + { + peers.map((peer, index) => ( +
    + {renderIPolicyIpBlock(peer.ipBlock)} + {renderIPolicySelector("namespaceSelector", peer.namespaceSelector)} + {renderIPolicySelector("podSelector", peer.podSelector)} +
    + )) + } + + ); +} + +function renderNetworkPolicyPorts(ports: NetworkPolicyPort[] | undefined) { + if (!ports) { + return null; + } + + return ( + +
      + {ports.map(({ protocol = "TCP", port = "", endPort }, index) => ( +
    • + {protocol}:{port}{typeof endPort === "number" && `:${endPort}`} +
    • + ))} +
    +
    + ); +} + +const NonInjectedNetworkPolicyDetails = observer(({ object: networkPolicy }: Dependencies & NetworkPolicyDetailsProps) => { + if (!networkPolicy) { + return null; + } + + if (!(networkPolicy instanceof NetworkPolicy)) { + logger.error("[NetworkPolicyDetails]: passed object that is not an instanceof NetworkPolicy", networkPolicy); + + return null; + } + + const { ingress, egress } = networkPolicy.spec; + const selector = networkPolicy.getMatchLabels(); + + return ( +
    + + + 0}> + { + selector.length > 0 + ? networkPolicy.getMatchLabels().map(label => ) + : `(empty) (Allowing the specific traffic to all pods in this namespace)` + } + + + {ingress && ( + <> + + {ingress.map((ingress, i) => ( +
    + {renderNetworkPolicyPorts(ingress.ports)} + {renderNetworkPolicyPeers("From", ingress.from)} +
    + ))} + + )} + + {egress && ( + <> + + {egress.map((egress, i) => ( +
    + {renderNetworkPolicyPorts(egress.ports)} + {renderNetworkPolicyPeers("To", egress.to)} +
    + ))} + + )} +
    + ); +}); + +export const NetworkPolicyDetails = withInjectables(NonInjectedNetworkPolicyDetails, { + getProps: (di, props) => ({ + ...props, + }), +}); diff --git a/src/renderer/components/+network-policies/index.ts b/src/renderer/components/+network-policies/index.ts index 2e33a9440b..0a771627e2 100644 --- a/src/renderer/components/+network-policies/index.ts +++ b/src/renderer/components/+network-policies/index.ts @@ -4,4 +4,4 @@ */ export * from "./network-policies"; -export * from "./network-policy-details"; +export * from "./details"; diff --git a/src/renderer/components/+network-policies/network-policies.tsx b/src/renderer/components/+network-policies/network-policies.tsx index 79f012452f..653192fc0c 100644 --- a/src/renderer/components/+network-policies/network-policies.tsx +++ b/src/renderer/components/+network-policies/network-policies.tsx @@ -9,9 +9,11 @@ import React from "react"; import { observer } from "mobx-react"; import type { RouteComponentProps } from "react-router-dom"; import { KubeObjectListLayout } from "../kube-object-list-layout"; -import { networkPolicyStore } from "./network-policy.store"; +import type { NetworkPolicyStore } from "./store"; import { KubeObjectStatusIcon } from "../kube-object-status-icon"; import type { NetworkPoliciesRouteParams } from "../../../common/routes"; +import { withInjectables } from "@ogre-tools/injectable-react"; +import networkPolicyStoreInjectable from "./store.injectable"; enum columnId { name = "name", @@ -20,41 +22,49 @@ enum columnId { age = "age", } -interface Props extends RouteComponentProps { +export interface NetworkPoliciesProps extends RouteComponentProps { } -@observer -export class NetworkPolicies extends React.Component { - render() { - return ( - item.getName(), - [columnId.namespace]: item => item.getNs(), - [columnId.age]: item => item.getTimeDiffFromNow(), - }} - searchFilters={[ - item => item.getSearchFields(), - ]} - renderHeaderTitle="Network Policies" - renderTableHeader={[ - { title: "Name", className: "name", sortBy: columnId.name, id: columnId.name }, - { className: "warning", showWithColumn: columnId.name }, - { title: "Namespace", className: "namespace", sortBy: columnId.namespace, id: columnId.namespace }, - { title: "Policy Types", className: "type", id: columnId.types }, - { title: "Age", className: "age", sortBy: columnId.age, id: columnId.age }, - ]} - renderTableContents={item => [ - item.getName(), - , - item.getNs(), - item.getTypes().join(", "), - item.getAge(), - ]} - /> - ); - } +interface Dependencies { + networkPolicyStore: NetworkPolicyStore; } + +const NonInjectedNetworkPolicies = observer(({ networkPolicyStore }: Dependencies & NetworkPoliciesProps) => ( + item.getName(), + [columnId.namespace]: item => item.getNs(), + [columnId.age]: item => item.getTimeDiffFromNow(), + }} + searchFilters={[ + item => item.getSearchFields(), + ]} + renderHeaderTitle="Network Policies" + renderTableHeader={[ + { title: "Name", className: "name", sortBy: columnId.name, id: columnId.name }, + { className: "warning", showWithColumn: columnId.name }, + { title: "Namespace", className: "namespace", sortBy: columnId.namespace, id: columnId.namespace }, + { title: "Policy Types", className: "type", id: columnId.types }, + { title: "Age", className: "age", sortBy: columnId.age, id: columnId.age }, + ]} + renderTableContents={item => [ + item.getName(), + , + item.getNs(), + item.getTypes().join(", "), + item.getAge(), + ]} + /> +)); + +export const NetworkPolicies = withInjectables(NonInjectedNetworkPolicies, { + getProps: (di, props) => ({ + networkPolicyStore: di.inject(networkPolicyStoreInjectable), + ...props, + }), +}); + diff --git a/src/renderer/components/+network-policies/network-policy-details.tsx b/src/renderer/components/+network-policies/network-policy-details.tsx deleted file mode 100644 index d343707557..0000000000 --- a/src/renderer/components/+network-policies/network-policy-details.tsx +++ /dev/null @@ -1,197 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import styles from "./network-policy-details.module.scss"; - -import React from "react"; -import { DrawerItem, DrawerTitle } from "../drawer"; -import { IPolicyIpBlock, NetworkPolicy, NetworkPolicyPeer, NetworkPolicyPort } from "../../../common/k8s-api/endpoints/network-policy.api"; -import { Badge } from "../badge"; -import { SubTitle } from "../layout/sub-title"; -import { observer } from "mobx-react"; -import type { KubeObjectDetailsProps } from "../kube-object-details"; -import { KubeObjectMeta } from "../kube-object-meta"; -import logger from "../../../common/logger"; -import type { LabelMatchExpression, LabelSelector } from "../../../common/k8s-api/kube-object"; -import { isEmpty } from "lodash"; - -interface Props extends KubeObjectDetailsProps { -} - -@observer -export class NetworkPolicyDetails extends React.Component { - renderIPolicyIpBlock(ipBlock: IPolicyIpBlock | undefined) { - if (!ipBlock) { - return null; - } - - const { cidr, except = [] } = ipBlock; - - if (!cidr) { - return null; - } - - const items = [`cidr: ${cidr}`]; - - if (except.length > 0) { - items.push(`except: ${except.join(", ")}`); - } - - return ( - - {items.join(", ")} - - ); - } - - renderMatchLabels(matchLabels: Record | undefined) { - if (!matchLabels) { - return null; - } - - return Object.entries(matchLabels) - .map(([key, value]) =>
  • {key}: {value}
  • ); - } - - renderMatchExpressions(matchExpressions: LabelMatchExpression[] | undefined) { - if (!matchExpressions) { - return null; - } - - return matchExpressions.map(expr => { - switch (expr.operator) { - case "DoesNotExist": - case "Exists": - return
  • {expr.key} ({expr.operator})
  • ; - case "In": - case "NotIn": - return ( -
  • - {expr.key}({expr.operator}) -
      - {expr.values.map((value, index) =>
    • {value}
    • )} -
    -
  • - ); - } - }); - } - - renderIPolicySelector(name: string, selector: LabelSelector | undefined) { - if (!selector) { - return null; - } - - const { matchLabels, matchExpressions } = selector; - - return ( - -
      - {this.renderMatchLabels(matchLabels)} - {this.renderMatchExpressions(matchExpressions)} - { - (isEmpty(matchLabels) && isEmpty(matchExpressions)) && ( -
    • (empty)
    • - ) - } -
    -
    - ); - } - - renderNetworkPolicyPeers(name: string, peers: NetworkPolicyPeer[] | undefined) { - if (!peers) { - return null; - } - - return ( - <> - - { - peers.map((peer, index) => ( -
    - {this.renderIPolicyIpBlock(peer.ipBlock)} - {this.renderIPolicySelector("namespaceSelector", peer.namespaceSelector)} - {this.renderIPolicySelector("podSelector", peer.podSelector)} -
    - )) - } - - ); - } - - renderNetworkPolicyPorts(ports: NetworkPolicyPort[] | undefined) { - if (!ports) { - return null; - } - - return ( - -
      - {ports.map(({ protocol = "TCP", port = "", endPort }, index) => ( -
    • - {protocol}:{port}{typeof endPort === "number" && `:${endPort}`} -
    • - ))} -
    -
    - ); - } - - render() { - const { object: policy } = this.props; - - if (!policy) { - return null; - } - - if (!(policy instanceof NetworkPolicy)) { - logger.error("[NetworkPolicyDetails]: passed object that is not an instanceof NetworkPolicy", policy); - - return null; - } - - const { ingress, egress } = policy.spec; - const selector = policy.getMatchLabels(); - - return ( -
    - - - 0}> - { - selector.length > 0 - ? policy.getMatchLabels().map(label => ) - : `(empty) (Allowing the specific traffic to all pods in this namespace)` - } - - - {ingress && ( - <> - - {ingress.map((ingress, i) => ( -
    - {this.renderNetworkPolicyPorts(ingress.ports)} - {this.renderNetworkPolicyPeers("From", ingress.from)} -
    - ))} - - )} - - {egress && ( - <> - - {egress.map((egress, i) => ( -
    - {this.renderNetworkPolicyPorts(egress.ports)} - {this.renderNetworkPolicyPeers("To", egress.to)} -
    - ))} - - )} -
    - ); - } -} diff --git a/src/renderer/components/+network-policies/network-policy.store.ts b/src/renderer/components/+network-policies/network-policy.store.ts deleted file mode 100644 index f3ccf874a6..0000000000 --- a/src/renderer/components/+network-policies/network-policy.store.ts +++ /dev/null @@ -1,15 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import { KubeObjectStore } from "../../../common/k8s-api/kube-object.store"; -import { NetworkPolicy, networkPolicyApi } from "../../../common/k8s-api/endpoints/network-policy.api"; -import { apiManager } from "../../../common/k8s-api/api-manager"; - -export class NetworkPolicyStore extends KubeObjectStore { - api = networkPolicyApi; -} - -export const networkPolicyStore = new NetworkPolicyStore(); -apiManager.registerStore(networkPolicyStore); diff --git a/src/renderer/components/+network-policies/store.injectable.ts b/src/renderer/components/+network-policies/store.injectable.ts new file mode 100644 index 0000000000..82c5fc22ef --- /dev/null +++ b/src/renderer/components/+network-policies/store.injectable.ts @@ -0,0 +1,14 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import apiManagerInjectable from "../../../common/k8s-api/api-manager.injectable"; +import type { NetworkPolicyStore } from "./store"; + +const networkPolicyStoreInjectable = getInjectable({ + instantiate: (di) => di.inject(apiManagerInjectable).getStore("/apis/networking.k8s.io/v1/networkpolicies") as NetworkPolicyStore, + lifecycle: lifecycleEnum.singleton, +}); + +export default networkPolicyStoreInjectable; diff --git a/src/renderer/components/+network-policies/store.ts b/src/renderer/components/+network-policies/store.ts new file mode 100644 index 0000000000..16560abcc6 --- /dev/null +++ b/src/renderer/components/+network-policies/store.ts @@ -0,0 +1,13 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { KubeObjectStore } from "../../../common/k8s-api/kube-object.store"; +import type { NetworkPolicy, NetworkPolicyApi } from "../../../common/k8s-api/endpoints/network-policy.api"; + +export class NetworkPolicyStore extends KubeObjectStore { + constructor(public readonly api:NetworkPolicyApi) { + super(); + } +} diff --git a/src/renderer/components/+network-port-forwards/port-forward-menu.tsx b/src/renderer/components/+network-port-forwards/port-forward-menu.tsx deleted file mode 100644 index 41f195ee06..0000000000 --- a/src/renderer/components/+network-port-forwards/port-forward-menu.tsx +++ /dev/null @@ -1,122 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import React from "react"; -import { boundMethod, cssNames } from "../../utils"; -import { openPortForward, PortForwardItem, PortForwardStore } from "../../port-forward"; -import { MenuActions, MenuActionsProps } from "../menu/menu-actions"; -import { MenuItem } from "../menu"; -import { Icon } from "../icon"; -import { Notifications } from "../notifications"; -import { withInjectables } from "@ogre-tools/injectable-react"; -import portForwardDialogModelInjectable from "../../port-forward/port-forward-dialog-model/port-forward-dialog-model.injectable"; -import portForwardStoreInjectable from "../../port-forward/port-forward-store/port-forward-store.injectable"; - -interface Props extends MenuActionsProps { - portForward: PortForwardItem; - hideDetails?(): void; -} - -interface Dependencies { - portForwardStore: PortForwardStore, - openPortForwardDialog: (item: PortForwardItem) => void -} - -class NonInjectedPortForwardMenu extends React.Component { - @boundMethod - remove() { - const { portForward } = this.props; - - try { - this.portForwardStore.remove(portForward); - } catch (error) { - Notifications.error(`Error occurred stopping the port-forward from port ${portForward.forwardPort}. The port-forward may still be active.`); - } - } - - get portForwardStore() { - return this.props.portForwardStore; - } - - private startPortForwarding = async () => { - const { portForward } = this.props; - - const pf = await this.portForwardStore.start(portForward); - - if (pf.status === "Disabled") { - const { name, kind, forwardPort } = portForward; - - Notifications.error(`Error occurred starting port-forward, the local port ${forwardPort} may not be available or the ${kind} ${name} may not be reachable`); - } - }; - - renderStartStopMenuItem() { - const { portForward, toolbar } = this.props; - - if (portForward.status === "Active") { - return ( - this.portForwardStore.stop(portForward)}> - - Stop - - ); - } - - return ( - - - Start - - ); - } - - renderContent() { - const { portForward, toolbar } = this.props; - - if (!portForward) return null; - - return ( - <> - { portForward.status === "Active" && - openPortForward(portForward)}> - - Open - - } - this.props.openPortForwardDialog(portForward)}> - - Edit - - {this.renderStartStopMenuItem()} - - ); - } - - render() { - const { className, ...menuProps } = this.props; - - return ( - - {this.renderContent()} - - ); - } -} - -export const PortForwardMenu = withInjectables( - NonInjectedPortForwardMenu, - - { - getProps: (di, props) => ({ - portForwardStore: di.inject(portForwardStoreInjectable), - openPortForwardDialog: di.inject(portForwardDialogModelInjectable).open, - ...props, - }), - }, -); diff --git a/src/renderer/components/+network-port-forwards/port-forwards.tsx b/src/renderer/components/+network-port-forwards/port-forwards.tsx deleted file mode 100644 index b3e7e4c1f3..0000000000 --- a/src/renderer/components/+network-port-forwards/port-forwards.tsx +++ /dev/null @@ -1,154 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import "./port-forwards.scss"; - -import React from "react"; -import { disposeOnUnmount, observer } from "mobx-react"; -import type { RouteComponentProps } from "react-router-dom"; -import { ItemListLayout } from "../item-object-list/item-list-layout"; -import type { PortForwardItem, PortForwardStore } from "../../port-forward"; -import { PortForwardMenu } from "./port-forward-menu"; -import { PortForwardsRouteParams, portForwardsURL } from "../../../common/routes"; -import { PortForwardDetails } from "./port-forward-details"; -import { navigation } from "../../navigation"; -import { withInjectables } from "@ogre-tools/injectable-react"; -import portForwardStoreInjectable from "../../port-forward/port-forward-store/port-forward-store.injectable"; - -enum columnId { - name = "name", - namespace = "namespace", - kind = "kind", - port = "port", - forwardPort = "forwardPort", - protocol = "protocol", - status = "status", -} - -interface Props extends RouteComponentProps { -} - -interface Dependencies { - portForwardStore: PortForwardStore -} - -@observer -class NonInjectedPortForwards extends React.Component { - - componentDidMount() { - disposeOnUnmount(this, [ - this.props.portForwardStore.watch(), - ]); - } - - get selectedPortForward() { - const { match: { params: { forwardport }}} = this.props; - - return this.props.portForwardStore.getById(forwardport); - } - - onDetails = (item: PortForwardItem) => { - if (item === this.selectedPortForward) { - this.hideDetails(); - } else { - this.showDetails(item); - } - }; - - showDetails = (item: PortForwardItem) => { - navigation.push(portForwardsURL({ - params: { - forwardport: item.getId(), - }, - })); - }; - - hideDetails = () => { - navigation.push(portForwardsURL()); - }; - - renderRemoveDialogMessage(selectedItems: PortForwardItem[]) { - const forwardPorts = selectedItems.map(item => item.getForwardPort()).join(", "); - - return ( -
    - <>Stop forwarding from {forwardPorts}? -
    - ); - } - - - render() { - return ( - <> - item.getName(), - [columnId.namespace]: item => item.getNs(), - [columnId.kind]: item => item.getKind(), - [columnId.port]: item => item.getPort(), - [columnId.forwardPort]: item => item.getForwardPort(), - [columnId.protocol]: item => item.getProtocol(), - [columnId.status]: item => item.getStatus(), - }} - searchFilters={[ - item => item.getSearchFields(), - ]} - renderHeaderTitle="Port Forwarding" - renderTableHeader={[ - { title: "Name", className: "name", sortBy: columnId.name, id: columnId.name }, - { title: "Namespace", className: "namespace", sortBy: columnId.namespace, id: columnId.namespace }, - { title: "Kind", className: "kind", sortBy: columnId.kind, id: columnId.kind }, - { title: "Pod Port", className: "port", sortBy: columnId.port, id: columnId.port }, - { title: "Local Port", className: "forwardPort", sortBy: columnId.forwardPort, id: columnId.forwardPort }, - { title: "Protocol", className: "protocol", sortBy: columnId.protocol, id: columnId.protocol }, - { title: "Status", className: "status", sortBy: columnId.status, id: columnId.status }, - ]} - renderTableContents={item => [ - item.getName(), - item.getNs(), - item.getKind(), - item.getPort(), - item.getForwardPort(), - item.getProtocol(), - { title: item.getStatus(), className: item.getStatus().toLowerCase() }, - ]} - renderItemMenu={pf => ( - - )} - customizeRemoveDialog={selectedItems => ({ - message: this.renderRemoveDialogMessage(selectedItems), - })} - detailsItem={this.selectedPortForward} - onDetails={this.onDetails} - /> - {this.selectedPortForward && ( - - )} - - ); - } -} - -export const PortForwards = withInjectables( - NonInjectedPortForwards, - - { - getProps: (di, props) => ({ - portForwardStore: di.inject(portForwardStoreInjectable), - ...props, - }), - }, -); - diff --git a/src/renderer/components/+network-services/service-details-endpoint.tsx b/src/renderer/components/+network-services/service-details-endpoint.tsx deleted file mode 100644 index 0943427aad..0000000000 --- a/src/renderer/components/+network-services/service-details-endpoint.tsx +++ /dev/null @@ -1,56 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import type { KubeObject } from "../../../common/k8s-api/kube-object"; -import { observer } from "mobx-react"; -import React from "react"; -import { Table, TableHead, TableCell, TableRow } from "../table"; -import { prevDefault } from "../../utils"; -import { endpointStore } from "../+network-endpoints/endpoints.store"; -import { Spinner } from "../spinner"; -import { showDetails } from "../kube-detail-params"; - -interface Props { - endpoint: KubeObject; -} - -@observer -export class ServiceDetailsEndpoint extends React.Component { - render() { - const { endpoint } = this.props; - - if (!endpoint && !endpointStore.isLoaded) return ( -
    - ); - - if (!endpoint) { - return null; - } - - return ( -
    - - - Name - Endpoints - - showDetails(endpoint.selfLink, false))} - > - {endpoint.getName()} - { endpoint.toString()} - -
    -
    - ); - } -} diff --git a/src/renderer/components/+network-services/service-details.tsx b/src/renderer/components/+network-services/service-details.tsx deleted file mode 100644 index 87c900343b..0000000000 --- a/src/renderer/components/+network-services/service-details.tsx +++ /dev/null @@ -1,150 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import "./service-details.scss"; - -import React from "react"; -import { disposeOnUnmount, observer } from "mobx-react"; -import { DrawerItem, DrawerTitle } from "../drawer"; -import { Badge } from "../badge"; -import type { KubeObjectDetailsProps } from "../kube-object-details"; -import { Service } from "../../../common/k8s-api/endpoints"; -import { KubeObjectMeta } from "../kube-object-meta"; -import { ServicePortComponent } from "./service-port-component"; -import { endpointStore } from "../+network-endpoints/endpoints.store"; -import { ServiceDetailsEndpoint } from "./service-details-endpoint"; -import type { PortForwardStore } from "../../port-forward"; -import logger from "../../../common/logger"; -import type { KubeObjectStore } from "../../../common/k8s-api/kube-object.store"; -import type { KubeObject } from "../../../common/k8s-api/kube-object"; -import type { Disposer } from "../../../common/utils"; -import { withInjectables } from "@ogre-tools/injectable-react"; -import kubeWatchApiInjectable - from "../../kube-watch-api/kube-watch-api.injectable"; -import portForwardStoreInjectable from "../../port-forward/port-forward-store/port-forward-store.injectable"; -import type { KubeWatchSubscribeStoreOptions } from "../../kube-watch-api/kube-watch-api"; - -interface Props extends KubeObjectDetailsProps { -} - -interface Dependencies { - subscribeStores: (stores: KubeObjectStore[], options: KubeWatchSubscribeStoreOptions) => Disposer - portForwardStore: PortForwardStore -} - -@observer -class NonInjectedServiceDetails extends React.Component { - componentDidMount() { - const { object: service } = this.props; - - disposeOnUnmount(this, [ - this.props.subscribeStores([ - endpointStore, - ], { - namespaces: [service.getNs()], - }), - this.props.portForwardStore.watch(), - ]); - } - - render() { - const { object: service } = this.props; - - if (!service) { - return null; - } - - if (!(service instanceof Service)) { - logger.error("[ServiceDetails]: passed object that is not an instanceof Service", service); - - return null; - } - - const { spec } = service; - const endpoint = endpointStore.getByName(service.getName(), service.getNs()); - const externalIps = service.getExternalIps(); - - if (externalIps.length === 0 && spec?.externalName) { - externalIps.push(spec.externalName); - } - - return ( -
    - - - - {service.getSelector().map(selector => )} - - - - {spec.type} - - - - {spec.sessionAffinity} - - - - - - {spec.clusterIP} - - - - - - - - - {externalIps.length > 0 && ( - - {externalIps.map(ip =>
    {ip}
    )} -
    - )} - - -
    - { - service.getPorts().map((port) => ( - - )) - } -
    -
    - - {spec.type === "LoadBalancer" && spec.loadBalancerIP && ( - - {spec.loadBalancerIP} - - )} - - - -
    - ); - } -} - -export const ServiceDetails = withInjectables( - NonInjectedServiceDetails, - - { - getProps: (di, props) => ({ - subscribeStores: di.inject(kubeWatchApiInjectable).subscribeStores, - portForwardStore: di.inject(portForwardStoreInjectable), - ...props, - }), - }, -); diff --git a/src/renderer/components/+network-services/service-port-component.scss b/src/renderer/components/+network-services/service-port-component.scss deleted file mode 100644 index b9edf48557..0000000000 --- a/src/renderer/components/+network-services/service-port-component.scss +++ /dev/null @@ -1,29 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -.ServicePortComponent { - &.waiting { - opacity: 0.5; - pointer-events: none; - } - - &:not(:last-child) { - margin-bottom: $margin; - } - - span { - cursor: pointer; - color: var(--primary); - text-decoration: underline; - padding-right: 1em; - } - - .portInput { - display: inline-block !important; - width: 70px; - margin-left: 10px; - margin-right: 10px; - } -} diff --git a/src/renderer/components/+network-services/service-port-component.tsx b/src/renderer/components/+network-services/service-port-component.tsx deleted file mode 100644 index c2706761ee..0000000000 --- a/src/renderer/components/+network-services/service-port-component.tsx +++ /dev/null @@ -1,200 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import "./service-port-component.scss"; - -import React from "react"; -import { disposeOnUnmount, observer } from "mobx-react"; -import type { Service, ServicePort } from "../../../common/k8s-api/endpoints"; -import { action, makeObservable, observable, reaction } from "mobx"; -import { cssNames } from "../../utils"; -import { Notifications } from "../notifications"; -import { Button } from "../button"; -import type { ForwardedPort } from "../../port-forward"; -import { - aboutPortForwarding, - notifyErrorPortForwarding, openPortForward, - PortForwardStore, - predictProtocol, -} from "../../port-forward"; -import { Spinner } from "../spinner"; -import { withInjectables } from "@ogre-tools/injectable-react"; -import portForwardStoreInjectable from "../../port-forward/port-forward-store/port-forward-store.injectable"; -import portForwardDialogModelInjectable from "../../port-forward/port-forward-dialog-model/port-forward-dialog-model.injectable"; -import logger from "../../../common/logger"; - -interface Props { - service: Service; - port: ServicePort; -} - -interface Dependencies { - portForwardStore: PortForwardStore - openPortForwardDialog: (item: ForwardedPort, options: { openInBrowser: boolean, onClose: () => void }) => void -} - -@observer -class NonInjectedServicePortComponent extends React.Component { - @observable waiting = false; - @observable forwardPort = 0; - @observable isPortForwarded = false; - @observable isActive = false; - - constructor(props: Props & Dependencies) { - super(props); - makeObservable(this); - this.checkExistingPortForwarding(); - } - - componentDidMount() { - disposeOnUnmount(this, [ - reaction(() => this.props.service, () => this.checkExistingPortForwarding()), - ]); - } - - get portForwardStore() { - return this.props.portForwardStore; - } - - @action - async checkExistingPortForwarding() { - const { service, port } = this.props; - let portForward: ForwardedPort = { - kind: "service", - name: service.getName(), - namespace: service.getNs(), - port: port.port, - forwardPort: this.forwardPort, - }; - - try { - portForward = await this.portForwardStore.getPortForward(portForward); - } catch (error) { - this.isPortForwarded = false; - this.isActive = false; - - return; - } - - this.forwardPort = portForward.forwardPort; - this.isPortForwarded = true; - this.isActive = portForward.status === "Active"; - } - - @action - async portForward() { - const { service, port } = this.props; - let portForward: ForwardedPort = { - kind: "service", - name: service.getName(), - namespace: service.getNs(), - port: port.port, - forwardPort: this.forwardPort, - protocol: predictProtocol(port.name), - status: "Active", - }; - - this.waiting = true; - - try { - // determine how many port-forwards already exist - const { length } = this.portForwardStore.getPortForwards(); - - if (!this.isPortForwarded) { - portForward = await this.portForwardStore.add(portForward); - } else if (!this.isActive) { - portForward = await this.portForwardStore.start(portForward); - } - - this.forwardPort = portForward.forwardPort; - - if (portForward.status === "Active") { - openPortForward(portForward); - - // if this is the first port-forward show the about notification - if (!length) { - aboutPortForwarding(); - } - } else { - notifyErrorPortForwarding(`Error occurred starting port-forward, the local port may not be available or the ${portForward.kind} ${portForward.name} may not be reachable`); - } - } catch (error) { - logger.error("[SERVICE-PORT-COMPONENT]:", error, portForward); - } finally { - this.checkExistingPortForwarding(); - this.waiting = false; - } - } - - @action - async stopPortForward() { - const { service, port } = this.props; - const portForward: ForwardedPort = { - kind: "service", - name: service.getName(), - namespace: service.getNs(), - port: port.port, - forwardPort: this.forwardPort, - }; - - this.waiting = true; - - try { - await this.portForwardStore.remove(portForward); - } catch (error) { - Notifications.error(`Error occurred stopping the port-forward from port ${portForward.forwardPort}.`); - } finally { - this.checkExistingPortForwarding(); - this.forwardPort = 0; - this.waiting = false; - } - } - - render() { - const { port, service } = this.props; - - const portForwardAction = action(async () => { - if (this.isPortForwarded) { - await this.stopPortForward(); - } else { - const portForward: ForwardedPort = { - kind: "service", - name: service.getName(), - namespace: service.getNs(), - port: port.port, - forwardPort: this.forwardPort, - protocol: predictProtocol(port.name), - }; - - this.props.openPortForwardDialog(portForward, { openInBrowser: true, onClose: () => this.checkExistingPortForwarding() }); - } - }); - - return ( -
    - this.portForward()}> - {port.toString()} - - - {this.waiting && ( - - )} -
    - ); - } -} - -export const ServicePortComponent = withInjectables( - NonInjectedServicePortComponent, - - { - getProps: (di, props) => ({ - portForwardStore: di.inject(portForwardStoreInjectable), - openPortForwardDialog: di.inject(portForwardDialogModelInjectable).open, - ...props, - }), - }, -); - diff --git a/src/renderer/components/+network-services/services.tsx b/src/renderer/components/+network-services/services.tsx deleted file mode 100644 index 84d608cb43..0000000000 --- a/src/renderer/components/+network-services/services.tsx +++ /dev/null @@ -1,91 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import "./services.scss"; - -import React from "react"; -import { observer } from "mobx-react"; -import type { RouteComponentProps } from "react-router"; -import { KubeObjectListLayout } from "../kube-object-list-layout"; -import { Badge } from "../badge"; -import { serviceStore } from "./services.store"; -import { KubeObjectStatusIcon } from "../kube-object-status-icon"; -import type { ServicesRouteParams } from "../../../common/routes"; - -enum columnId { - name = "name", - namespace = "namespace", - selector = "selector", - ports = "port", - clusterIp = "cluster-ip", - externalIp = "external-ip", - age = "age", - type = "type", - status = "status", -} - -interface Props extends RouteComponentProps { -} - -@observer -export class Services extends React.Component { - render() { - return ( - service.getName(), - [columnId.namespace]: service => service.getNs(), - [columnId.selector]: service => service.getSelector(), - [columnId.ports]: service => (service.spec.ports || []).map(({ port }) => port)[0], - [columnId.clusterIp]: service => service.getClusterIp(), - [columnId.type]: service => service.getType(), - [columnId.age]: service => service.getTimeDiffFromNow(), - [columnId.status]: service => service.getStatus(), - }} - searchFilters={[ - service => service.getSearchFields(), - service => service.getSelector().join(" "), - service => service.getPorts().join(" "), - ]} - renderHeaderTitle="Services" - renderTableHeader={[ - { title: "Name", className: "name", sortBy: columnId.name, id: columnId.name }, - { className: "warning", showWithColumn: columnId.name }, - { title: "Namespace", className: "namespace", sortBy: columnId.namespace, id: columnId.namespace }, - { title: "Type", className: "type", sortBy: columnId.type, id: columnId.type }, - { title: "Cluster IP", className: "clusterIp", sortBy: columnId.clusterIp, id: columnId.clusterIp }, - { title: "Ports", className: "ports", sortBy: columnId.ports, id: columnId.ports }, - { title: "External IP", className: "externalIp", id: columnId.externalIp }, - { title: "Selector", className: "selector", sortBy: columnId.selector, id: columnId.selector }, - { title: "Age", className: "age", sortBy: columnId.age, id: columnId.age }, - { title: "Status", className: "status", sortBy: columnId.status, id: columnId.status }, - ]} - renderTableContents={service => { - const externalIps = service.getExternalIps(); - - if (externalIps.length === 0 && service.spec?.externalName) { - externalIps.push(service.spec.externalName); - } - - return [ - service.getName(), - , - service.getNs(), - service.getType(), - service.getClusterIp(), - service.getPorts().join(", "), - externalIps.join(", ") || "-", - service.getSelector().map(label => ), - service.getAge(), - { title: service.getStatus(), className: service.getStatus().toLowerCase() }, - ]; - }} - /> - ); - } -} diff --git a/src/renderer/components/+network/index.ts b/src/renderer/components/+network/index.ts deleted file mode 100644 index 37d73526e3..0000000000 --- a/src/renderer/components/+network/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -export * from "./network"; diff --git a/src/renderer/components/+network/layout.tsx b/src/renderer/components/+network/layout.tsx new file mode 100644 index 0000000000..d98ef625b3 --- /dev/null +++ b/src/renderer/components/+network/layout.tsx @@ -0,0 +1,31 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import React from "react"; +import { observer } from "mobx-react"; +import { TabLayout, TabLayoutRoute } from "../layout/tab-layout"; +import { withInjectables } from "@ogre-tools/injectable-react"; +import type { IComputedValue } from "mobx"; +import networkRoutesInjectable from "./routes.injectable"; + +export interface NetworkLayoutProps {} + +interface Dependencies { + routes: IComputedValue; +} + +const NonInjectedNetworkLayout = observer(({ routes }: Dependencies & NetworkLayoutProps) => ( + +)); + +export const NetworkLayout = withInjectables(NonInjectedNetworkLayout, { + getProps: (di, props) => ({ + routes: di.inject(networkRoutesInjectable), + ...props, + }), +}); diff --git a/src/renderer/components/+network/network-mixins.scss b/src/renderer/components/+network/mixins.scss similarity index 89% rename from src/renderer/components/+network/network-mixins.scss rename to src/renderer/components/+network/mixins.scss index e303a4719a..7611462007 100644 --- a/src/renderer/components/+network/network-mixins.scss +++ b/src/renderer/components/+network/mixins.scss @@ -5,7 +5,7 @@ $service-status-color-list: ( active: var(--colorOk), - pending: var(--colorWarning) + pending: var(--colorWarning), ); @mixin service-status-colors { @@ -18,7 +18,7 @@ $service-status-color-list: ( $port-forward-status-color-list: ( active: var(--colorOk), - disabled: var(--colorSoftError) + disabled: var(--colorSoftError), ); @mixin port-forward-status-colors { diff --git a/src/renderer/components/+network/network.scss b/src/renderer/components/+network/network.scss deleted file mode 100644 index e862468c8a..0000000000 --- a/src/renderer/components/+network/network.scss +++ /dev/null @@ -1,7 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -.Network { -} diff --git a/src/renderer/components/+network/network.tsx b/src/renderer/components/+network/routes.injectable.ts similarity index 60% rename from src/renderer/components/+network/network.tsx rename to src/renderer/components/+network/routes.injectable.ts index 2bdad62d8d..3183125dbc 100644 --- a/src/renderer/components/+network/network.tsx +++ b/src/renderer/components/+network/routes.injectable.ts @@ -2,23 +2,24 @@ * Copyright (c) OpenLens Authors. All rights reserved. * Licensed under MIT License. See LICENSE in root directory for more information. */ - -import "./network.scss"; - -import React from "react"; -import { observer } from "mobx-react"; -import { TabLayout, TabLayoutRoute } from "../layout/tab-layout"; -import { Services } from "../+network-services"; -import { Endpoints } from "../+network-endpoints"; -import { Ingresses } from "../+network-ingresses"; -import { NetworkPolicies } from "../+network-policies"; -import { PortForwards } from "../+network-port-forwards"; -import { isAllowedResource } from "../../../common/utils/allowed-resource"; +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; import * as routes from "../../../common/routes"; +import type { KubeResource } from "../../../common/rbac"; +import type { TabLayoutRoute } from "../layout/tab-layout"; +import { computed, IComputedValue } from "mobx"; +import { Services } from "../+services"; +import { Endpoints } from "../+endpoints"; +import { Ingresses } from "../+ingresses"; +import { NetworkPolicies } from "../+network-policies"; +import { PortForwards } from "../+port-forwards"; +import isAllowedResourceInjectable from "../../utils/allowed-resource.injectable"; -@observer -export class Network extends React.Component { - static get tabRoutes(): TabLayoutRoute[] { +interface Dependencies { + isAllowedResource: (resource: KubeResource) => boolean; +} + +function getConfigRoutes({ isAllowedResource }: Dependencies): IComputedValue { + return computed(() => { const tabs: TabLayoutRoute[] = []; if (isAllowedResource("services")) { @@ -65,11 +66,14 @@ export class Network extends React.Component { }); return tabs; - } - - render() { - return ( - - ); - } + }); } + +const networkRoutesInjectable = getInjectable({ + instantiate: (di) => getConfigRoutes({ + isAllowedResource: di.inject(isAllowedResourceInjectable), + }), + lifecycle: lifecycleEnum.singleton, +}); + +export default networkRoutesInjectable; diff --git a/src/renderer/components/+network/sidebar-item.tsx b/src/renderer/components/+network/sidebar-item.tsx new file mode 100644 index 0000000000..c6e09fe781 --- /dev/null +++ b/src/renderer/components/+network/sidebar-item.tsx @@ -0,0 +1,46 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import React from "react"; +import { observer } from "mobx-react"; +import type { TabLayoutRoute } from "../layout/tab-layout"; +import { withInjectables } from "@ogre-tools/injectable-react"; +import type { IComputedValue } from "mobx"; +import networkRoutesInjectable from "./routes.injectable"; +import { networkRoute, networkURL } from "../../../common/routes"; +import { isActiveRoute } from "../../navigation"; +import { Icon } from "../icon"; +import { SidebarItem } from "../layout/sidebar-item"; +import { TabRouteTree } from "../layout/tab-route-tree"; + +export interface NetworkSidebarItemProps {} + +interface Dependencies { + routes: IComputedValue; +} + +const NonInjectedNetworkSidebarItem = observer(({ routes }: Dependencies & NetworkSidebarItemProps) => { + const tabRoutes = routes.get(); + + return ( + } + > + + + ); +}); + +export const NetworkSidebarItem = withInjectables(NonInjectedNetworkSidebarItem, { + getProps: (di, props) => ({ + routes: di.inject(networkRoutesInjectable), + ...props, + }), +}); diff --git a/src/renderer/components/+nodes/node-charts.tsx b/src/renderer/components/+nodes/charts.tsx similarity index 84% rename from src/renderer/components/+nodes/node-charts.tsx rename to src/renderer/components/+nodes/charts.tsx index 434dba1146..96562561b7 100644 --- a/src/renderer/components/+nodes/node-charts.tsx +++ b/src/renderer/components/+nodes/charts.tsx @@ -4,22 +4,28 @@ */ import React, { useContext } from "react"; -import type { IClusterMetrics, Node } from "../../../common/k8s-api/endpoints"; import { BarChart, cpuOptions, memoryOptions } from "../chart"; import { isMetricsEmpty, normalizeMetrics } from "../../../common/k8s-api/endpoints/metrics.api"; import { NoMetrics } from "../resource-metrics/no-metrics"; -import { IResourceMetricsValue, ResourceMetricsContext } from "../resource-metrics"; +import { ResourceMetricsContext } from "../resource-metrics"; import { observer } from "mobx-react"; import type { ChartOptions, ChartPoint } from "chart.js"; -import { ThemeStore } from "../../theme.store"; +import type { Theme } from "../../themes/store"; import { mapValues } from "lodash"; +import { withInjectables } from "@ogre-tools/injectable-react"; +import type { IComputedValue } from "mobx"; +import activeThemeInjectable from "../../themes/active-theme.injectable"; -type IContext = IResourceMetricsValue; +export interface NodeChartsProps {} -export const NodeCharts = observer(() => { - const { params: { metrics }, tabId, object } = useContext(ResourceMetricsContext); +interface Dependencies { + activeTheme: IComputedValue; +} + +const NonInjectedNodeCharts = observer(({ activeTheme }: Dependencies & NodeChartsProps) => { + const { metrics, tabId, object } = useContext(ResourceMetricsContext); const id = object.getId(); - const { chartCapacityColor } = ThemeStore.getInstance().activeTheme.colors; + const { chartCapacityColor } = activeTheme.get().colors; if (!metrics) { return null; @@ -181,3 +187,10 @@ export const NodeCharts = observer(() => { /> ); }); + +export const NodeCharts = withInjectables(NonInjectedNodeCharts, { + getProps: (di, props) => ({ + activeTheme: di.inject(activeThemeInjectable), + ...props, + }), +}); diff --git a/src/renderer/components/+nodes/node-details.scss b/src/renderer/components/+nodes/details.scss similarity index 100% rename from src/renderer/components/+nodes/node-details.scss rename to src/renderer/components/+nodes/details.scss diff --git a/src/renderer/components/+nodes/details.tsx b/src/renderer/components/+nodes/details.tsx new file mode 100644 index 0000000000..a48b8954e9 --- /dev/null +++ b/src/renderer/components/+nodes/details.tsx @@ -0,0 +1,175 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import "./details.scss"; + +import React, { useEffect, useState } from "react"; +import upperFirst from "lodash/upperFirst"; +import kebabCase from "lodash/kebabCase"; +import { observer } from "mobx-react"; +import { DrawerItem, DrawerItemLabels } from "../drawer"; +import { Badge } from "../badge"; +import { ResourceMetrics } from "../resource-metrics"; +import type { PodStore } from "../+pods/store"; +import type { KubeObjectDetailsProps } from "../kube-object-details"; +import { formatNodeTaint, getMetricsByNodeNames, IClusterMetrics, Node } from "../../../common/k8s-api/endpoints"; +import { NodeCharts } from "./charts"; +import { PodDetailsList } from "../+pods/details-list"; +import { KubeObjectMeta } from "../kube-object-meta"; +import { ClusterMetricsResourceType } from "../../../common/cluster-types"; +import { NodeDetailsResources } from "./resource-details"; +import { DrawerTitle } from "../drawer/drawer-title"; +import logger from "../../../common/logger"; +import { withInjectables } from "@ogre-tools/injectable-react"; +import podStoreInjectable from "../+pods/store.injectable"; +import isMetricHiddenInjectable from "../../utils/is-metrics-hidden.injectable"; +import type { KubeWatchApi } from "../../kube-watch-api/kube-watch-api"; +import kubeWatchApiInjectable from "../../kube-watch-api/kube-watch-api.injectable"; + +export interface NodeDetailsProps extends KubeObjectDetailsProps { +} + +interface Dependencies { + podStore: PodStore; + isMetricHidden: boolean; + kubeWatchApi: KubeWatchApi; +} + +const NonInjectedNodeDetails = observer(({ kubeWatchApi, isMetricHidden, podStore, object: node }: Dependencies & NodeDetailsProps) => { + const [metrics, setMetrics] = useState(null); + + useEffect(() => setMetrics(null), [node]); + useEffect(() => ( + kubeWatchApi.subscribeStores([ + podStore, + ]) + ), []); + + const loadMetrics = async () => { + setMetrics(await getMetricsByNodeNames([node.getName()])); + }; + + if (!node) { + return null; + } + + if (!(node instanceof Node)) { + logger.error("[NodeDetails]: passed object that is not an instanceof Node", node); + + return null; + } + + const { status } = node; + const { nodeInfo, addresses } = status; + const conditions = node.getActiveConditions(); + const taints = node.getTaints(); + const childPods = podStore.getPodsByNode(node.getName()); + const metricTabs = [ + "CPU", + "Memory", + "Disk", + "Pods", + ]; + + return ( +
    + {(!isMetricHidden && podStore.isLoaded) && ( + + + + )} + + {addresses && + + { + addresses.map(({ type, address }) => ( +

    {type}: {address}

    + )) + } +
    + } + + {nodeInfo.operatingSystem} ({nodeInfo.architecture}) + + + {nodeInfo.osImage} + + + {nodeInfo.kernelVersion} + + + {nodeInfo.containerRuntimeVersion} + + + {nodeInfo.kubeletVersion} + + + + {taints.length > 0 && ( + + {taints.map(taint => )} + + )} + {conditions && + + { + conditions.map(condition => ( + ( +
    +
    {upperFirst(key)}
    +
    {value}
    +
    + )), + }} /> + )) + } +
    + } + + + + + +
    + ); +}); + +export const NodeDetails = withInjectables(NonInjectedNodeDetails, { + getProps: (di, props) => ({ + podStore: di.inject(podStoreInjectable), + isMetricHidden: di.inject(isMetricHiddenInjectable, { + metricType: ClusterMetricsResourceType.Node, + }), + kubeWatchApi: di.inject(kubeWatchApiInjectable), + ...props, + }), +}); + diff --git a/src/renderer/components/+nodes/index.ts b/src/renderer/components/+nodes/index.ts index 78b0f3b279..9e0b7fe24d 100644 --- a/src/renderer/components/+nodes/index.ts +++ b/src/renderer/components/+nodes/index.ts @@ -4,4 +4,4 @@ */ export * from "./nodes"; -export * from "./node-details"; +export * from "./details"; diff --git a/src/renderer/components/+nodes/nodes-mixins.scss b/src/renderer/components/+nodes/mixins.scss similarity index 100% rename from src/renderer/components/+nodes/nodes-mixins.scss rename to src/renderer/components/+nodes/mixins.scss diff --git a/src/renderer/components/+nodes/node-details.tsx b/src/renderer/components/+nodes/node-details.tsx deleted file mode 100644 index 82580224ec..0000000000 --- a/src/renderer/components/+nodes/node-details.tsx +++ /dev/null @@ -1,197 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import "./node-details.scss"; - -import React from "react"; -import upperFirst from "lodash/upperFirst"; -import kebabCase from "lodash/kebabCase"; -import { disposeOnUnmount, observer } from "mobx-react"; -import { DrawerItem, DrawerItemLabels } from "../drawer"; -import { Badge } from "../badge"; -import { ResourceMetrics } from "../resource-metrics"; -import { podsStore } from "../+workloads-pods/pods.store"; -import type { KubeObjectDetailsProps } from "../kube-object-details"; -import { formatNodeTaint, getMetricsByNodeNames, IClusterMetrics, Node } from "../../../common/k8s-api/endpoints"; -import { NodeCharts } from "./node-charts"; -import { makeObservable, observable, reaction } from "mobx"; -import { PodDetailsList } from "../+workloads-pods/pod-details-list"; -import { KubeObjectMeta } from "../kube-object-meta"; -import { getActiveClusterEntity } from "../../api/catalog-entity-registry"; -import { ClusterMetricsResourceType } from "../../../common/cluster-types"; -import { NodeDetailsResources } from "./node-details-resources"; -import { DrawerTitle } from "../drawer/drawer-title"; -import { boundMethod, Disposer } from "../../utils"; -import logger from "../../../common/logger"; -import type { KubeObjectStore } from "../../../common/k8s-api/kube-object.store"; -import type { KubeObject } from "../../../common/k8s-api/kube-object"; -import { withInjectables } from "@ogre-tools/injectable-react"; -import kubeWatchApiInjectable - from "../../kube-watch-api/kube-watch-api.injectable"; - -interface Props extends KubeObjectDetailsProps { -} - -interface Dependencies { - subscribeStores: (stores: KubeObjectStore[]) => Disposer -} - -@observer -class NonInjectedNodeDetails extends React.Component { - @observable metrics: Partial; - - constructor(props: Props & Dependencies) { - super(props); - makeObservable(this); - } - - componentDidMount() { - disposeOnUnmount(this, [ - reaction(() => this.props.object.getName(), () => { - this.metrics = null; - }), - - this.props.subscribeStores([ - podsStore, - ]), - ]); - } - - @boundMethod - async loadMetrics() { - const { object: node } = this.props; - - this.metrics = await getMetricsByNodeNames([node.getName()]); - } - - render() { - const { object: node } = this.props; - - if (!node) { - return null; - } - - if (!(node instanceof Node)) { - logger.error("[NodeDetails]: passed object that is not an instanceof Node", node); - - return null; - } - - const { status } = node; - const { nodeInfo, addresses } = status; - const conditions = node.getActiveConditions(); - const taints = node.getTaints(); - const childPods = podsStore.getPodsByNode(node.getName()); - const { metrics } = this; - const metricTabs = [ - "CPU", - "Memory", - "Disk", - "Pods", - ]; - const isMetricHidden = getActiveClusterEntity()?.isMetricHidden(ClusterMetricsResourceType.Node); - - return ( -
    - {!isMetricHidden && podsStore.isLoaded && ( - - - - )} - - {addresses && - - { - addresses.map(({ type, address }) => ( -

    {type}: {address}

    - )) - } -
    - } - - {nodeInfo.operatingSystem} ({nodeInfo.architecture}) - - - {nodeInfo.osImage} - - - {nodeInfo.kernelVersion} - - - {nodeInfo.containerRuntimeVersion} - - - {nodeInfo.kubeletVersion} - - - - {taints.length > 0 && ( - - {taints.map(taint => )} - - )} - {conditions && - - { - conditions.map(condition => { - const { type } = condition; - - return ( - -
    -
    {upperFirst(key)}
    -
    {value}
    -
    , - ), - }} - /> - ); - }) - } -
    - } - - - - - -
    - ); - } -} - -export const NodeDetails = withInjectables( - NonInjectedNodeDetails, - - { - getProps: (di, props) => ({ - subscribeStores: di.inject(kubeWatchApiInjectable).subscribeStores, - ...props, - }), - }, -); - diff --git a/src/renderer/components/+nodes/nodes.scss b/src/renderer/components/+nodes/nodes.scss index bace3aa726..8438ef84c9 100644 --- a/src/renderer/components/+nodes/nodes.scss +++ b/src/renderer/components/+nodes/nodes.scss @@ -3,7 +3,7 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -@import "nodes-mixins"; +@import "mixins"; .Nodes { .TableCell { @@ -17,7 +17,7 @@ } &.cpu { - flex: 1.0; + flex: 1; align-self: center; .LineProgress { @@ -26,7 +26,7 @@ } &.memory { - flex: 1.0; + flex: 1; align-self: center; .LineProgress { @@ -35,7 +35,7 @@ } &.disk { - flex: 1.0; + flex: 1; align-self: center; .LineProgress { diff --git a/src/renderer/components/+nodes/nodes.tsx b/src/renderer/components/+nodes/nodes.tsx index 43a3b3a84e..25738346ff 100644 --- a/src/renderer/components/+nodes/nodes.tsx +++ b/src/renderer/components/+nodes/nodes.tsx @@ -4,14 +4,14 @@ */ import "./nodes.scss"; -import React from "react"; +import React, { useEffect, useState } from "react"; import { observer } from "mobx-react"; import type { RouteComponentProps } from "react-router"; import { cssNames, interval } from "../../utils"; import { TabLayout } from "../layout/tab-layout"; -import { nodesStore } from "./nodes.store"; +import type { NodeStore } from "./store"; import { KubeObjectListLayout } from "../kube-object-list-layout"; -import { formatNodeTaint, getMetricsForAllNodes, INodeMetrics, Node } from "../../../common/k8s-api/endpoints/nodes.api"; +import { formatNodeTaint, getMetricsForAllNodes, INodeMetrics, Node } from "../../../common/k8s-api/endpoints/node.api"; import { LineProgress } from "../line-progress"; import { bytesToUnits } from "../../../common/utils/convertMemory"; import { Tooltip, TooltipPosition } from "../tooltip"; @@ -19,10 +19,12 @@ import kebabCase from "lodash/kebabCase"; import upperFirst from "lodash/upperFirst"; import { KubeObjectStatusIcon } from "../kube-object-status-icon"; import { Badge } from "../badge/badge"; -import { eventStore } from "../+events/event.store"; +import type { EventStore } from "../+events/store"; import type { NodesRouteParams } from "../../../common/routes"; -import { makeObservable, observable } from "mobx"; import isEmpty from "lodash/isEmpty"; +import { withInjectables } from "@ogre-tools/injectable-react"; +import eventStoreInjectable from "../+events/store.injectable"; +import nodeStoreInjectable from "./store.injectable"; enum columnId { name = "name", @@ -37,7 +39,7 @@ enum columnId { status = "status", } -interface Props extends RouteComponentProps { +export interface NodesProps extends RouteComponentProps { } type MetricsTooltipFormatter = (metrics: [number, number]) => string; @@ -49,26 +51,23 @@ interface UsageArgs { formatters: MetricsTooltipFormatter[]; } -@observer -export class Nodes extends React.Component { - @observable.ref metrics: Partial = {}; - private metricsWatcher = interval(30, async () => this.metrics = await getMetricsForAllNodes()); +interface Dependencies { + eventStore: EventStore; + nodeStore: NodeStore; +} - constructor(props: Props) { - super(props); - makeObservable(this); - } +const NonInjectedNodes = observer(({ eventStore, nodeStore }: Dependencies & NodesProps) => { + const [metrics, setMetrics] = useState(null); + const [metricsPoller] = useState(interval(30, async () => setMetrics(await getMetricsForAllNodes()))); - async componentDidMount() { - this.metricsWatcher.start(true); - } + useEffect(() => { + metricsPoller.start(true); - componentWillUnmount() { - this.metricsWatcher.stop(); - } + return () => metricsPoller.stop(); + }, []); - getLastMetricValues(node: Node, metricNames: string[]): number[] { - if (isEmpty(this.metrics)) { + const getLastMetricValues = (node: Node, metricNames: string[]): number[] => { + if (isEmpty(metrics)) { return []; } @@ -76,7 +75,7 @@ export class Nodes extends React.Component { return metricNames.map(metricName => { try { - const metric = this.metrics[metricName]; + const metric = metrics[metricName]; const result = metric.data.result.find(({ metric: { node, instance, kubernetes_node }}) => ( nodeName === node || nodeName === instance @@ -88,10 +87,10 @@ export class Nodes extends React.Component { return 0; } }); - } + }; - private renderUsage({ node, title, metricNames, formatters }: UsageArgs) { - const metrics = this.getLastMetricValues(node, metricNames); + const renderUsage =({ node, title, metricNames, formatters }: UsageArgs) => { + const metrics = getLastMetricValues(node, metricNames); if (!metrics || metrics.length < 2) { return ; @@ -109,10 +108,10 @@ export class Nodes extends React.Component { }} /> ); - } + }; - renderCpuUsage(node: Node) { - return this.renderUsage({ + const renderCpuUsage = (node: Node) => { + return renderUsage({ node, title: "CPU", metricNames: ["cpuUsage", "cpuCapacity"], @@ -121,10 +120,10 @@ export class Nodes extends React.Component { ([, cap]) => `cores: ${cap}`, ], }); - } + }; - renderMemoryUsage(node: Node) { - return this.renderUsage({ + const renderMemoryUsage = (node: Node) => { + return renderUsage({ node, title: "Memory", metricNames: ["workloadMemoryUsage", "memoryAllocatableCapacity"], @@ -133,10 +132,10 @@ export class Nodes extends React.Component { ([usage]) => bytesToUnits(usage, 3), ], }); - } + }; - renderDiskUsage(node: Node) { - return this.renderUsage({ + const renderDiskUsage = (node: Node) => { + return renderUsage({ node, title: "Disk", metricNames: ["fsUsage", "fsSize"], @@ -145,9 +144,9 @@ export class Nodes extends React.Component { ([usage]) => bytesToUnits(usage, 3), ], }); - } + }; - renderConditions(node: Node) { + const renderConditions = (node: Node) => { if (!node.status.conditions) { return null; } @@ -170,73 +169,78 @@ export class Nodes extends React.Component {
    ); }); - } + }; - render() { - return ( - - node.getName(), - [columnId.cpu]: node => this.getLastMetricValues(node, ["cpuUsage"]), - [columnId.memory]: node => this.getLastMetricValues(node, ["memoryUsage"]), - [columnId.disk]: node => this.getLastMetricValues(node, ["fsUsage"]), - [columnId.conditions]: node => node.getNodeConditionText(), - [columnId.taints]: node => node.getTaints().length, - [columnId.roles]: node => node.getRoleLabels(), - [columnId.age]: node => node.getTimeDiffFromNow(), - [columnId.version]: node => node.getKubeletVersion(), - }} - searchFilters={[ - node => node.getSearchFields(), - node => node.getRoleLabels(), - node => node.getKubeletVersion(), - node => node.getNodeConditionText(), - ]} - renderHeaderTitle="Nodes" - renderTableHeader={[ - { title: "Name", className: "name", sortBy: columnId.name, id: columnId.name }, - { className: "warning", showWithColumn: columnId.name }, - { title: "CPU", className: "cpu", sortBy: columnId.cpu, id: columnId.cpu }, - { title: "Memory", className: "memory", sortBy: columnId.memory, id: columnId.memory }, - { title: "Disk", className: "disk", sortBy: columnId.disk, id: columnId.disk }, - { title: "Taints", className: "taints", sortBy: columnId.taints, id: columnId.taints }, - { title: "Roles", className: "roles", sortBy: columnId.roles, id: columnId.roles }, - { title: "Version", className: "version", sortBy: columnId.version, id: columnId.version }, - { title: "Age", className: "age", sortBy: columnId.age, id: columnId.age }, - { title: "Conditions", className: "conditions", sortBy: columnId.conditions, id: columnId.conditions }, - ]} - renderTableContents={node => { - const tooltipId = `node-taints-${node.getId()}`; - const taints = node.getTaints(); + return ( + + node.getName(), + [columnId.cpu]: node => getLastMetricValues(node, ["cpuUsage"]), + [columnId.memory]: node => getLastMetricValues(node, ["memoryUsage"]), + [columnId.disk]: node => getLastMetricValues(node, ["fsUsage"]), + [columnId.conditions]: node => node.getNodeConditionText(), + [columnId.taints]: node => node.getTaints().length, + [columnId.roles]: node => node.getRoleLabels(), + [columnId.age]: node => node.getTimeDiffFromNow(), + [columnId.version]: node => node.getKubeletVersion(), + }} + searchFilters={[ + node => node.getSearchFields(), + node => node.getRoleLabels(), + node => node.getKubeletVersion(), + node => node.getNodeConditionText(), + ]} + renderHeaderTitle="Nodes" + renderTableHeader={[ + { title: "Name", className: "name", sortBy: columnId.name, id: columnId.name }, + { className: "warning", showWithColumn: columnId.name }, + { title: "CPU", className: "cpu", sortBy: columnId.cpu, id: columnId.cpu }, + { title: "Memory", className: "memory", sortBy: columnId.memory, id: columnId.memory }, + { title: "Disk", className: "disk", sortBy: columnId.disk, id: columnId.disk }, + { title: "Taints", className: "taints", sortBy: columnId.taints, id: columnId.taints }, + { title: "Roles", className: "roles", sortBy: columnId.roles, id: columnId.roles }, + { title: "Version", className: "version", sortBy: columnId.version, id: columnId.version }, + { title: "Age", className: "age", sortBy: columnId.age, id: columnId.age }, + { title: "Conditions", className: "conditions", sortBy: columnId.conditions, id: columnId.conditions }, + ]} + renderTableContents={node => { + const tooltipId = `node-taints-${node.getId()}`; + const taints = node.getTaints(); - return [ - , - , - this.renderCpuUsage(node), - this.renderMemoryUsage(node), - this.renderDiskUsage(node), - <> - {taints.length} - - {taints.map(formatNodeTaint).join("\n")} - - , - node.getRoleLabels(), - node.status.nodeInfo.kubeletVersion, - node.getAge(), - this.renderConditions(node), - ]; - }} - /> - - ); - } -} + return [ + , + , + renderCpuUsage(node), + renderMemoryUsage(node), + renderDiskUsage(node), + <> + {taints.length} + + {taints.map(formatNodeTaint).join("\n")} + + , + node.getRoleLabels(), + node.status.nodeInfo.kubeletVersion, + node.getAge(), + renderConditions(node), + ]; + }} + /> + + ); +}); + +export const Nodes = withInjectables(NonInjectedNodes, { + getProps: (di, props) => ({ + eventStore: di.inject(eventStoreInjectable), + nodeStore: di.inject(nodeStoreInjectable), + ...props, + }), +}); diff --git a/src/renderer/components/+nodes/node-details-resources.scss b/src/renderer/components/+nodes/resource-details.scss similarity index 100% rename from src/renderer/components/+nodes/node-details-resources.scss rename to src/renderer/components/+nodes/resource-details.scss diff --git a/src/renderer/components/+nodes/node-details-resources.tsx b/src/renderer/components/+nodes/resource-details.tsx similarity index 98% rename from src/renderer/components/+nodes/node-details-resources.tsx rename to src/renderer/components/+nodes/resource-details.tsx index 98dab82d91..a8fcf1fed3 100644 --- a/src/renderer/components/+nodes/node-details-resources.tsx +++ b/src/renderer/components/+nodes/resource-details.tsx @@ -3,7 +3,7 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import "./node-details-resources.scss"; +import "./resource-details.scss"; import { Table } from "../table/table"; import { TableHead } from "../table/table-head"; diff --git a/src/renderer/components/+nodes/store.injectable.ts b/src/renderer/components/+nodes/store.injectable.ts new file mode 100644 index 0000000000..8b20e006cd --- /dev/null +++ b/src/renderer/components/+nodes/store.injectable.ts @@ -0,0 +1,14 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import apiManagerInjectable from "../../../common/k8s-api/api-manager.injectable"; +import type { NodeStore } from "./store"; + +const nodeStoreInjectable = getInjectable({ + instantiate: (di) => di.inject(apiManagerInjectable).getStore("/api/v1/nodes") as NodeStore, + lifecycle: lifecycleEnum.singleton, +}); + +export default nodeStoreInjectable; diff --git a/src/renderer/components/+nodes/nodes.store.ts b/src/renderer/components/+nodes/store.ts similarity index 71% rename from src/renderer/components/+nodes/nodes.store.ts rename to src/renderer/components/+nodes/store.ts index f9d7e3ea2d..1ca5464997 100644 --- a/src/renderer/components/+nodes/nodes.store.ts +++ b/src/renderer/components/+nodes/store.ts @@ -5,15 +5,12 @@ import { sum } from "lodash"; import { computed, makeObservable } from "mobx"; -import { apiManager } from "../../../common/k8s-api/api-manager"; -import { Node, nodesApi } from "../../../common/k8s-api/endpoints"; +import type { Node, NodeApi } from "../../../common/k8s-api/endpoints"; import { KubeObjectStore } from "../../../common/k8s-api/kube-object.store"; import { autoBind } from "../../utils"; -export class NodesStore extends KubeObjectStore { - api = nodesApi; - - constructor() { +export class NodeStore extends KubeObjectStore { + constructor(public readonly api:NodeApi) { super(); makeObservable(this); @@ -32,6 +29,3 @@ export class NodesStore extends KubeObjectStore { return sum(this.items.map((node: Node) => node.getWarningConditions().length)); } } - -export const nodesStore = new NodesStore(); -apiManager.registerStore(nodesStore); diff --git a/src/renderer/components/+storage-volume-claims/volume-claim-details.scss b/src/renderer/components/+persistent-volume-claims/details.scss similarity index 100% rename from src/renderer/components/+storage-volume-claims/volume-claim-details.scss rename to src/renderer/components/+persistent-volume-claims/details.scss diff --git a/src/renderer/components/+persistent-volume-claims/details.tsx b/src/renderer/components/+persistent-volume-claims/details.tsx new file mode 100644 index 0000000000..6fbc2a617c --- /dev/null +++ b/src/renderer/components/+persistent-volume-claims/details.tsx @@ -0,0 +1,118 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import "./details.scss"; + +import React, { Fragment, useEffect, useState } from "react"; +import { observer } from "mobx-react"; +import { DrawerItem, DrawerTitle } from "../drawer"; +import { Badge } from "../badge"; +import type { PodStore } from "../+pods/store"; +import { Link } from "react-router-dom"; +import { ResourceMetrics } from "../resource-metrics"; +import { VolumeClaimDiskChart } from "./disk-chart"; +import type { KubeObjectDetailsProps } from "../kube-object-details"; +import { getMetricsForPvc, IPvcMetrics, PersistentVolumeClaim } from "../../../common/k8s-api/endpoints"; +import { ClusterMetricsResourceType } from "../../../common/cluster-types"; +import { KubeObjectMeta } from "../kube-object-meta"; +import { getDetailsUrl } from "../kube-detail-params"; +import logger from "../../../common/logger"; +import { withInjectables } from "@ogre-tools/injectable-react"; +import podStoreInjectable from "../+pods/store.injectable"; +import isMetricHiddenInjectable from "../../utils/is-metrics-hidden.injectable"; + +export interface PersistentVolumeClaimDetailsProps extends KubeObjectDetailsProps { +} + +interface Dependencies { + podStore: PodStore; + isMetricHidden: boolean; +} + +const NonInjectedPersistentVolumeClaimDetails = observer(({ isMetricHidden, podStore, object: persistentVolumeClaim }: Dependencies & PersistentVolumeClaimDetailsProps) => { + const [metrics, setMetrics] = useState(null); + + useEffect(() => setMetrics(null), [persistentVolumeClaim]); + + const loadMetrics = async () => { + setMetrics(await getMetricsForPvc(persistentVolumeClaim)); + }; + + if (!persistentVolumeClaim) { + return null; + } + + if (!(persistentVolumeClaim instanceof PersistentVolumeClaim)) { + logger.error("[PersistentVolumeClaimDetails]: passed object that is not an instanceof PersistentVolumeClaim", persistentVolumeClaim); + + return null; + } + + const { storageClassName, accessModes } = persistentVolumeClaim.spec; + const pods = persistentVolumeClaim.getPods(podStore.items); + + return ( +
    + {!isMetricHidden && ( + + + + )} + + + {accessModes.join(", ")} + + + {storageClassName} + + + {persistentVolumeClaim.getStorage()} + + + {pods.map(pod => ( + + {pod.getName()} + + ))} + + + {persistentVolumeClaim.getStatus()} + + + + + + {persistentVolumeClaim.getMatchLabels().map(label => )} + + + + {persistentVolumeClaim.getMatchExpressions().map(({ key, operator, values }, i) => ( + + {key} + {operator} + {values.join(", ")} + + ))} + +
    + ); +}); + +export const PersistentVolumeClaimDetails = withInjectables(NonInjectedPersistentVolumeClaimDetails, { + getProps: (di, props) => ({ + podStore: di.inject(podStoreInjectable), + isMetricHidden: di.inject(isMetricHiddenInjectable, { + metricType: ClusterMetricsResourceType.VolumeClaim, + }), + ...props, + }), +}); diff --git a/src/renderer/components/+storage-volume-claims/volume-claim-disk-chart.tsx b/src/renderer/components/+persistent-volume-claims/disk-chart.tsx similarity index 61% rename from src/renderer/components/+storage-volume-claims/volume-claim-disk-chart.tsx rename to src/renderer/components/+persistent-volume-claims/disk-chart.tsx index eebd712232..13f16f84b3 100644 --- a/src/renderer/components/+storage-volume-claims/volume-claim-disk-chart.tsx +++ b/src/renderer/components/+persistent-volume-claims/disk-chart.tsx @@ -5,18 +5,24 @@ import React, { useContext } from "react"; import { observer } from "mobx-react"; -import type { IPvcMetrics, PersistentVolumeClaim } from "../../../common/k8s-api/endpoints"; import { BarChart, ChartDataSets, memoryOptions } from "../chart"; import { isMetricsEmpty, normalizeMetrics } from "../../../common/k8s-api/endpoints/metrics.api"; import { NoMetrics } from "../resource-metrics/no-metrics"; -import { IResourceMetricsValue, ResourceMetricsContext } from "../resource-metrics"; -import { ThemeStore } from "../../theme.store"; +import { ResourceMetricsContext } from "../resource-metrics"; +import type { Theme } from "../../themes/store"; +import type { IComputedValue } from "mobx"; +import { withInjectables } from "@ogre-tools/injectable-react"; +import activeThemeInjectable from "../../themes/active-theme.injectable"; -type IContext = IResourceMetricsValue; +export interface VolumeClaimDiskChartProps {} -export const VolumeClaimDiskChart = observer(() => { - const { params: { metrics }, object } = useContext(ResourceMetricsContext); - const { chartCapacityColor } = ThemeStore.getInstance().activeTheme.colors; +interface Dependencies { + activeTheme: IComputedValue; +} + +const NonInjectedVolumeClaimDiskChart = observer(({ activeTheme }: Dependencies & VolumeClaimDiskChartProps) => { + const { metrics, object } = useContext(ResourceMetricsContext); + const { chartCapacityColor } = activeTheme.get().colors; const id = object.getId(); if (!metrics) return null; @@ -53,3 +59,10 @@ export const VolumeClaimDiskChart = observer(() => { /> ); }); + +export const VolumeClaimDiskChart = withInjectables(NonInjectedVolumeClaimDiskChart, { + getProps: (di, props) => ({ + activeTheme: di.inject(activeThemeInjectable), + ...props, + }), +}); diff --git a/src/renderer/components/+storage-volume-claims/index.ts b/src/renderer/components/+persistent-volume-claims/index.ts similarity index 81% rename from src/renderer/components/+storage-volume-claims/index.ts rename to src/renderer/components/+persistent-volume-claims/index.ts index 6d8b8c02aa..62928b4fe9 100644 --- a/src/renderer/components/+storage-volume-claims/index.ts +++ b/src/renderer/components/+persistent-volume-claims/index.ts @@ -4,4 +4,4 @@ */ export * from "./volume-claims"; -export * from "./volume-claim-details"; +export * from "./details"; diff --git a/src/renderer/components/+persistent-volume-claims/store.injectable.ts b/src/renderer/components/+persistent-volume-claims/store.injectable.ts new file mode 100644 index 0000000000..90d0a7f316 --- /dev/null +++ b/src/renderer/components/+persistent-volume-claims/store.injectable.ts @@ -0,0 +1,14 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import apiManagerInjectable from "../../../common/k8s-api/api-manager.injectable"; +import type { PersistentVolumeClaimStore } from "./store"; + +const persistentVolumeClaimStoreInjectable = getInjectable({ + instantiate: (di) => di.inject(apiManagerInjectable).getStore("/api/v1/persistentvolumeclaims") as PersistentVolumeClaimStore, + lifecycle: lifecycleEnum.singleton, +}); + +export default persistentVolumeClaimStoreInjectable; diff --git a/src/renderer/components/+persistent-volume-claims/store.ts b/src/renderer/components/+persistent-volume-claims/store.ts new file mode 100644 index 0000000000..7763bfabfe --- /dev/null +++ b/src/renderer/components/+persistent-volume-claims/store.ts @@ -0,0 +1,12 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import type { PersistentVolumeClaim, PersistentVolumeClaimApi } from "../../../common/k8s-api/endpoints"; +import { KubeObjectStore } from "../../../common/k8s-api/kube-object.store"; + +export class PersistentVolumeClaimStore extends KubeObjectStore { + constructor(public readonly api:PersistentVolumeClaimApi) { + super(); + } +} diff --git a/src/renderer/components/+storage-volume-claims/volume-claims.scss b/src/renderer/components/+persistent-volume-claims/volume-claims.scss similarity index 93% rename from src/renderer/components/+storage-volume-claims/volume-claims.scss rename to src/renderer/components/+persistent-volume-claims/volume-claims.scss index d42d57ce8b..8a71994fda 100644 --- a/src/renderer/components/+storage-volume-claims/volume-claims.scss +++ b/src/renderer/components/+persistent-volume-claims/volume-claims.scss @@ -3,7 +3,7 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -@import "../+storage/storage-mixins"; +@import "../+storage/mixins"; .PersistentVolumeClaims { .TableCell { diff --git a/src/renderer/components/+persistent-volume-claims/volume-claims.tsx b/src/renderer/components/+persistent-volume-claims/volume-claims.tsx new file mode 100644 index 0000000000..043bf8c66b --- /dev/null +++ b/src/renderer/components/+persistent-volume-claims/volume-claims.tsx @@ -0,0 +1,110 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import "./volume-claims.scss"; + +import React from "react"; +import { observer } from "mobx-react"; +import { Link, RouteComponentProps } from "react-router-dom"; +import type { PodStore } from "../+pods/store"; +import { KubeObjectListLayout } from "../kube-object-list-layout"; +import { unitsToBytes } from "../../../common/utils/convertMemory"; +import { stopPropagation } from "../../utils"; +import { KubeObjectStatusIcon } from "../kube-object-status-icon"; +import type { VolumeClaimsRouteParams } from "../../../common/routes"; +import { getDetailsUrl } from "../kube-detail-params"; +import { withInjectables } from "@ogre-tools/injectable-react"; +import type { PersistentVolumeClaimStore } from "./store"; +import type { StorageClassApi } from "../../../common/k8s-api/endpoints"; +import podStoreInjectable from "../+pods/store.injectable"; +import persistentVolumeClaimStoreInjectable from "./store.injectable"; +import storageClassApiInjectable from "../../../common/k8s-api/endpoints/storage-class.api.injectable"; + +enum columnId { + name = "name", + namespace = "namespace", + pods = "pods", + size = "size", + storageClass = "storage-class", + status = "status", + age = "age", +} + +export interface PersistentVolumeClaimsProps extends RouteComponentProps { +} + +interface Dependencies { + podStore: PodStore; + persistentVolumeClaimStore: PersistentVolumeClaimStore; + storageClassApi: StorageClassApi; +} + +const NonInjectedPersistentVolumeClaims = observer(({ podStore, persistentVolumeClaimStore, storageClassApi }: Dependencies & PersistentVolumeClaimsProps) => ( + pvc.getName(), + [columnId.namespace]: pvc => pvc.getNs(), + [columnId.pods]: pvc => pvc.getPods(podStore.items).map(pod => pod.getName()), + [columnId.status]: pvc => pvc.getStatus(), + [columnId.size]: pvc => unitsToBytes(pvc.getStorage()), + [columnId.storageClass]: pvc => pvc.spec.storageClassName, + [columnId.age]: pvc => pvc.getTimeDiffFromNow(), + }} + searchFilters={[ + item => item.getSearchFields(), + item => item.getPods(podStore.items).map(pod => pod.getName()), + ]} + renderHeaderTitle="Persistent Volume Claims" + renderTableHeader={[ + { title: "Name", className: "name", sortBy: columnId.name, id: columnId.name }, + { className: "warning", showWithColumn: columnId.name }, + { title: "Namespace", className: "namespace", sortBy: columnId.namespace, id: columnId.namespace }, + { title: "Storage class", className: "storageClass", sortBy: columnId.storageClass, id: columnId.storageClass }, + { title: "Size", className: "size", sortBy: columnId.size, id: columnId.size }, + { title: "Pods", className: "pods", sortBy: columnId.pods, id: columnId.pods }, + { title: "Age", className: "age", sortBy: columnId.age, id: columnId.age }, + { title: "Status", className: "status", sortBy: columnId.status, id: columnId.status }, + ]} + renderTableContents={pvc => { + const pods = pvc.getPods(podStore.items); + const { storageClassName } = pvc.spec; + const storageClassDetailsUrl = getDetailsUrl(storageClassApi.getUrl({ + name: storageClassName, + })); + + return [ + pvc.getName(), + , + pvc.getNs(), + + {storageClassName} + , + pvc.getStorage(), + pods.map(pod => ( + + {pod.getName()} + + )), + pvc.getAge(), + { title: pvc.getStatus(), className: pvc.getStatus().toLowerCase() }, + ]; + }} + /> +)); + +export const PersistentVolumeClaims = withInjectables(NonInjectedPersistentVolumeClaims, { + getProps: (di, props) => ({ + podStore: di.inject(podStoreInjectable), + persistentVolumeClaimStore: di.inject(persistentVolumeClaimStoreInjectable), + storageClassApi: di.inject(storageClassApiInjectable), + ...props, + }), +}); + diff --git a/src/renderer/components/+storage-volumes/volume-details-list.scss b/src/renderer/components/+persistent-volumes/details-list.scss similarity index 84% rename from src/renderer/components/+storage-volumes/volume-details-list.scss rename to src/renderer/components/+persistent-volumes/details-list.scss index 939432b9d0..b14c79179b 100644 --- a/src/renderer/components/+storage-volumes/volume-details-list.scss +++ b/src/renderer/components/+persistent-volumes/details-list.scss @@ -3,7 +3,7 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -@import "../+storage/storage-mixins"; +@import "../+storage/mixins"; .VolumeDetailsList { position: relative; @@ -12,7 +12,7 @@ margin: 0 (-$margin * 3); &.virtual { - height: 500px; // applicable for 100+ items + height: 500px; // applicable for 100+ items } } diff --git a/src/renderer/components/+persistent-volumes/details-list.tsx b/src/renderer/components/+persistent-volumes/details-list.tsx new file mode 100644 index 0000000000..dde98f9ee0 --- /dev/null +++ b/src/renderer/components/+persistent-volumes/details-list.tsx @@ -0,0 +1,97 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import "./details-list.scss"; + +import React from "react"; +import { observer } from "mobx-react"; +import type { PersistentVolume } from "../../../common/k8s-api/endpoints/persistent-volume.api"; +import { TableRow } from "../table/table-row"; +import { cssNames, prevDefault } from "../../utils"; +import { showDetails } from "../kube-detail-params"; +import { TableCell } from "../table/table-cell"; +import { Spinner } from "../spinner/spinner"; +import { DrawerTitle } from "../drawer/drawer-title"; +import { Table } from "../table/table"; +import { TableHead } from "../table/table-head"; +import kebabCase from "lodash/kebabCase"; +import { withInjectables } from "@ogre-tools/injectable-react"; + +export interface PersistentVolumeDetailsListProps { + persistentVolumes: PersistentVolume[]; + isLoaded: boolean; +} + +enum sortBy { + name = "name", + status = "status", + capacity = "capacity", +} + +interface Dependencies { + +} + +const NonInjectedPersistentVolumeDetailsList = observer(({ persistentVolumes, isLoaded }: Dependencies & PersistentVolumeDetailsListProps) => { + const getTableRow = (uid: string) => { + const volume = persistentVolumes.find(volume => volume.getId() === uid); + + return ( + showDetails(volume.selfLink, false))} + > + {volume.getName()} + {volume.getCapacity()} + {volume.getStatus()} + + ); + }; + + const virtual = persistentVolumes.length > 100; + + if (!persistentVolumes.length) { + return !isLoaded && ; + } + + return ( +
    + + volume.getName(), + [sortBy.capacity]: (volume: PersistentVolume) => volume.getCapacity(), + [sortBy.status]: (volume: PersistentVolume) => volume.getStatus(), + }} + sortByDefault={{ sortBy: sortBy.name, orderBy: "desc" }} + sortSyncWithUrl={false} + getTableRow={getTableRow} + className="box grow" + > + + Name + Capacity + Status + + { + !virtual && persistentVolumes.map(volume => getTableRow(volume.getId())) + } +
    +
    + ); +}); + +export const PersistentVolumeDetailsList = withInjectables(NonInjectedPersistentVolumeDetailsList, { + getProps: (di, props) => ({ + + ...props, + }), +}); diff --git a/src/renderer/components/+storage-volumes/volume-details.scss b/src/renderer/components/+persistent-volumes/details.scss similarity index 100% rename from src/renderer/components/+storage-volumes/volume-details.scss rename to src/renderer/components/+persistent-volumes/details.scss diff --git a/src/renderer/components/+persistent-volumes/details.tsx b/src/renderer/components/+persistent-volumes/details.tsx new file mode 100644 index 0000000000..a888a4056e --- /dev/null +++ b/src/renderer/components/+persistent-volumes/details.tsx @@ -0,0 +1,122 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import "./details.scss"; + +import startCase from "lodash/startCase"; +import React from "react"; +import { Link } from "react-router-dom"; +import { observer } from "mobx-react"; +import { DrawerItem, DrawerTitle } from "../drawer"; +import { Badge } from "../badge"; +import { PersistentVolume, PersistentVolumeClaimApi } from "../../../common/k8s-api/endpoints"; +import type { KubeObjectDetailsProps } from "../kube-object-details"; +import { KubeObjectMeta } from "../kube-object-meta"; +import { getDetailsUrl } from "../kube-detail-params"; +import logger from "../../../common/logger"; +import { withInjectables } from "@ogre-tools/injectable-react"; +import persistentVolumeClaimApiInjectable from "../../../common/k8s-api/endpoints/persistent-volume-claim.api.injectable"; + +export interface PersistentVolumeDetailsProps extends KubeObjectDetailsProps { +} + +interface Dependencies { + persistentVolumeClaimApi: PersistentVolumeClaimApi; +} + +const NonInjectedPersistentVolumeDetails = observer(({ persistentVolumeClaimApi, object: volume }: Dependencies & PersistentVolumeDetailsProps) => { + if (!volume) { + return null; + } + + if (!(volume instanceof PersistentVolume)) { + logger.error("[PersistentVolumeDetails]: passed object that is not an instanceof PersistentVolume", volume); + + return null; + } + + const { accessModes, capacity, persistentVolumeReclaimPolicy, storageClassName, claimRef, flexVolume, mountOptions, nfs } = volume.spec; + + return ( +
    + + + {capacity.storage} + + + {mountOptions && ( + + {mountOptions.join(", ")} + + )} + + + {accessModes.join(", ")} + + + {persistentVolumeReclaimPolicy} + + + {storageClassName} + + + + + + {nfs && ( + <> + + { + Object.entries(nfs).map(([name, value]) => ( + + {value} + + )) + } + + )} + + {flexVolume && ( + <> + + + {flexVolume.driver} + + { + Object.entries(flexVolume.options).map(([name, value]) => ( + + {value} + + )) + } + + )} + + {claimRef && ( + <> + + + {claimRef.kind} + + + + {claimRef.name} + + + + {claimRef.namespace} + + + )} +
    + ); +}); + +export const PersistentVolumeDetails = withInjectables(NonInjectedPersistentVolumeDetails, { + getProps: (di, props) => ({ + persistentVolumeClaimApi: di.inject(persistentVolumeClaimApiInjectable), + ...props, + }), +}); diff --git a/src/renderer/components/+storage-volumes/index.ts b/src/renderer/components/+persistent-volumes/index.ts similarity index 83% rename from src/renderer/components/+storage-volumes/index.ts rename to src/renderer/components/+persistent-volumes/index.ts index 7ca88aa2c1..57659b97aa 100644 --- a/src/renderer/components/+storage-volumes/index.ts +++ b/src/renderer/components/+persistent-volumes/index.ts @@ -4,4 +4,4 @@ */ export * from "./volumes"; -export * from "./volume-details"; +export * from "./details"; diff --git a/src/renderer/components/+persistent-volumes/store.injectable.ts b/src/renderer/components/+persistent-volumes/store.injectable.ts new file mode 100644 index 0000000000..836af5bda9 --- /dev/null +++ b/src/renderer/components/+persistent-volumes/store.injectable.ts @@ -0,0 +1,14 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import apiManagerInjectable from "../../../common/k8s-api/api-manager.injectable"; +import type { PersistentVolumeStore } from "./store"; + +const persistentVolumeStoreInjectable = getInjectable({ + instantiate: (di) => di.inject(apiManagerInjectable).getStore("/api/v1/persistentvolumes") as PersistentVolumeStore, + lifecycle: lifecycleEnum.singleton, +}); + +export default persistentVolumeStoreInjectable; diff --git a/src/renderer/components/+storage-volumes/volumes.store.ts b/src/renderer/components/+persistent-volumes/store.ts similarity index 58% rename from src/renderer/components/+storage-volumes/volumes.store.ts rename to src/renderer/components/+persistent-volumes/store.ts index d1028b24ec..e8f37b0386 100644 --- a/src/renderer/components/+storage-volumes/volumes.store.ts +++ b/src/renderer/components/+persistent-volumes/store.ts @@ -5,14 +5,11 @@ import { KubeObjectStore } from "../../../common/k8s-api/kube-object.store"; import { autoBind } from "../../utils"; -import { PersistentVolume, persistentVolumeApi } from "../../../common/k8s-api/endpoints/persistent-volume.api"; -import { apiManager } from "../../../common/k8s-api/api-manager"; +import type { PersistentVolume, PersistentVolumeApi } from "../../../common/k8s-api/endpoints/persistent-volume.api"; import type { StorageClass } from "../../../common/k8s-api/endpoints/storage-class.api"; -export class PersistentVolumesStore extends KubeObjectStore { - api = persistentVolumeApi; - - constructor() { +export class PersistentVolumeStore extends KubeObjectStore { + constructor(public readonly api:PersistentVolumeApi) { super(); autoBind(this); } @@ -23,6 +20,3 @@ export class PersistentVolumesStore extends KubeObjectStore { ); } } - -export const volumesStore = new PersistentVolumesStore(); -apiManager.registerStore(volumesStore); diff --git a/src/renderer/components/+storage-volumes/volumes.scss b/src/renderer/components/+persistent-volumes/volumes.scss similarity index 93% rename from src/renderer/components/+storage-volumes/volumes.scss rename to src/renderer/components/+persistent-volumes/volumes.scss index 50bb12eb61..1dc8cf3360 100644 --- a/src/renderer/components/+storage-volumes/volumes.scss +++ b/src/renderer/components/+persistent-volumes/volumes.scss @@ -3,7 +3,7 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -@import "../+storage/storage-mixins"; +@import "../+storage/mixins"; .PersistentVolumes { .TableCell { diff --git a/src/renderer/components/+persistent-volumes/volumes.tsx b/src/renderer/components/+persistent-volumes/volumes.tsx new file mode 100644 index 0000000000..7c825769ce --- /dev/null +++ b/src/renderer/components/+persistent-volumes/volumes.tsx @@ -0,0 +1,103 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import "./volumes.scss"; + +import React from "react"; +import { observer } from "mobx-react"; +import { Link, RouteComponentProps } from "react-router-dom"; +import { KubeObjectListLayout } from "../kube-object-list-layout"; +import { getDetailsUrl } from "../kube-detail-params"; +import { stopPropagation } from "../../utils"; +import type { PersistentVolumeStore } from "./store"; +import type { PersistentVolumeClaimApi, StorageClassApi } from "../../../common/k8s-api/endpoints"; +import { KubeObjectStatusIcon } from "../kube-object-status-icon"; +import type { VolumesRouteParams } from "../../../common/routes"; +import { withInjectables } from "@ogre-tools/injectable-react"; +import persistentVolumeStoreInjectable from "./store.injectable"; +import persistentVolumeClaimApiInjectable from "../../../common/k8s-api/endpoints/persistent-volume-claim.api.injectable"; +import storageClassApiInjectable from "../../../common/k8s-api/endpoints/storage-class.api.injectable"; + +enum columnId { + name = "name", + storageClass = "storage-class", + capacity = "capacity", + claim = "claim", + status = "status", + age = "age", +} + +export interface PersistentVolumesProps extends RouteComponentProps { +} + +interface Dependencies { + persistentVolumeStore: PersistentVolumeStore; + persistentVolumeClaimApi: PersistentVolumeClaimApi; + storageClassApi: StorageClassApi; +} + +const NonInjectedPersistentVolumes = observer(({ persistentVolumeStore, persistentVolumeClaimApi, storageClassApi }: Dependencies & PersistentVolumesProps) => ( + item.getName(), + [columnId.storageClass]: item => item.getStorageClass(), + [columnId.capacity]: item => item.getCapacity(true), + [columnId.status]: item => item.getStatus(), + [columnId.age]: item => item.getTimeDiffFromNow(), + }} + searchFilters={[ + item => item.getSearchFields(), + item => item.getClaimRefName(), + ]} + renderHeaderTitle="Persistent Volumes" + renderTableHeader={[ + { title: "Name", className: "name", sortBy: columnId.name, id: columnId.name }, + { className: "warning", showWithColumn: columnId.name }, + { title: "Storage Class", className: "storageClass", sortBy: columnId.storageClass, id: columnId.storageClass }, + { title: "Capacity", className: "capacity", sortBy: columnId.capacity, id: columnId.capacity }, + { title: "Claim", className: "claim", id: columnId.claim }, + { title: "Age", className: "age", sortBy: columnId.age, id: columnId.age }, + { title: "Status", className: "status", sortBy: columnId.status, id: columnId.status }, + ]} + renderTableContents={volume => { + const { claimRef, storageClassName } = volume.spec; + + return [ + volume.getName(), + , + + {storageClassName} + , + volume.getCapacity(), + claimRef && ( + + {claimRef.name} + + ), + volume.getAge(), + { title: volume.getStatus(), className: volume.getStatus().toLowerCase() }, + ]; + }} + /> +)); + +export const PersistentVolumes = withInjectables(NonInjectedPersistentVolumes, { + getProps: (di, props) => ({ + persistentVolumeStore: di.inject(persistentVolumeStoreInjectable), + persistentVolumeClaimApi: di.inject(persistentVolumeClaimApiInjectable), + storageClassApi: di.inject(storageClassApiInjectable), + ...props, + }), +}); diff --git a/src/renderer/components/+config-pod-disruption-budgets/pod-disruption-budgets-details.scss b/src/renderer/components/+pod-disruption-budgets/details.scss similarity index 100% rename from src/renderer/components/+config-pod-disruption-budgets/pod-disruption-budgets-details.scss rename to src/renderer/components/+pod-disruption-budgets/details.scss diff --git a/src/renderer/components/+pod-disruption-budgets/details.tsx b/src/renderer/components/+pod-disruption-budgets/details.tsx new file mode 100644 index 0000000000..b0a1b6d4ea --- /dev/null +++ b/src/renderer/components/+pod-disruption-budgets/details.tsx @@ -0,0 +1,76 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import "./details.scss"; + +import React from "react"; +import { observer } from "mobx-react"; +import { DrawerItem } from "../drawer"; +import { Badge } from "../badge"; +import type { KubeObjectDetailsProps } from "../kube-object-details"; +import { PodDisruptionBudget } from "../../../common/k8s-api/endpoints"; +import { KubeObjectMeta } from "../kube-object-meta"; +import logger from "../../../common/logger"; +import { withInjectables } from "@ogre-tools/injectable-react"; + +export interface PodDisruptionBudgetDetailsProps extends KubeObjectDetailsProps { +} + +interface Dependencies { + +} + +const NonInjectedPodDisruptionBudgetDetails = observer(({ object: pdb }: Dependencies & PodDisruptionBudgetDetailsProps) => { + if (!pdb) { + return null; + } + + if (!(pdb instanceof PodDisruptionBudget)) { + logger.error("[PodDisruptionBudgetDetails]: passed object that is not an instanceof PodDisruptionBudget", pdb); + + return null; + } + + const selectors = pdb.getSelectors(); + + return ( +
    + + + {selectors.length > 0 && + + { + selectors.map(label => ) + } + + } + + + {pdb.getMinAvailable()} + + + + {pdb.getMaxUnavailable()} + + + + {pdb.getCurrentHealthy()} + + + + {pdb.getDesiredHealthy()} + + +
    + ); +}); + +export const PodDisruptionBudgetDetails = withInjectables(NonInjectedPodDisruptionBudgetDetails, { + getProps: (di, props) => ({ + + ...props, + }), +}); + diff --git a/src/renderer/components/+config-pod-disruption-budgets/index.ts b/src/renderer/components/+pod-disruption-budgets/index.ts similarity index 79% rename from src/renderer/components/+config-pod-disruption-budgets/index.ts rename to src/renderer/components/+pod-disruption-budgets/index.ts index 421024572f..fd01db4960 100644 --- a/src/renderer/components/+config-pod-disruption-budgets/index.ts +++ b/src/renderer/components/+pod-disruption-budgets/index.ts @@ -4,4 +4,4 @@ */ export * from "./pod-disruption-budgets"; -export * from "./pod-disruption-budgets-details"; +export * from "./details"; diff --git a/src/renderer/components/+config-pod-disruption-budgets/pod-disruption-budgets.scss b/src/renderer/components/+pod-disruption-budgets/pod-disruption-budgets.scss similarity index 100% rename from src/renderer/components/+config-pod-disruption-budgets/pod-disruption-budgets.scss rename to src/renderer/components/+pod-disruption-budgets/pod-disruption-budgets.scss diff --git a/src/renderer/components/+pod-disruption-budgets/pod-disruption-budgets.tsx b/src/renderer/components/+pod-disruption-budgets/pod-disruption-budgets.tsx new file mode 100644 index 0000000000..7261192491 --- /dev/null +++ b/src/renderer/components/+pod-disruption-budgets/pod-disruption-budgets.tsx @@ -0,0 +1,83 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import "./pod-disruption-budgets.scss"; + +import * as React from "react"; +import { observer } from "mobx-react"; +import type { PodDisruptionBudgetStore } from "./store"; +import type { PodDisruptionBudget } from "../../../common/k8s-api/endpoints/pod-disruption-budget.api"; +import { KubeObjectStatusIcon } from "../kube-object-status-icon"; +import type { KubeObjectDetailsProps } from "../kube-object-details"; +import { KubeObjectListLayout } from "../kube-object-list-layout"; +import { withInjectables } from "@ogre-tools/injectable-react"; +import podDisruptionBudgetStoreInjectable from "./store.injectable"; + +enum columnId { + name = "name", + namespace = "namespace", + minAvailable = "min-available", + maxUnavailable = "max-unavailable", + currentHealthy = "current-healthy", + desiredHealthy = "desired-healthy", + age = "age", +} + +export interface PodDisruptionBudgetsProps extends KubeObjectDetailsProps { +} + +interface Dependencies { + podDisruptionBudgetStore: PodDisruptionBudgetStore; +} + +const NonInjectedPodDisruptionBudgets = observer(({ podDisruptionBudgetStore }: Dependencies & PodDisruptionBudgetsProps) => ( + pdb.getName(), + [columnId.namespace]: pdb => pdb.getNs(), + [columnId.minAvailable]: pdb => pdb.getMinAvailable(), + [columnId.maxUnavailable]: pdb => pdb.getMaxUnavailable(), + [columnId.currentHealthy]: pdb => pdb.getCurrentHealthy(), + [columnId.desiredHealthy]: pdb => pdb.getDesiredHealthy(), + [columnId.age]: pdb => pdb.getAge(), + }} + searchFilters={[ + pdb => pdb.getSearchFields(), + ]} + renderHeaderTitle="Pod Disruption Budgets" + renderTableHeader={[ + { title: "Name", className: "name", sortBy: columnId.name, id: columnId.name }, + { className: "warning", showWithColumn: columnId.name }, + { title: "Namespace", className: "namespace", sortBy: columnId.namespace, id: columnId.namespace }, + { title: "Min Available", className: "min-available", sortBy: columnId.minAvailable, id: columnId.minAvailable }, + { title: "Max Unavailable", className: "max-unavailable", sortBy: columnId.maxUnavailable, id: columnId.maxUnavailable }, + { title: "Current Healthy", className: "current-healthy", sortBy: columnId.currentHealthy, id: columnId.currentHealthy }, + { title: "Desired Healthy", className: "desired-healthy", sortBy: columnId.desiredHealthy, id: columnId.desiredHealthy }, + { title: "Age", className: "age", sortBy: columnId.age, id: columnId.age }, + ]} + renderTableContents={pdb => [ + pdb.getName(), + , + pdb.getNs(), + pdb.getMinAvailable(), + pdb.getMaxUnavailable(), + pdb.getCurrentHealthy(), + pdb.getDesiredHealthy(), + pdb.getAge(), + ]} + /> +)); + +export const PodDisruptionBudgets = withInjectables(NonInjectedPodDisruptionBudgets, { + getProps: (di, props) => ({ + podDisruptionBudgetStore: di.inject(podDisruptionBudgetStoreInjectable), + ...props, + }), +}); + diff --git a/src/renderer/components/+pod-disruption-budgets/store.injectable.ts b/src/renderer/components/+pod-disruption-budgets/store.injectable.ts new file mode 100644 index 0000000000..35e01c8c44 --- /dev/null +++ b/src/renderer/components/+pod-disruption-budgets/store.injectable.ts @@ -0,0 +1,14 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import apiManagerInjectable from "../../../common/k8s-api/api-manager.injectable"; +import type { PodDisruptionBudgetStore } from "./store"; + +const podDisruptionBudgetStoreInjectable = getInjectable({ + instantiate: (di) => di.inject(apiManagerInjectable).getStore("/apis/policy/v1beta1/poddisruptionbudgets") as PodDisruptionBudgetStore, + lifecycle: lifecycleEnum.singleton, +}); + +export default podDisruptionBudgetStoreInjectable; diff --git a/src/renderer/components/+pod-disruption-budgets/store.ts b/src/renderer/components/+pod-disruption-budgets/store.ts new file mode 100644 index 0000000000..ca0e1b4e55 --- /dev/null +++ b/src/renderer/components/+pod-disruption-budgets/store.ts @@ -0,0 +1,14 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { KubeObjectStore } from "../../../common/k8s-api/kube-object.store"; +import type { PodDisruptionBudget, PodDisruptionBudgetApi } from "../../../common/k8s-api/endpoints/pod-disruption-budget.api"; + +export class PodDisruptionBudgetStore extends KubeObjectStore { + constructor(public readonly api:PodDisruptionBudgetApi) { + super(); + } +} + diff --git a/src/renderer/components/+pod-security-policies/pod-security-policy-details.scss b/src/renderer/components/+pod-security-policies/details.scss similarity index 100% rename from src/renderer/components/+pod-security-policies/pod-security-policy-details.scss rename to src/renderer/components/+pod-security-policies/details.scss diff --git a/src/renderer/components/+pod-security-policies/pod-security-policy-details.tsx b/src/renderer/components/+pod-security-policies/details.tsx similarity index 99% rename from src/renderer/components/+pod-security-policies/pod-security-policy-details.tsx rename to src/renderer/components/+pod-security-policies/details.tsx index 71d82cd506..979b04190a 100644 --- a/src/renderer/components/+pod-security-policies/pod-security-policy-details.tsx +++ b/src/renderer/components/+pod-security-policies/details.tsx @@ -3,7 +3,7 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import "./pod-security-policy-details.scss"; +import "./details.scss"; import React from "react"; import { observer } from "mobx-react"; diff --git a/src/renderer/components/+pod-security-policies/index.ts b/src/renderer/components/+pod-security-policies/index.ts index 1a384b409c..60a4fe7ebd 100644 --- a/src/renderer/components/+pod-security-policies/index.ts +++ b/src/renderer/components/+pod-security-policies/index.ts @@ -4,4 +4,4 @@ */ export * from "./pod-security-policies"; -export * from "./pod-security-policy-details"; +export * from "./details"; diff --git a/src/renderer/components/+pod-security-policies/pod-security-policies.store.ts b/src/renderer/components/+pod-security-policies/pod-security-policies.store.ts deleted file mode 100644 index 5b913449cf..0000000000 --- a/src/renderer/components/+pod-security-policies/pod-security-policies.store.ts +++ /dev/null @@ -1,15 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import { PodSecurityPolicy, pspApi } from "../../../common/k8s-api/endpoints"; -import { KubeObjectStore } from "../../../common/k8s-api/kube-object.store"; -import { apiManager } from "../../../common/k8s-api/api-manager"; - -export class PodSecurityPoliciesStore extends KubeObjectStore { - api = pspApi; -} - -export const podSecurityPoliciesStore = new PodSecurityPoliciesStore(); -apiManager.registerStore(podSecurityPoliciesStore); diff --git a/src/renderer/components/+pod-security-policies/pod-security-policies.tsx b/src/renderer/components/+pod-security-policies/pod-security-policies.tsx index 1e596aa258..461749b790 100644 --- a/src/renderer/components/+pod-security-policies/pod-security-policies.tsx +++ b/src/renderer/components/+pod-security-policies/pod-security-policies.tsx @@ -8,8 +8,10 @@ import "./pod-security-policies.scss"; import React from "react"; import { observer } from "mobx-react"; import { KubeObjectListLayout } from "../kube-object-list-layout"; -import { podSecurityPoliciesStore } from "./pod-security-policies.store"; +import type { PodSecurityPolicyStore } from "./store"; import { KubeObjectStatusIcon } from "../kube-object-status-icon"; +import { withInjectables } from "@ogre-tools/injectable-react"; +import podSecurityPolicyStoreInjectable from "./store.injectable"; enum columnId { name = "name", @@ -18,44 +20,51 @@ enum columnId { age = "age", } -@observer -export class PodSecurityPolicies extends React.Component { - render() { - return ( - item.getName(), - [columnId.volumes]: item => item.getVolumes(), - [columnId.privileged]: item => +item.isPrivileged(), - [columnId.age]: item => item.getTimeDiffFromNow(), - }} - searchFilters={[ - item => item.getSearchFields(), - item => item.getVolumes(), - item => Object.values(item.getRules()), - ]} - renderHeaderTitle="Pod Security Policies" - renderTableHeader={[ - { title: "Name", className: "name", sortBy: columnId.name, id: columnId.name }, - { className: "warning", showWithColumn: columnId.name }, - { title: "Privileged", className: "privileged", sortBy: columnId.privileged, id: columnId.privileged }, - { title: "Volumes", className: "volumes", sortBy: columnId.volumes, id: columnId.volumes }, - { title: "Age", className: "age", sortBy: columnId.age, id: columnId.age }, - ]} - renderTableContents={item => { - return [ - item.getName(), - , - item.isPrivileged() ? "Yes" : "No", - item.getVolumes().join(", "), - item.getAge(), - ]; - }} - /> - ); - } +export interface PodSecurityPoliciesProps {} + +interface Dependencies { + podSecurityPolicyStore: PodSecurityPolicyStore; } + +const NonInjectedPodSecurityPolicies = observer(({ podSecurityPolicyStore }: Dependencies & PodSecurityPoliciesProps) => ( + item.getName(), + [columnId.volumes]: item => item.getVolumes(), + [columnId.privileged]: item => +item.isPrivileged(), + [columnId.age]: item => item.getTimeDiffFromNow(), + }} + searchFilters={[ + item => item.getSearchFields(), + item => item.getVolumes(), + item => Object.values(item.getRules()), + ]} + renderHeaderTitle="Pod Security Policies" + renderTableHeader={[ + { title: "Name", className: "name", sortBy: columnId.name, id: columnId.name }, + { className: "warning", showWithColumn: columnId.name }, + { title: "Privileged", className: "privileged", sortBy: columnId.privileged, id: columnId.privileged }, + { title: "Volumes", className: "volumes", sortBy: columnId.volumes, id: columnId.volumes }, + { title: "Age", className: "age", sortBy: columnId.age, id: columnId.age }, + ]} + renderTableContents={item => [ + item.getName(), + , + item.isPrivileged() ? "Yes" : "No", + item.getVolumes().join(", "), + item.getAge(), + ]} + /> +)); + +export const PodSecurityPolicies = withInjectables(NonInjectedPodSecurityPolicies, { + getProps: (di, props) => ({ + podSecurityPolicyStore: di.inject(podSecurityPolicyStoreInjectable), + ...props, + }), +}); + diff --git a/src/renderer/components/+pod-security-policies/store.injectable.ts b/src/renderer/components/+pod-security-policies/store.injectable.ts new file mode 100644 index 0000000000..614c8f0076 --- /dev/null +++ b/src/renderer/components/+pod-security-policies/store.injectable.ts @@ -0,0 +1,14 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import apiManagerInjectable from "../../../common/k8s-api/api-manager.injectable"; +import type { PodSecurityPolicyStore } from "./store"; + +const podSecurityPolicyStoreInjectable = getInjectable({ + instantiate: (di) => di.inject(apiManagerInjectable).getStore("/apis/policy/v1beta1/podsecuritypolicies") as PodSecurityPolicyStore, + lifecycle: lifecycleEnum.singleton, +}); + +export default podSecurityPolicyStoreInjectable; diff --git a/src/renderer/components/+pod-security-policies/store.ts b/src/renderer/components/+pod-security-policies/store.ts new file mode 100644 index 0000000000..6bd12acfa0 --- /dev/null +++ b/src/renderer/components/+pod-security-policies/store.ts @@ -0,0 +1,13 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { PodSecurityPolicy, PodSecurityPolicyApi } from "../../../common/k8s-api/endpoints"; +import { KubeObjectStore } from "../../../common/k8s-api/kube-object.store"; + +export class PodSecurityPolicyStore extends KubeObjectStore { + constructor(public readonly api:PodSecurityPolicyApi) { + super(); + } +} diff --git a/src/renderer/components/+workloads-pods/__tests__/pod-tolerations.test.tsx b/src/renderer/components/+pods/__tests__/pod-tolerations.test.tsx similarity index 68% rename from src/renderer/components/+workloads-pods/__tests__/pod-tolerations.test.tsx rename to src/renderer/components/+pods/__tests__/pod-tolerations.test.tsx index 79deef7dcb..6ba74316c5 100644 --- a/src/renderer/components/+workloads-pods/__tests__/pod-tolerations.test.tsx +++ b/src/renderer/components/+pods/__tests__/pod-tolerations.test.tsx @@ -7,17 +7,10 @@ import React from "react"; import "@testing-library/jest-dom/extend-expect"; import { fireEvent } from "@testing-library/react"; import type { IToleration } from "../../../../common/k8s-api/workload-kube-object"; -import { PodTolerations } from "../pod-tolerations"; +import { PodTolerations } from "../tolerations"; +import type { ConfigurableDependencyInjectionContainer } from "@ogre-tools/injectable"; import { getDiForUnitTesting } from "../../../getDiForUnitTesting"; -import { DiRender, renderFor } from "../../test-utils/renderFor"; -import directoryForLensLocalStorageInjectable - from "../../../../common/directory-for-lens-local-storage/directory-for-lens-local-storage.injectable"; - -jest.mock("electron", () => ({ - app: { - getPath: () => "/foo", - }, -})); +import { type DiRender, renderFor } from "../../test-utils/renderFor"; const tolerations: IToleration[] =[ { @@ -36,17 +29,10 @@ const tolerations: IToleration[] =[ describe("", () => { let render: DiRender; + let di: ConfigurableDependencyInjectionContainer; - beforeEach(async () => { - const di = getDiForUnitTesting({ doGeneralOverrides: true }); - - di.override( - directoryForLensLocalStorageInjectable, - () => "some-directory-for-lens-local-storage", - ); - - await di.runSetups(); - + beforeEach(() => { + di = getDiForUnitTesting(); render = renderFor(di); }); @@ -56,8 +42,11 @@ describe("", () => { expect(container).toBeInstanceOf(HTMLElement); }); - it("shows all tolerations", () => { - const { container } = render(); + it("shows all tolerations", async () => { + const { container, findByTestId } = render(); + + await findByTestId("pod-tolerations-table"); // assert that the table has finally rendered + const rows = container.querySelectorAll(".TableRow"); expect(rows[0].querySelector(".key").textContent).toBe("CriticalAddonsOnly"); @@ -71,9 +60,9 @@ describe("", () => { expect(rows[1].querySelector(".seconds").textContent).toBe("7200"); }); - it("sorts table properly", () => { - const { container, getByText } = render(); - const headCell = getByText("Key"); + it("sorts table properly", async () => { + const { container, findByText } = render(); + const headCell = await findByText("Key"); fireEvent.click(headCell); fireEvent.click(headCell); diff --git a/src/renderer/components/+workloads-pods/pod-charts.tsx b/src/renderer/components/+pods/charts.tsx similarity index 86% rename from src/renderer/components/+workloads-pods/pod-charts.tsx rename to src/renderer/components/+pods/charts.tsx index f5bbe1cdb3..acca49cba1 100644 --- a/src/renderer/components/+workloads-pods/pod-charts.tsx +++ b/src/renderer/components/+pods/charts.tsx @@ -8,12 +8,9 @@ import { observer } from "mobx-react"; import React, { useContext } from "react"; import { isMetricsEmpty, normalizeMetrics } from "../../../common/k8s-api/endpoints/metrics.api"; import { BarChart, cpuOptions, memoryOptions } from "../chart"; -import { IResourceMetricsValue, ResourceMetricsContext } from "../resource-metrics"; +import { ResourceMetricsContext } from "../resource-metrics"; import { NoMetrics } from "../resource-metrics/no-metrics"; -import type { WorkloadKubeObject } from "../../../common/k8s-api/workload-kube-object"; -import type { IPodMetrics } from "../../../common/k8s-api/endpoints"; - export const podMetricTabs = [ "CPU", "Memory", @@ -21,10 +18,8 @@ export const podMetricTabs = [ "Filesystem", ]; -type IContext = IResourceMetricsValue; - export const PodCharts = observer(() => { - const { params: { metrics }, tabId, object } = useContext(ResourceMetricsContext); + const { metrics, tabId, object } = useContext(ResourceMetricsContext); const id = object.getId(); if (!metrics) return null; diff --git a/src/renderer/components/+workloads-pods/container-charts.tsx b/src/renderer/components/+pods/container-charts.tsx similarity index 77% rename from src/renderer/components/+workloads-pods/container-charts.tsx rename to src/renderer/components/+pods/container-charts.tsx index 85182ed516..25676cb0f6 100644 --- a/src/renderer/components/+workloads-pods/container-charts.tsx +++ b/src/renderer/components/+pods/container-charts.tsx @@ -5,19 +5,25 @@ import React, { useContext } from "react"; import { observer } from "mobx-react"; -import type { IPodMetrics } from "../../../common/k8s-api/endpoints"; import { BarChart, cpuOptions, memoryOptions } from "../chart"; import { isMetricsEmpty, normalizeMetrics } from "../../../common/k8s-api/endpoints/metrics.api"; import { NoMetrics } from "../resource-metrics/no-metrics"; -import { IResourceMetricsValue, ResourceMetricsContext } from "../resource-metrics"; -import { ThemeStore } from "../../theme.store"; +import { ResourceMetricsContext } from "../resource-metrics"; import { mapValues } from "lodash"; +import { withInjectables } from "@ogre-tools/injectable-react"; +import type { IComputedValue } from "mobx"; +import activeThemeInjectable from "../../themes/active-theme.injectable"; +import type { Theme } from "../../themes/store"; -type IContext = IResourceMetricsValue; +export interface ContainerChartsProps {} -export const ContainerCharts = observer(() => { - const { params: { metrics }, tabId } = useContext(ResourceMetricsContext); - const { chartCapacityColor } = ThemeStore.getInstance().activeTheme.colors; +interface Dependencies { + activeTheme: IComputedValue; +} + +const NonInjectedContainerCharts = observer(({ activeTheme }: Dependencies & ContainerChartsProps) => { + const { metrics, tabId } = useContext(ResourceMetricsContext); + const { chartCapacityColor } = activeTheme.get().colors; if (!metrics) return null; if (isMetricsEmpty(metrics)) return ; @@ -119,3 +125,10 @@ export const ContainerCharts = observer(() => { /> ); }); + +export const ContainerCharts = withInjectables(NonInjectedContainerCharts, { + getProps: (di, props) => ({ + activeTheme: di.inject(activeThemeInjectable), + ...props, + }), +}); diff --git a/src/renderer/components/+workloads-pods/pod-container-env.scss b/src/renderer/components/+pods/container-env.scss similarity index 100% rename from src/renderer/components/+workloads-pods/pod-container-env.scss rename to src/renderer/components/+pods/container-env.scss diff --git a/src/renderer/components/+workloads-pods/pod-container-env.tsx b/src/renderer/components/+pods/container-env.tsx similarity index 66% rename from src/renderer/components/+workloads-pods/pod-container-env.tsx rename to src/renderer/components/+pods/container-env.tsx index ecf711007c..1700bc6654 100644 --- a/src/renderer/components/+workloads-pods/pod-container-env.tsx +++ b/src/renderer/components/+pods/container-env.tsx @@ -3,41 +3,47 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import "./pod-container-env.scss"; +import "./container-env.scss"; import React, { useEffect, useState } from "react"; import { observer } from "mobx-react"; import type { IPodContainer, Secret } from "../../../common/k8s-api/endpoints"; import { DrawerItem } from "../drawer"; import { autorun } from "mobx"; -import { secretsStore } from "../+config-secrets/secrets.store"; -import { configMapsStore } from "../+config-maps/config-maps.store"; +import type { SecretStore } from "../+secrets/store"; +import type { ConfigMapStore } from "../+config-maps/store"; import { Icon } from "../icon"; import { base64, cssNames, iter } from "../../utils"; import _ from "lodash"; +import { withInjectables } from "@ogre-tools/injectable-react"; +import secretStoreInjectable from "../+secrets/store.injectable"; +import configMapStoreInjectable from "../+config-maps/store.injectable"; -interface Props { +export interface ContainerEnvironmentProps { container: IPodContainer; namespace: string; } -export const ContainerEnvironment = observer((props: Props) => { - const { container: { env, envFrom }, namespace } = props; +interface ContainerEnvironmentDependencies { + secretStore: SecretStore; + configMapStore: ConfigMapStore; +} +const NonInjectedContainerEnvironment = observer(({ container: { env, envFrom }, namespace, secretStore, configMapStore }: ContainerEnvironmentDependencies & ContainerEnvironmentProps) => { useEffect( () => autorun(() => { for (const { valueFrom } of env ?? []) { if (valueFrom?.configMapKeyRef) { - configMapsStore.load({ name: valueFrom.configMapKeyRef.name, namespace }); + configMapStore.load({ name: valueFrom.configMapKeyRef.name, namespace }); } } for (const { configMapRef, secretRef } of envFrom ?? []) { if (secretRef?.name) { - secretsStore.load({ name: secretRef.name, namespace }); + secretStore.load({ name: secretRef.name, namespace }); } if (configMapRef?.name) { - configMapsStore.load({ name: configMapRef.name, namespace }); + configMapStore.load({ name: configMapRef.name, namespace }); } } }), []); @@ -73,7 +79,7 @@ export const ContainerEnvironment = observer((props: Props) => { if (configMapKeyRef) { const { name, key } = configMapKeyRef; - const configMap = configMapsStore.getByName(name, namespace); + const configMap = configMapStore.getByName(name, namespace); secretValue = configMap ? configMap.data[key] : @@ -104,7 +110,7 @@ export const ContainerEnvironment = observer((props: Props) => { }; const renderEnvFromConfigMap = (configMapName: string) => { - const configMap = configMapsStore.getByName(configMapName, namespace); + const configMap = configMapStore.getByName(configMapName, namespace); if (!configMap) return null; @@ -116,7 +122,7 @@ export const ContainerEnvironment = observer((props: Props) => { }; const renderEnvFromSecret = (secretName: string) => { - const secret = secretsStore.getByName(secretName, namespace); + const secret = secretStore.getByName(secretName, namespace); if (!secret) return null; @@ -149,7 +155,15 @@ export const ContainerEnvironment = observer((props: Props) => { ); }); -interface SecretKeyProps { +export const ContainerEnvironment = withInjectables(NonInjectedContainerEnvironment, { + getProps: (di, props) => ({ + secretStore: di.inject(secretStoreInjectable), + configMapStore: di.inject(configMapStoreInjectable), + ...props, + }), +}); + +export interface SecretKeyProps { reference: { name: string; key: string; @@ -157,15 +171,18 @@ interface SecretKeyProps { namespace: string; } -const SecretKey = (props: SecretKeyProps) => { - const { reference: { name, key }, namespace } = props; +interface SecretKeyDependencies { + secretStore: SecretStore; +} + +const NonInjectedSecretKey = observer(({ secretStore, reference: { name, key }, namespace }: SecretKeyDependencies & SecretKeyProps) => { const [loading, setLoading] = useState(false); const [secret, setSecret] = useState(); const showKey = async (evt: React.MouseEvent) => { evt.preventDefault(); setLoading(true); - const secret = await secretsStore.load({ name, namespace }); + const secret = await secretStore.load({ name, namespace }); setLoading(false); setSecret(secret); @@ -186,4 +203,11 @@ const SecretKey = (props: SecretKeyProps) => { /> ); -}; +}); + +export const SecretKey = withInjectables(NonInjectedSecretKey, { + getProps: (di, props) => ({ + secretStore: di.inject(secretStoreInjectable), + ...props, + }), +}); diff --git a/src/renderer/components/+workloads-pods/pod-details-affinities.tsx b/src/renderer/components/+pods/details-affinities.tsx similarity index 100% rename from src/renderer/components/+workloads-pods/pod-details-affinities.tsx rename to src/renderer/components/+pods/details-affinities.tsx diff --git a/src/renderer/components/+workloads-pods/pod-details-container.scss b/src/renderer/components/+pods/details-container.scss similarity index 100% rename from src/renderer/components/+workloads-pods/pod-details-container.scss rename to src/renderer/components/+pods/details-container.scss diff --git a/src/renderer/components/+pods/details-container.tsx b/src/renderer/components/+pods/details-container.tsx new file mode 100644 index 0000000000..1709ba3fd6 --- /dev/null +++ b/src/renderer/components/+pods/details-container.tsx @@ -0,0 +1,203 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import "./details-container.scss"; + +import React, { useEffect } from "react"; +import { IPodContainer, IPodContainerStatus, Pod } from "../../../common/k8s-api/endpoints"; +import { DrawerItem } from "../drawer"; +import { cssNames } from "../../utils"; +import { StatusBrick } from "../status-brick"; +import { Badge } from "../badge"; +import { ContainerEnvironment } from "./container-env"; +import { ResourceMetrics } from "../resource-metrics"; +import type { IMetrics } from "../../../common/k8s-api/endpoints/metrics.api"; +import { ContainerCharts } from "./container-charts"; +import { LocaleDate } from "../locale-date"; +import { ClusterMetricsResourceType } from "../../../common/cluster-types"; +import { observer } from "mobx-react"; +import { withInjectables } from "@ogre-tools/injectable-react"; +import watchPortForwardsInjectable from "../../port-forward/watch-port-forwards.injectable"; +import isMetricHiddenInjectable from "../../utils/is-metrics-hidden.injectable"; +import logger from "../../../common/logger"; +import { ContainerPort } from "../container-port/view"; + +export interface PodDetailsContainerProps { + pod: Pod | null | undefined; + container: IPodContainer | null | undefined; + metrics?: { [key: string]: IMetrics }; +} + +interface Dependencies { + watchPortForwards: () => () => void; + isMetricHidden: boolean; +} + +const NonInjectedPodDetailsContainer = observer(({ watchPortForwards, isMetricHidden, pod, container, metrics }: Dependencies & PodDetailsContainerProps) => { + useEffect(() => watchPortForwards(), []); + + if (!pod || !container) { + return null; + } + + if (!(pod instanceof Pod)) { + logger.error("[PodDetails]: passed object that is not an instanceof Pod", pod); + + return null; + } + + const renderStatus = (state: string, status: IPodContainerStatus) => { + const ready = status ? status.ready : ""; + + return ( + + {state}{ready ? `, ready` : ""} + {state === "terminated" ? ` - ${status.state.terminated.reason} (exit code: ${status.state.terminated.exitCode})` : ""} + + ); + }; + + const renderLastState = (lastState: string, status: IPodContainerStatus) => { + if (lastState === "terminated") { + return ( + + {lastState}
    + Reason: {status.lastState.terminated.reason} - exit code: {status.lastState.terminated.exitCode}
    + Started at: {}
    + Finished at: {}
    +
    + ); + } + + return null; + }; + + const { name, image, imagePullPolicy, ports = [], volumeMounts = [], command, args } = container; + const status = pod.getContainerStatuses().find(status => status.name === container.name); + const state = status ? Object.keys(status.state)[0] : ""; + const lastState = status ? Object.keys(status.lastState)[0] : ""; + const ready = status ? status.ready : ""; + const imageId = status? status.imageID : ""; + const liveness = pod.getLivenessProbe(container); + const readiness = pod.getReadinessProbe(container); + const startup = pod.getStartupProbe(container); + const isInitContainer = !!pod.getInitContainers().find(c => c.name == name); + const metricTabs = [ + "CPU", + "Memory", + "Filesystem", + ]; + + return ( +
    +
    + {name} +
    + {(!isMetricHidden && !isInitContainer) &&( + + + + )} + {status && ( + + {renderStatus(state, status)} + + )} + {lastState && ( + + {renderLastState(lastState, status)} + + )} + + + + {imagePullPolicy && imagePullPolicy !== "IfNotPresent" &&( + + {imagePullPolicy} + + )} + {ports.length > 0 &&( + + { + ports.map((port) => ( + + )) + } + + )} + {} + {volumeMounts.length > 0 &&( + + { + volumeMounts.map(({ name, mountPath, readOnly }) => ( + + {mountPath} + from {name} ({readOnly ? "ro" : "rw"}) + + )) + } + + )} + {liveness.length > 0 &&( + + { + liveness.map((value, index) => ( + + )) + } + + )} + {readiness.length > 0 &&( + + { + readiness.map((value, index) => ( + + )) + } + + )} + {startup.length > 0 &&( + + { + startup.map((value, index) => ( + + )) + } + + )} + {command &&( + + {command.join(" ")} + + )} + {args &&( + + {args.join(" ")} + + )} +
    + ); +}); + +export const PodDetailsContainer = withInjectables(NonInjectedPodDetailsContainer, { + getProps: (di, props) => ({ + watchPortForwards: di.inject(watchPortForwardsInjectable), + isMetricHidden: di.inject(isMetricHiddenInjectable, { + metricType: ClusterMetricsResourceType.Container, + }), + ...props, + }), +}); diff --git a/src/renderer/components/+workloads-pods/pod-details-list.scss b/src/renderer/components/+pods/details-list.scss similarity index 100% rename from src/renderer/components/+workloads-pods/pod-details-list.scss rename to src/renderer/components/+pods/details-list.scss diff --git a/src/renderer/components/+pods/details-list.tsx b/src/renderer/components/+pods/details-list.tsx new file mode 100644 index 0000000000..fccf3d2c67 --- /dev/null +++ b/src/renderer/components/+pods/details-list.tsx @@ -0,0 +1,204 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import "./details-list.scss"; + +import React, { useEffect, useState } from "react"; +import kebabCase from "lodash/kebabCase"; +import { observer } from "mobx-react"; +import type { Pod, PodMetrics, PodMetricsApi } from "../../../common/k8s-api/endpoints"; +import { bytesToUnits, cpuUnitsToNumber, cssNames, interval, prevDefault, unitsToBytes } from "../../utils"; +import { LineProgress } from "../line-progress"; +import type { KubeObject } from "../../../common/k8s-api/kube-object"; +import { Table, TableCell, TableHead, TableRow } from "../table"; +import { Spinner } from "../spinner"; +import { DrawerTitle } from "../drawer"; +import { KubeObjectStatusIcon } from "../kube-object-status-icon"; +import { showDetails } from "../kube-detail-params"; +import { withInjectables } from "@ogre-tools/injectable-react"; +import podMetricsApiInjectable from "../../../common/k8s-api/endpoints/pod-metrics.api.injectable"; +import logger from "../../../common/logger"; + +enum sortBy { + name = "name", + namespace = "namespace", + cpu = "cpu", + memory = "memory", +} + +export interface PodDetailsListProps { + pods: Pod[]; + owner: KubeObject; + maxCpu?: number; + maxMemory?: number; + isLoaded: boolean; +} + +interface Dependencies { + podMetricsApi: PodMetricsApi; +} + +const NonInjectedPodDetailsList = observer(({ pods, owner, maxCpu, maxMemory, isLoaded, podMetricsApi }: Dependencies & PodDetailsListProps) => { + const [kubeMetrics, setKubeMetrics] = useState([]); + const [metricsPoller] = useState(interval(120, async () => { + try { + setKubeMetrics(await podMetricsApi.list({ namespace: owner.getNs() })); + } catch (error) { + logger.warn("loadKubeMetrics failed", error); + } + })); + + useEffect(() => { + metricsPoller.start(true); + + return () => metricsPoller.stop(); + }, []); + useEffect(() => metricsPoller.restart(true), [owner]); + + const renderCpuUsage = (id: string, usage: number) => { + const value = usage.toFixed(3); + const tooltip = ( +

    CPU: {Math.ceil(usage * 100) / maxCpu}%
    {usage.toFixed(3)}

    + ); + + if (!maxCpu) { + if (parseFloat(value) === 0) return 0; + + return value; + } + + return ( + + ); + }; + + const renderMemoryUsage = (id: string, usage: number) => { + const tooltip = ( +

    Memory: {Math.ceil(usage * 100 / maxMemory)}%
    {bytesToUnits(usage, 3)}

    + ); + + if (!maxMemory) return usage ? bytesToUnits(usage) : 0; + + return ( + + ); + }; + + const getPodKubeMetrics = (pod: Pod) => { + const containers = pod.getContainers(); + const empty = { cpu: 0, memory: 0 }; + const metrics = kubeMetrics.find(metric => ( + metric.getName() === pod.getName() + && metric.getNs() === pod.getNs() + )); + + if (!metrics) return empty; + + return containers.reduce((total, container) => { + const metric = metrics.containers.find(item => item.name == container.name); + let cpu = "0"; + let memory = "0"; + + if (metric && metric.usage) { + cpu = metric.usage.cpu || "0"; + memory = metric.usage.memory || "0"; + } + + return { + cpu: total.cpu + cpuUnitsToNumber(cpu), + memory: total.memory + unitsToBytes(memory), + }; + }, empty); + }; + + const getTableRow = (uid: string) => { + const pod = pods.find(pod => pod.getId() == uid); + const metrics = getPodKubeMetrics(pod); + + return ( + showDetails(pod.selfLink, false))} + > + {pod.getName()} + + {pod.getNs()} + {pod.getRunningContainers().length}/{pod.getContainers().length} + {renderCpuUsage(`cpu-${pod.getId()}`, metrics.cpu)} + {renderMemoryUsage(`memory-${pod.getId()}`, metrics.memory)} + {pod.getStatusMessage()} + + ); + }; + + if (!isLoaded) { + return ( +
    + +
    + ); + } + + if (!pods.length) { + return null; + } + + const virtual = pods.length > 20; + + return ( +
    + + pod.getName(), + [sortBy.namespace]: pod => pod.getNs(), + [sortBy.cpu]: pod => getPodKubeMetrics(pod).cpu, + [sortBy.memory]: pod => getPodKubeMetrics(pod).memory, + }} + sortByDefault={{ sortBy: sortBy.cpu, orderBy: "desc" }} + sortSyncWithUrl={false} + getTableRow={getTableRow} + renderRow={!virtual && (pod => getTableRow(pod.getId()))} + className="box grow" + > + + Name + + Namespace + Ready + CPU + Memory + Status + +
    +
    + ); +}); + +export const PodDetailsList = withInjectables(NonInjectedPodDetailsList, { + getProps: (di, props) => ({ + podMetricsApi: di.inject(podMetricsApiInjectable), + ...props, + }), +}); diff --git a/src/renderer/components/+workloads-pods/pod-details-secrets.scss b/src/renderer/components/+pods/details-secrets.scss similarity index 100% rename from src/renderer/components/+workloads-pods/pod-details-secrets.scss rename to src/renderer/components/+pods/details-secrets.scss diff --git a/src/renderer/components/+pods/details-secrets.tsx b/src/renderer/components/+pods/details-secrets.tsx new file mode 100644 index 0000000000..1ccdad41d5 --- /dev/null +++ b/src/renderer/components/+pods/details-secrets.tsx @@ -0,0 +1,71 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import "./details-secrets.scss"; + +import React, { useEffect, useState } from "react"; +import { Link } from "react-router-dom"; +import { autorun, observable } from "mobx"; +import { observer } from "mobx-react"; +import type { Pod, Secret, SecretApi } from "../../../common/k8s-api/endpoints"; +import { getDetailsUrl } from "../kube-detail-params"; +import { withInjectables } from "@ogre-tools/injectable-react"; +import secretApiInjectable from "../../../common/k8s-api/endpoints/secret.api.injectable"; + +export interface PodDetailsSecretsProps { + pod: Pod; +} + +interface Dependencies { + secretApi: SecretApi; +} + +const NonInjectedPodDetailsSecrets = observer(({ secretApi, pod }: Dependencies & PodDetailsSecretsProps) => { + const [secrets] = useState(observable.map()); + + useEffect(() => autorun(async () => { + const getSecrets = await Promise.all( + pod.getSecrets().map(secretName => secretApi.get({ + name: secretName, + namespace: pod.getNs(), + })), + ); + + for (const secret of getSecrets) { + if (secret) { + secrets.set(secret.getName(), secret); + } + } + }), []); + + return ( +
    + { + pod.getSecrets().map(secretName => { + const secret = secrets.get(secretName); + + return secret + ? ( + + {secret.getName()} + + ) + : ( + + {secretName} + + ); + }) + } +
    + ); +}); + +export const PodDetailsSecrets = withInjectables(NonInjectedPodDetailsSecrets, { + getProps: (di, props) => ({ + secretApi: di.inject(secretApiInjectable), + ...props, + }), +}); diff --git a/src/renderer/components/+workloads-pods/pod-details-statuses.scss b/src/renderer/components/+pods/details-statuses.scss similarity index 100% rename from src/renderer/components/+workloads-pods/pod-details-statuses.scss rename to src/renderer/components/+pods/details-statuses.scss diff --git a/src/renderer/components/+workloads-pods/pod-details-statuses.tsx b/src/renderer/components/+pods/details-statuses.tsx similarity index 95% rename from src/renderer/components/+workloads-pods/pod-details-statuses.tsx rename to src/renderer/components/+pods/details-statuses.tsx index 056ae41d21..21d8d6c848 100644 --- a/src/renderer/components/+workloads-pods/pod-details-statuses.tsx +++ b/src/renderer/components/+pods/details-statuses.tsx @@ -3,7 +3,7 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import "./pod-details-statuses.scss"; +import "./details-statuses.scss"; import React from "react"; import countBy from "lodash/countBy"; import kebabCase from "lodash/kebabCase"; diff --git a/src/renderer/components/+workloads-pods/pod-details-tolerations.scss b/src/renderer/components/+pods/details-tolerations.scss similarity index 100% rename from src/renderer/components/+workloads-pods/pod-details-tolerations.scss rename to src/renderer/components/+pods/details-tolerations.scss diff --git a/src/renderer/components/+workloads-pods/pod-details-tolerations.tsx b/src/renderer/components/+pods/details-tolerations.tsx similarity index 90% rename from src/renderer/components/+workloads-pods/pod-details-tolerations.tsx rename to src/renderer/components/+pods/details-tolerations.tsx index dec8d7d79c..9c9d8f0a9a 100644 --- a/src/renderer/components/+workloads-pods/pod-details-tolerations.tsx +++ b/src/renderer/components/+pods/details-tolerations.tsx @@ -3,11 +3,11 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import "./pod-details-tolerations.scss"; +import "./details-tolerations.scss"; import React from "react"; import { DrawerParamToggler, DrawerItem } from "../drawer"; import type { WorkloadKubeObject } from "../../../common/k8s-api/workload-kube-object"; -import { PodTolerations } from "./pod-tolerations"; +import { PodTolerations } from "./tolerations"; interface Props { workload: WorkloadKubeObject; diff --git a/src/renderer/components/+workloads-pods/pod-details.scss b/src/renderer/components/+pods/details.scss similarity index 100% rename from src/renderer/components/+workloads-pods/pod-details.scss rename to src/renderer/components/+pods/details.scss diff --git a/src/renderer/components/+pods/details.tsx b/src/renderer/components/+pods/details.tsx new file mode 100644 index 0000000000..ac9ab4d74b --- /dev/null +++ b/src/renderer/components/+pods/details.tsx @@ -0,0 +1,246 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import "./details.scss"; + +import React, { useEffect, useState } from "react"; +import kebabCase from "lodash/kebabCase"; +import { observer } from "mobx-react"; +import { Link } from "react-router-dom"; +import { IPodMetrics, Pod, getMetricsForPods, NodeApi, PersistentVolumeClaimApi, ConfigMapApi } from "../../../common/k8s-api/endpoints"; +import { DrawerItem, DrawerTitle } from "../drawer"; +import { Badge } from "../badge"; +import { cssNames, toJS } from "../../utils"; +import { PodDetailsContainer } from "./details-container"; +import { PodDetailsAffinities } from "./details-affinities"; +import { PodDetailsTolerations } from "./details-tolerations"; +import { Icon } from "../icon"; +import { PodDetailsSecrets } from "./details-secrets"; +import { ResourceMetrics } from "../resource-metrics"; +import type { KubeObjectDetailsProps } from "../kube-object-details"; +import { getItemMetrics } from "../../../common/k8s-api/endpoints/metrics.api"; +import { PodCharts, podMetricTabs } from "./charts"; +import { KubeObjectMeta } from "../kube-object-meta"; +import { ClusterMetricsResourceType } from "../../../common/cluster-types"; +import { getDetailsUrl } from "../kube-detail-params"; +import logger from "../../../common/logger"; +import { withInjectables } from "@ogre-tools/injectable-react"; +import nodeApiInjectable from "../../../common/k8s-api/endpoints/node.api.injectable"; +import persistentVolumeClaimApiInjectable from "../../../common/k8s-api/endpoints/persistent-volume-claim.api.injectable"; +import configMapApiInjectable from "../../../common/k8s-api/endpoints/configmap.api.injectable"; +import isMetricHiddenInjectable from "../../utils/is-metrics-hidden.injectable"; + +export interface PodDetailsProps extends KubeObjectDetailsProps { +} + +interface Dependencies { + nodeApi: NodeApi; + persistentVolumeClaimApi: PersistentVolumeClaimApi; + configMapApi: ConfigMapApi; + isMetricHidden: boolean; +} + +const NonInjectedPodDetails = observer(({ isMetricHidden, nodeApi, persistentVolumeClaimApi, configMapApi, object: pod }: Dependencies & PodDetailsProps) => { + const [metrics, setMetrics] = useState(null); + const [containerMetrics, setContainerMetrics] = useState(null); + + useEffect(() => { + setMetrics(null); + setContainerMetrics(null); + }, [pod]); + + if (!pod) { + return null; + } + + if (!(pod instanceof Pod)) { + logger.error("[PodDetails]: passed object that is not an instanceof Pod", pod); + + return null; + } + + const loadMetrics = async () => { + setMetrics(await getMetricsForPods([pod], pod.getNs())); + setContainerMetrics(await getMetricsForPods([pod], pod.getNs(), "container, namespace")); + }; + const { status, spec } = pod; + const { conditions, podIP } = status; + const podIPs = pod.getIPs(); + const { nodeName } = spec; + const nodeSelector = pod.getNodeSelectors(); + const volumes = pod.getVolumes(); + const initContainers = pod.getInitContainers(); + + return ( +
    + {!isMetricHidden && ( + + + + )} + + + {pod.getStatusMessage()} + + + {nodeName && ( + + {nodeName} + + )} + + + {podIP} + + + + {pod.getPriorityClassName()} + + + {pod.getQosClass()} + + {conditions && + + {conditions.map(({ type, status, lastTransitionTime }) => ( + + ))} + + } + {nodeSelector.length > 0 && + + { + nodeSelector.map(label => ( + + )) + } + + } + + + + {pod.getSecrets().length > 0 && ( + + + + )} + + {initContainers.length > 0 && ( + <> + + {initContainers.map(container => ( + + ))} + + )} + + + { + pod.getContainers().map(container => ( + + )) + } + + {volumes.length > 0 && ( + <> + + {volumes.map(volume => { + const claimName = volume.persistentVolumeClaim ? volume.persistentVolumeClaim.claimName : null; + const configMap = volume.configMap ? volume.configMap.name : null; + const type = Object.keys(volume)[1]; + + return ( +
    +
    + + {volume.name} +
    + + {type} + + { type == "configMap" && ( +
    + {configMap && ( + + {configMap} + + + )} +
    + )} + { type === "emptyDir" && ( +
    + { volume.emptyDir.medium && ( + + {volume.emptyDir.medium} + + )} + { volume.emptyDir.sizeLimit && ( + + {volume.emptyDir.sizeLimit} + + )} +
    + )} + + {claimName && ( + + {claimName} + + + )} +
    + ); + })} + + )} +
    + ); +}); + +export const PodDetails = withInjectables(NonInjectedPodDetails, { + getProps: (di, props) => ({ + nodeApi: di.inject(nodeApiInjectable), + persistentVolumeClaimApi: di.inject(persistentVolumeClaimApiInjectable), + configMapApi: di.inject(configMapApiInjectable), + isMetricHidden: di.inject(isMetricHiddenInjectable, { + metricType: ClusterMetricsResourceType.Pod, + }), + ...props, + }), +}); diff --git a/src/renderer/components/+pods/get-pod-by-id.injectable.ts b/src/renderer/components/+pods/get-pod-by-id.injectable.ts new file mode 100644 index 0000000000..268032d346 --- /dev/null +++ b/src/renderer/components/+pods/get-pod-by-id.injectable.ts @@ -0,0 +1,27 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import type { Pod } from "../../../common/k8s-api/endpoints"; +import { bind } from "../../utils"; +import type { PodStore } from "./store"; +import podStoreInjectable from "./store.injectable"; + +interface Dependencies { + podStore: PodStore; +} + +function getPodById({ podStore }: Dependencies, id: string): Pod | undefined { + return podStore.getById(id); +} + +const getPodByIdInjectable = getInjectable({ + instantiate: (di) => bind(getPodById, null, { + podStore: di.inject(podStoreInjectable), + }), + lifecycle: lifecycleEnum.singleton, +}); + +export default getPodByIdInjectable; + diff --git a/src/renderer/api/catalog-entity-registry/catalog-entity-registry.injectable.ts b/src/renderer/components/+pods/get-pods-by-owner-id.injectable.ts similarity index 54% rename from src/renderer/api/catalog-entity-registry/catalog-entity-registry.injectable.ts rename to src/renderer/components/+pods/get-pods-by-owner-id.injectable.ts index efdecf8bc5..7535a94f60 100644 --- a/src/renderer/api/catalog-entity-registry/catalog-entity-registry.injectable.ts +++ b/src/renderer/components/+pods/get-pods-by-owner-id.injectable.ts @@ -3,11 +3,11 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; -import { catalogEntityRegistry } from "../catalog-entity-registry"; +import podStoreInjectable from "./store.injectable"; -const catalogEntityRegistryInjectable = getInjectable({ - instantiate: () => catalogEntityRegistry, +const getPodsByOwnerIdInjectable = getInjectable({ + instantiate: (di) => di.inject(podStoreInjectable).getPodsByOwnerId, lifecycle: lifecycleEnum.singleton, }); -export default catalogEntityRegistryInjectable; +export default getPodsByOwnerIdInjectable; diff --git a/src/renderer/components/+workloads-pods/index.ts b/src/renderer/components/+pods/index.ts similarity index 84% rename from src/renderer/components/+workloads-pods/index.ts rename to src/renderer/components/+pods/index.ts index 7a97e64c8d..87a09a42ad 100644 --- a/src/renderer/components/+workloads-pods/index.ts +++ b/src/renderer/components/+pods/index.ts @@ -4,4 +4,4 @@ */ export * from "./pods"; -export * from "./pod-details"; +export * from "./details"; diff --git a/src/renderer/components/+pods/pods.injectable.ts b/src/renderer/components/+pods/pods.injectable.ts new file mode 100644 index 0000000000..6a1646ad89 --- /dev/null +++ b/src/renderer/components/+pods/pods.injectable.ts @@ -0,0 +1,14 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { computed } from "mobx"; +import podStoreInjectable from "./store.injectable"; + +const podsInjectable = getInjectable({ + instantiate: (di) => computed(() => [...di.inject(podStoreInjectable).items]), + lifecycle: lifecycleEnum.singleton, +}); + +export default podsInjectable; diff --git a/src/renderer/components/+workloads-pods/pods.scss b/src/renderer/components/+pods/pods.scss similarity index 93% rename from src/renderer/components/+workloads-pods/pods.scss rename to src/renderer/components/+pods/pods.scss index 9a6ad8c8ab..5ba520b8ac 100644 --- a/src/renderer/components/+workloads-pods/pods.scss +++ b/src/renderer/components/+pods/pods.scss @@ -3,7 +3,7 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -@import "../+workloads/workloads-mixins"; +@import "../+workloads/mixins"; .Pods { .TableCell { diff --git a/src/renderer/components/+pods/pods.tsx b/src/renderer/components/+pods/pods.tsx new file mode 100644 index 0000000000..cdec90f308 --- /dev/null +++ b/src/renderer/components/+pods/pods.tsx @@ -0,0 +1,163 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import "./pods.scss"; + +import React, { Fragment } from "react"; +import { observer } from "mobx-react"; +import { Link } from "react-router-dom"; +import type { RouteComponentProps } from "react-router"; +import type { EventStore } from "../+events/store"; +import { KubeObjectListLayout } from "../kube-object-list-layout"; +import type { NodeApi, Pod } from "../../../common/k8s-api/endpoints"; +import { StatusBrick } from "../status-brick"; +import { cssNames, getConvertedParts, stopPropagation } from "../../utils"; +import toPairs from "lodash/toPairs"; +import startCase from "lodash/startCase"; +import kebabCase from "lodash/kebabCase"; +import type { ApiManager } from "../../../common/k8s-api/api-manager"; +import { KubeObjectStatusIcon } from "../kube-object-status-icon"; +import { Badge } from "../badge"; +import type { PodsRouteParams } from "../../../common/routes"; +import { getDetailsUrl } from "../kube-detail-params"; +import { withInjectables } from "@ogre-tools/injectable-react"; +import apiManagerInjectable from "../../../common/k8s-api/api-manager.injectable"; +import type { PodsStore } from "../../../extensions/renderer-api/k8s-api"; +import nodeApiInjectable from "../../../common/k8s-api/endpoints/node.api.injectable"; +import podStoreInjectable from "./store.injectable"; +import eventStoreInjectable from "../+events/store.injectable"; + +enum columnId { + name = "name", + namespace = "namespace", + containers = "containers", + restarts = "restarts", + age = "age", + qos = "qos", + node = "node", + owners = "owners", + status = "status", +} + +export interface PodsProps extends RouteComponentProps { +} + +interface Dependencies { + apiManager: ApiManager; + podStore: PodsStore; + eventStore: EventStore; + nodeApi: NodeApi; +} + +const NonInjectedPods = observer(({ apiManager, podStore, eventStore, nodeApi }: Dependencies & PodsProps) => { + const renderContainersStatus = (pod: Pod) => ( + pod.getContainerStatuses() + .map(({ name, state, ready }) => ( + + ( + +
    + {name} ({status}{ready ? ", ready" : ""}) +
    + {toPairs(state[status]).map(([name, value]) => ( +
    +
    {startCase(name)}
    +
    {value}
    +
    + ))} +
    + )), + }} + /> +
    + )) + ); + + return ( + getConvertedParts(pod.getName()), + [columnId.namespace]: pod => pod.getNs(), + [columnId.containers]: pod => pod.getContainers().length, + [columnId.restarts]: pod => pod.getRestartsCount(), + [columnId.owners]: pod => pod.getOwnerRefs().map(ref => ref.kind), + [columnId.qos]: pod => pod.getQosClass(), + [columnId.node]: pod => pod.getNodeName(), + [columnId.age]: pod => pod.getTimeDiffFromNow(), + [columnId.status]: pod => pod.getStatusMessage(), + }} + searchFilters={[ + pod => pod.getSearchFields(), + pod => pod.getStatusMessage(), + pod => pod.status.podIP, + pod => pod.getNodeName(), + ]} + renderHeaderTitle="Pods" + renderTableHeader={[ + { title: "Name", className: "name", sortBy: columnId.name, id: columnId.name }, + { className: "warning", showWithColumn: columnId.name }, + { title: "Namespace", className: "namespace", sortBy: columnId.namespace, id: columnId.namespace }, + { title: "Containers", className: "containers", sortBy: columnId.containers, id: columnId.containers }, + { title: "Restarts", className: "restarts", sortBy: columnId.restarts, id: columnId.restarts }, + { title: "Controlled By", className: "owners", sortBy: columnId.owners, id: columnId.owners }, + { title: "Node", className: "node", sortBy: columnId.node, id: columnId.node }, + { title: "QoS", className: "qos", sortBy: columnId.qos, id: columnId.qos }, + { title: "Age", className: "age", sortBy: columnId.age, id: columnId.age }, + { title: "Status", className: "status", sortBy: columnId.status, id: columnId.status }, + ]} + renderTableContents={pod => [ + , + , + pod.getNs(), + renderContainersStatus(pod), + pod.getRestartsCount(), + pod.getOwnerRefs().map(ref => { + const { kind, name } = ref; + const detailsLink = getDetailsUrl(apiManager.lookupApiLink(ref, pod)); + + return ( + + + {kind} + + + ); + }), + pod.getNodeName() ? + + + {pod.getNodeName()} + + + : "", + pod.getQosClass(), + pod.getAge(), + { title: pod.getStatusMessage(), className: kebabCase(pod.getStatusMessage()) }, + ]} + /> + ); +}); + +export const Pods = withInjectables(NonInjectedPods, { + getProps: (di, props) => ({ + apiManager: di.inject(apiManagerInjectable), + podStore: di.inject(podStoreInjectable), + eventStore: di.inject(eventStoreInjectable), + nodeApi: di.inject(nodeApiInjectable), + ...props, + }), +}); + diff --git a/src/renderer/components/+pods/store.injectable.ts b/src/renderer/components/+pods/store.injectable.ts new file mode 100644 index 0000000000..cee3d41119 --- /dev/null +++ b/src/renderer/components/+pods/store.injectable.ts @@ -0,0 +1,15 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import apiManagerInjectable from "../../../common/k8s-api/api-manager.injectable"; +import type { PodStore } from "./store"; + +const podStoreInjectable = getInjectable({ + instantiate: (di) => di.inject(apiManagerInjectable).getStore("/api/v1/pods") as PodStore, + lifecycle: lifecycleEnum.singleton, +}); + +export default podStoreInjectable; diff --git a/src/renderer/components/+pods/store.ts b/src/renderer/components/+pods/store.ts new file mode 100644 index 0000000000..aea46550dc --- /dev/null +++ b/src/renderer/components/+pods/store.ts @@ -0,0 +1,59 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import countBy from "lodash/countBy"; +import { KubeObjectStore } from "../../../common/k8s-api/kube-object.store"; +import { autoBind } from "../../utils"; +import type { Pod, PodApi } from "../../../common/k8s-api/endpoints"; +import type { WorkloadKubeObject } from "../../../common/k8s-api/workload-kube-object"; + +export class PodStore extends KubeObjectStore { + constructor(public readonly api:PodApi) { + super(); + autoBind(this); + } + + /** + * @deprecated This function has been removed and returns nothing + */ + loadKubeMetrics(namespace?: string) { + void namespace; + console.warn("loadKubeMetrics has been removed and does nothing"); + + return Promise.resolve(); + } + + getPodsByOwner(workload: WorkloadKubeObject | null | undefined): Pod[] { + if (!workload) return []; + + return this.items.filter(pod => { + const owners = pod.getOwnerRefs(); + + return owners.find(owner => owner.uid === workload.getId()); + }); + } + + getPodsByOwnerId = (workloadId: string): Pod[] => { + return this.items.filter(pod => pod.getOwnerRefs().find(owner => owner.uid === workloadId)); + }; + + getPodsByNode(node: string) { + return this.items.filter(pod => pod.spec.nodeName === node); + } + + getStatuses(pods: Pod[]) { + return countBy(pods.map(pod => pod.getStatus()).sort().reverse()); + } + + /** + * @deprecated This function has been removed and returns nothing + */ + getPodKubeMetrics(pod: Pod) { + void pod; + console.warn("getPodKubeMetrics has been removed and does nothing"); + + return { cpu: 0, memory: 0 }; + } +} diff --git a/src/renderer/components/+workloads-pods/pod-tolerations.scss b/src/renderer/components/+pods/tolerations.scss similarity index 100% rename from src/renderer/components/+workloads-pods/pod-tolerations.scss rename to src/renderer/components/+pods/tolerations.scss diff --git a/src/renderer/components/+pods/tolerations.tsx b/src/renderer/components/+pods/tolerations.tsx new file mode 100644 index 0000000000..1e9a3b4991 --- /dev/null +++ b/src/renderer/components/+pods/tolerations.tsx @@ -0,0 +1,64 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import "./tolerations.scss"; +import React from "react"; +import uniqueId from "lodash/uniqueId"; + +import type { IToleration } from "../../../common/k8s-api/workload-kube-object"; +import { Table, TableCell, TableHead, TableRow } from "../table"; + +export interface PodTolerationsProps { + tolerations: IToleration[]; +} + +enum sortBy { + Key = "key", + Operator = "operator", + Effect = "effect", + Seconds = "seconds", + Value = "value", +} + +const getTableRow = (toleration: IToleration) => ( + + {toleration.key} + {toleration.operator} + {toleration.value} + {toleration.effect} + {toleration.tolerationSeconds} + +); + +export const PodTolerations = ({ tolerations }: PodTolerationsProps) => ( + toleration.key, + [sortBy.Operator]: toleration => toleration.operator, + [sortBy.Effect]: toleration => toleration.effect, + [sortBy.Seconds]: toleration => toleration.tolerationSeconds, + }} + sortSyncWithUrl={false} + className="PodTolerations" + renderRow={getTableRow} + data-testid="pod-tolerations-table" + > + + Key + Operator + Value + Effect + Seconds + +
    +); diff --git a/src/renderer/components/+network-port-forwards/index.ts b/src/renderer/components/+port-forwards/index.ts similarity index 100% rename from src/renderer/components/+network-port-forwards/index.ts rename to src/renderer/components/+port-forwards/index.ts diff --git a/src/renderer/components/+network-port-forwards/port-forward-details.scss b/src/renderer/components/+port-forwards/port-forward-details.scss similarity index 100% rename from src/renderer/components/+network-port-forwards/port-forward-details.scss rename to src/renderer/components/+port-forwards/port-forward-details.scss diff --git a/src/renderer/components/+network-port-forwards/port-forward-details.tsx b/src/renderer/components/+port-forwards/port-forward-details.tsx similarity index 55% rename from src/renderer/components/+network-port-forwards/port-forward-details.tsx rename to src/renderer/components/+port-forwards/port-forward-details.tsx index cd69a04bce..cb9a7f7553 100644 --- a/src/renderer/components/+network-port-forwards/port-forward-details.tsx +++ b/src/renderer/components/+port-forwards/port-forward-details.tsx @@ -10,23 +10,34 @@ import { Link } from "react-router-dom"; import { portForwardAddress, PortForwardItem } from "../../port-forward"; import { Drawer, DrawerItem } from "../drawer"; import { cssNames } from "../../utils"; -import { podsApi, serviceApi } from "../../../common/k8s-api/endpoints"; import { getDetailsUrl } from "../kube-detail-params"; import { PortForwardMenu } from "./port-forward-menu"; +import { withInjectables } from "@ogre-tools/injectable-react"; +import { observer } from "mobx-react"; +import type { ServiceApi, PodApi } from "../../../common/k8s-api/endpoints"; +import podApiInjectable from "../../../common/k8s-api/endpoints/pod.api.injectable"; +import serviceApiInjectable from "../../../common/k8s-api/endpoints/service.api.injectable"; -interface Props { +export interface PortForwardDetailsProps { portForward: PortForwardItem; hideDetails(): void; } -export class PortForwardDetails extends React.Component { +interface Dependencies { + serviceApi: ServiceApi; + podApi: PodApi; +} - renderResourceName() { - const { portForward } = this.props; +const NonInjectedPortForwardDetails = observer(({ podApi, serviceApi, portForward, hideDetails }: Dependencies & PortForwardDetailsProps) => { + if (!portForward) { + return null; + } + + const renderResourceName = () => { const name = portForward.getName(); const api = { "service": serviceApi, - "pod": podsApi, + "pod": podApi, }[portForward.kind]; if (!api) { @@ -40,17 +51,20 @@ export class PortForwardDetails extends React.Component { {name} ); - } + }; - renderContent() { - const { portForward } = this.props; - - if (!portForward) return null; - - return ( + return ( + } + >
    - {this.renderResourceName()} + {renderResourceName()} {portForward.getNs()} @@ -71,24 +85,14 @@ export class PortForwardDetails extends React.Component { {portForward.getStatus()}
    - ); - } +
    + ); +}); - render() { - const { hideDetails, portForward } = this.props; - const toolbar = ; - - return ( - - {this.renderContent()} - - ); - } -} +export const PortForwardDetails = withInjectables(NonInjectedPortForwardDetails, { + getProps: (di, props) => ({ + podApi: di.inject(podApiInjectable), + serviceApi: di.inject(serviceApiInjectable), + ...props, + }), +}); diff --git a/src/renderer/components/+port-forwards/port-forward-menu.tsx b/src/renderer/components/+port-forwards/port-forward-menu.tsx new file mode 100644 index 0000000000..919e303d5a --- /dev/null +++ b/src/renderer/components/+port-forwards/port-forward-menu.tsx @@ -0,0 +1,108 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import React from "react"; +import { cssNames } from "../../utils"; +import { openPortForward, PortForwardItem, ForwardedPort } from "../../port-forward"; +import { MenuActions, MenuActionsProps } from "../menu/menu-actions"; +import { MenuItem } from "../menu"; +import { Icon } from "../icon"; +import { Notifications } from "../notifications"; +import { withInjectables } from "@ogre-tools/injectable-react"; +import { observer } from "mobx-react"; +import removePortForwardInjectable from "../../port-forward/remove.injectable"; +import startPortForwardInjectable from "../../port-forward/start.injectable"; +import stopPortForwardInjectable from "../../port-forward/stop.injectable"; +import openPortForwardDialogInjectable from "../../port-forward/open-dialog.injectable"; + +export interface PortForwardMenuProps extends MenuActionsProps { + portForward: PortForwardItem; + hideDetails?: () => void; +} + +interface Dependencies { + removePortForward: (portForward: ForwardedPort) => Promise; + startPortForward: (portForward: ForwardedPort) => Promise; + stopPortForward: (portForward: ForwardedPort) => Promise; + openPortForwardDialog: (portForward: ForwardedPort) => void; +} + +const NonInjectedPortForwardMenu = observer(({ portForward, toolbar, hideDetails, openPortForwardDialog, className, removePortForward, stopPortForward, startPortForward, ...menuProps }: Dependencies & PortForwardMenuProps) => { + const remove = () => { + try { + removePortForward(portForward); + } catch (error) { + Notifications.error(`Error occurred stopping the port-forward from port ${portForward.forwardPort}. The port-forward may still be active.`); + } + }; + + const startPortForwarding = async () => { + const pf = await startPortForward(portForward); + + if (pf.status === "Disabled") { + const { name, kind, forwardPort } = portForward; + + Notifications.error(`Error occurred starting port-forward, the local port ${forwardPort} may not be available or the ${kind} ${name} may not be reachable`); + } + }; + + const renderStartStopMenuItem = () => { + if (portForward.status === "Active") { + return ( + stopPortForward(portForward)}> + + Stop + + ); + } + + return ( + + + Start + + ); + }; + + const renderContent = () => { + if (!portForward) return null; + + return ( + <> + { portForward.status === "Active" && + openPortForward(portForward)}> + + Open + + } + openPortForwardDialog(portForward)}> + + Edit + + {renderStartStopMenuItem()} + + ); + }; + + return ( + + {renderContent()} + + ); +}); + +export const PortForwardMenu = withInjectables(NonInjectedPortForwardMenu, { + getProps: (di, props) => ({ + removePortForward: di.inject(removePortForwardInjectable), + startPortForward: di.inject(startPortForwardInjectable), + stopPortForward: di.inject(stopPortForwardInjectable), + openPortForwardDialog: di.inject(openPortForwardDialogInjectable), + ...props, + }), +}); diff --git a/src/renderer/components/+network-port-forwards/port-forwards.scss b/src/renderer/components/+port-forwards/port-forwards.scss similarity index 100% rename from src/renderer/components/+network-port-forwards/port-forwards.scss rename to src/renderer/components/+port-forwards/port-forwards.scss diff --git a/src/renderer/components/+port-forwards/port-forwards.tsx b/src/renderer/components/+port-forwards/port-forwards.tsx new file mode 100644 index 0000000000..5adb5d0185 --- /dev/null +++ b/src/renderer/components/+port-forwards/port-forwards.tsx @@ -0,0 +1,137 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import "./port-forwards.scss"; + +import React, { useEffect } from "react"; +import { observer } from "mobx-react"; +import type { RouteComponentProps } from "react-router-dom"; +import { ItemListLayout } from "../item-object-list/item-list-layout"; +import type { PortForwardItem, PortForwardStore } from "../../port-forward"; +import { PortForwardMenu } from "./port-forward-menu"; +import { PortForwardsRouteParams, portForwardsURL } from "../../../common/routes"; +import { PortForwardDetails } from "./port-forward-details"; +import { navigation } from "../../navigation"; +import { withInjectables } from "@ogre-tools/injectable-react"; +import portForwardStoreInjectable from "../../port-forward/store.injectable"; + +enum columnId { + name = "name", + namespace = "namespace", + kind = "kind", + port = "port", + forwardPort = "forwardPort", + protocol = "protocol", + status = "status", +} + +export interface PortForwardsProps extends RouteComponentProps { +} + +interface Dependencies { + portForwardStore: PortForwardStore; +} + +const NonInjectedPortForwards = observer(({ portForwardStore, match }: Dependencies & PortForwardsProps) => { + useEffect(() => portForwardStore.watch(), []); + + const { forwardport } = match.params; + const selectedPortForward = portForwardStore.getById(forwardport); + + const onDetails = (item: PortForwardItem) => { + if (item === selectedPortForward) { + hideDetails(); + } else { + showDetails(item); + } + }; + + const showDetails = (item: PortForwardItem) => { + navigation.push(portForwardsURL({ + params: { + forwardport: item.getId(), + }, + })); + }; + + const hideDetails = () => { + navigation.push(portForwardsURL()); + }; + + const renderRemoveDialogMessage = (selectedItems: PortForwardItem[]) => { + const forwardPorts = selectedItems.map(item => item.getForwardPort()).join(", "); + + return ( +
    + <>Stop forwarding from {forwardPorts}? +
    + ); + }; + + return ( + <> + item.getName(), + [columnId.namespace]: item => item.getNs(), + [columnId.kind]: item => item.getKind(), + [columnId.port]: item => item.getPort(), + [columnId.forwardPort]: item => item.getForwardPort(), + [columnId.protocol]: item => item.getProtocol(), + [columnId.status]: item => item.getStatus(), + }} + searchFilters={[ + item => item.getSearchFields(), + ]} + renderHeaderTitle="Port Forwarding" + renderTableHeader={[ + { title: "Name", className: "name", sortBy: columnId.name, id: columnId.name }, + { title: "Namespace", className: "namespace", sortBy: columnId.namespace, id: columnId.namespace }, + { title: "Kind", className: "kind", sortBy: columnId.kind, id: columnId.kind }, + { title: "Pod Port", className: "port", sortBy: columnId.port, id: columnId.port }, + { title: "Local Port", className: "forwardPort", sortBy: columnId.forwardPort, id: columnId.forwardPort }, + { title: "Protocol", className: "protocol", sortBy: columnId.protocol, id: columnId.protocol }, + { title: "Status", className: "status", sortBy: columnId.status, id: columnId.status }, + ]} + renderTableContents={item => [ + item.getName(), + item.getNs(), + item.getKind(), + item.getPort(), + item.getForwardPort(), + item.getProtocol(), + { title: item.getStatus(), className: item.getStatus().toLowerCase() }, + ]} + renderItemMenu={pf => ( + + )} + customizeRemoveDialog={selectedItems => ({ + message: renderRemoveDialogMessage(selectedItems), + })} + detailsItem={selectedPortForward} + onDetails={onDetails} + /> + {selectedPortForward && ( + + )} + + ); +}); + +export const PortForwards = withInjectables(NonInjectedPortForwards, { + getProps: (di, props) => ({ + portForwardStore: di.inject(portForwardStoreInjectable), + ...props, + }), +}); diff --git a/src/renderer/components/+preferences/add-helm-repo-dialog.tsx b/src/renderer/components/+preferences/add-helm-repo-dialog.tsx index bd25940c84..06cc49706b 100644 --- a/src/renderer/components/+preferences/add-helm-repo-dialog.tsx +++ b/src/renderer/components/+preferences/add-helm-repo-dialog.tsx @@ -20,6 +20,7 @@ import { Icon } from "../icon"; import { Notifications } from "../notifications"; import { HelmRepo, HelmRepoManager } from "../../../main/helm/helm-repo-manager"; import { dialog } from "../../remote-helpers"; +import { cssNames } from "../../utils"; interface Props extends Partial { onAddRepo: Function @@ -144,18 +145,16 @@ export class AddHelmRepoDialog extends React.Component { } render() { - const { ...dialogProps } = this.props; - - const header =
    Add custom Helm Repo
    ; + const { className, ...dialogProps } = this.props; return ( - + Add custom Helm Repo} done={this.close}> this.addCustomRepo()}>
    [] = moment.tz.names().map(zone => ({ label: zone, @@ -30,24 +31,17 @@ const updateChannelOptions: SelectOption[] = Array.from( ([value, { label }]) => ({ value, label }), ); +export interface ApplicationProps {} + interface Dependencies { - appPreferenceItems: IComputedValue + appPreferenceItems: IComputedValue; + userStore: UserPreferencesStore; + themeStore: ThemeStore; } -const NonInjectedApplication: React.FC = ({ appPreferenceItems }) => { - const userStore = UserStore.getInstance(); - const defaultShell = process.env.SHELL - || process.env.PTYSHELL - || ( - isWindows - ? "powershell.exe" - : "System default shell" - ); - +const NonInjectedApplication = observer(({ appPreferenceItems, themeStore, userStore }: Dependencies & ApplicationProps) => { const [customUrl, setCustomUrl] = React.useState(userStore.extensionRegistryUrl.customUrl || ""); - const [shell, setShell] = React.useState(userStore.shell || ""); const extensionSettings = appPreferenceItems.get().filter((preference) => preference.showInPreferencesTab === "application"); - const themeStore = ThemeStore.getInstance(); return (
    @@ -62,43 +56,7 @@ const NonInjectedApplication: React.FC = ({ appPreferenceItems }) />
    -
    - -
    - - userStore.shell = shell} - /> -
    - -
    - - userStore.terminalCopyOnSelect = !userStore.terminalCopyOnSelect} - > - Copy on select and paste on right-click - -
    - -
    +
    @@ -169,14 +127,12 @@ const NonInjectedApplication: React.FC = ({ appPreferenceItems })
    ); -}; +}); -export const Application = withInjectables( - observer(NonInjectedApplication), - - { - getProps: (di) => ({ - appPreferenceItems: di.inject(appPreferencesInjectable), - }), - }, -); +export const Application = withInjectables(NonInjectedApplication, { + getProps: (di) => ({ + appPreferenceItems: di.inject(appPreferencesInjectable), + themeStore: di.inject(themeStoreInjectable), + userStore: di.inject(userPreferencesStoreInjectable), + }), +}); diff --git a/src/renderer/components/+preferences/editor.tsx b/src/renderer/components/+preferences/editor.tsx index dceddff7b5..699ef4b07b 100644 --- a/src/renderer/components/+preferences/editor.tsx +++ b/src/renderer/components/+preferences/editor.tsx @@ -4,12 +4,14 @@ */ import { observer } from "mobx-react"; import React from "react"; -import { UserStore } from "../../../common/user-store"; +import type { UserPreferencesStore } from "../../../common/user-preferences"; import { Switch } from "../switch"; import { Select } from "../select"; import { SubTitle } from "../layout/sub-title"; import { SubHeader } from "../layout/sub-header"; import { Input, InputValidators } from "../input"; +import { withInjectables } from "@ogre-tools/injectable-react"; +import userPreferencesStoreInjectable from "../../../common/user-preferences/store.injectable"; enum EditorLineNumbersStyles { on = "On", @@ -18,8 +20,14 @@ enum EditorLineNumbersStyles { interval = "Interval", } -export const Editor = observer(() => { - const editorConfiguration = UserStore.getInstance().editorConfiguration; +export interface EditorProps {} + +interface Dependencies { + userStore: UserPreferencesStore; +} + +const NonInjectedEditor = observer(({ userStore }: Dependencies & EditorProps) => { + const { editorConfiguration } = userStore; return (
    @@ -69,7 +77,35 @@ export const Editor = observer(() => { onChange={value => editorConfiguration.tabSize = Number(value)} />
    +
    + + editorConfiguration.fontSize = Number(value)} + /> +
    +
    + + editorConfiguration.fontFamily = value} + /> +
    ); }); +export const Editor = withInjectables(NonInjectedEditor, { + getProps: (di, props) => ({ + userStore: di.inject(userPreferencesStoreInjectable), + ...props, + }), +}); + diff --git a/src/renderer/components/+preferences/helm-charts.tsx b/src/renderer/components/+preferences/helm-charts.tsx index 5bd8fb387a..3cfea1c9f1 100644 --- a/src/renderer/components/+preferences/helm-charts.tsx +++ b/src/renderer/components/+preferences/helm-charts.tsx @@ -18,14 +18,20 @@ import { observer } from "mobx-react"; import { RemovableItem } from "./removable-item"; import { Notice } from "../+extensions/notice"; import { Spinner } from "../spinner"; +import { withInjectables } from "@ogre-tools/injectable-react"; +import loadAvailableHelmReposInjectable from "../../../main/helm/load-available-repos.injectable"; + +interface Dependencies { + loadAvailableHelmRepos: () => Promise; +} @observer -export class HelmCharts extends React.Component { +class NonInjectedHelmCharts extends React.Component { @observable loading = false; @observable repos: HelmRepo[] = []; @observable addedRepos = observable.map(); - constructor(props: {}) { + constructor(props: Dependencies) { super(props); makeObservable(this); } @@ -47,7 +53,7 @@ export class HelmCharts extends React.Component { try { if (!this.repos.length) { - this.repos = await HelmRepoManager.loadAvailableRepos(); + this.repos = await this.props.loadAvailableHelmRepos(); } const repos = await HelmRepoManager.getInstance().repositories(); // via helm-cli @@ -160,3 +166,10 @@ export class HelmCharts extends React.Component { ); } } + +export const HelmCharts = withInjectables(NonInjectedHelmCharts, { + getProps: (di, props) => ({ + loadAvailableHelmRepos: di.inject(loadAvailableHelmReposInjectable), + ...props, + }), +}); diff --git a/src/renderer/components/+preferences/kubeconfig-syncs.tsx b/src/renderer/components/+preferences/kubeconfig-syncs.tsx index 0594ea4de9..bd2fc7720f 100644 --- a/src/renderer/components/+preferences/kubeconfig-syncs.tsx +++ b/src/renderer/components/+preferences/kubeconfig-syncs.tsx @@ -2,13 +2,14 @@ * Copyright (c) OpenLens Authors. All rights reserved. * Licensed under MIT License. See LICENSE in root directory for more information. */ +import { withInjectables } from "@ogre-tools/injectable-react"; import fse from "fs-extra"; import { action, computed, makeObservable, observable, reaction } from "mobx"; import { disposeOnUnmount, observer } from "mobx-react"; import React from "react"; import { Notice } from "../+extensions/notice"; - -import { KubeconfigSyncEntry, KubeconfigSyncValue, UserStore } from "../../../common/user-store"; +import type { KubeconfigSyncEntry, KubeconfigSyncValue, UserPreferencesStore } from "../../../common/user-preferences"; +import userPreferencesStoreInjectable from "../../../common/user-preferences/store.injectable"; import { isWindows } from "../../../common/vars"; import logger from "../../../main/logger"; import { iter, multiSet } from "../../utils"; @@ -53,16 +54,20 @@ async function getMapEntry({ filePath, ...data }: KubeconfigSyncEntry): Promise< } } -export async function getAllEntries(filePaths: string[]): Promise<[string, Value][]> { +export function getAllEntries(filePaths: string[]): Promise<[string, Value][]> { return Promise.all(filePaths.map(filePath => getMapEntry({ filePath }))); } +interface Dependencies { + userStore: UserPreferencesStore; +} + @observer -export class KubeconfigSyncs extends React.Component { +class NonInjectedKubeconfigSyncs extends React.Component { syncs = observable.map(); @observable loaded = false; - constructor(props: {}) { + constructor(props: Dependencies) { super(props); makeObservable(this); } @@ -70,7 +75,7 @@ export class KubeconfigSyncs extends React.Component { async componentDidMount() { const mapEntries = await Promise.all( iter.map( - UserStore.getInstance().syncKubeconfigEntries, + this.props.userStore.syncKubeconfigEntries, ([filePath, ...value]) => getMapEntry({ filePath, ...value }), ), ); @@ -80,7 +85,7 @@ export class KubeconfigSyncs extends React.Component { disposeOnUnmount(this, [ reaction(() => Array.from(this.syncs.entries(), ([filePath, { data }]) => [filePath, data]), syncs => { - UserStore.getInstance().syncKubeconfigEntries.replace(syncs); + this.props.userStore.syncKubeconfigEntries.replace(syncs); }), ]); } @@ -191,3 +196,10 @@ export class KubeconfigSyncs extends React.Component { ); } } + +export const KubeconfigSyncs = withInjectables(NonInjectedKubeconfigSyncs, { + getProps: (di, props) => ({ + userStore: di.inject(userPreferencesStoreInjectable), + ...props, + }), +}); diff --git a/src/renderer/components/+preferences/kubectl-binaries.tsx b/src/renderer/components/+preferences/kubectl-binaries.tsx index fdd0ab4131..2d63ef9088 100644 --- a/src/renderer/components/+preferences/kubectl-binaries.tsx +++ b/src/renderer/components/+preferences/kubectl-binaries.tsx @@ -6,21 +6,23 @@ import React, { useState } from "react"; import { Input, InputValidators } from "../input"; import { SubTitle } from "../layout/sub-title"; -import { UserStore } from "../../../common/user-store"; +import type { UserPreferencesStore } from "../../../common/user-preferences"; import { bundledKubectlPath } from "../../../main/kubectl/kubectl"; import { SelectOption, Select } from "../select"; import { Switch } from "../switch"; -import { packageMirrors } from "../../../common/user-store/preferences-helpers"; +import { packageMirrors } from "../../../common/user-preferences/preferences-helpers"; import directoryForBinariesInjectable - from "../../../common/app-paths/directory-for-binaries/directory-for-binaries.injectable"; + from "../../../common/app-paths/directory-for-binaries.injectable"; import { withInjectables } from "@ogre-tools/injectable-react"; +import { observer } from "mobx-react"; +import userPreferencesStoreInjectable from "../../../common/user-preferences/store.injectable"; interface Dependencies { - defaultPathForKubectlBinaries: string + defaultPathForKubectlBinaries: string; + userStore: UserPreferencesStore; } -const NonInjectedKubectlBinaries: React.FC = (({ defaultPathForKubectlBinaries }) => { - const userStore = UserStore.getInstance(); +const NonInjectedKubectlBinaries = observer(({ defaultPathForKubectlBinaries, userStore }: Dependencies) => { const [downloadPath, setDownloadPath] = useState(userStore.downloadBinariesPath || ""); const [binariesPath, setBinariesPath] = useState(userStore.kubectlBinariesPath || ""); const pathValidator = downloadPath ? InputValidators.isPath : undefined; @@ -79,7 +81,7 @@ const NonInjectedKubectlBinaries: React.FC = (({ defaultPathForKub = (({ defaultPathForKub export const KubectlBinaries = withInjectables(NonInjectedKubectlBinaries, { getProps: (di) => ({ defaultPathForKubectlBinaries: di.inject(directoryForBinariesInjectable), + userStore: di.inject(userPreferencesStoreInjectable), }), }); diff --git a/src/renderer/components/+preferences/preferences.tsx b/src/renderer/components/+preferences/preferences.tsx index 828433fe2f..ddb1e0077e 100644 --- a/src/renderer/components/+preferences/preferences.tsx +++ b/src/renderer/components/+preferences/preferences.tsx @@ -22,6 +22,8 @@ import { editorRoute, telemetryRoute, telemetryURL, + terminalRoute, + terminalURL, } from "../../../common/routes"; import { navigateWithoutHistoryChange, navigation } from "../../navigation"; import { SettingLayout } from "../layout/setting-layout"; @@ -29,6 +31,7 @@ import { Tab, Tabs } from "../tabs"; import { Application } from "./application"; import { Kubernetes } from "./kubernetes"; import { Editor } from "./editor"; +import { Terminal } from "./terminal"; import { LensProxy } from "./proxy"; import { Telemetry } from "./telemetry"; import { Extensions } from "./extensions"; @@ -56,6 +59,7 @@ const NonInjectedPreferences: React.FC = ({ appPreferenceItems }) + {(telemetryExtensions.length > 0 || !!sentryDsn) && } @@ -77,6 +81,7 @@ const NonInjectedPreferences: React.FC = ({ appPreferenceItems }) + diff --git a/src/renderer/components/+preferences/proxy.tsx b/src/renderer/components/+preferences/proxy.tsx index 69db754d24..5aaa876751 100644 --- a/src/renderer/components/+preferences/proxy.tsx +++ b/src/renderer/components/+preferences/proxy.tsx @@ -2,17 +2,21 @@ * Copyright (c) OpenLens Authors. All rights reserved. * Licensed under MIT License. See LICENSE in root directory for more information. */ +import { withInjectables } from "@ogre-tools/injectable-react"; import { observer } from "mobx-react"; import React from "react"; - -import { UserStore } from "../../../common/user-store"; +import type { UserPreferencesStore } from "../../../common/user-preferences"; +import userPreferencesStoreInjectable from "../../../common/user-preferences/store.injectable"; import { Input } from "../input"; import { SubTitle } from "../layout/sub-title"; import { Switch } from "../switch"; -export const LensProxy = observer(() => { - const [proxy, setProxy] = React.useState(UserStore.getInstance().httpsProxy || ""); - const store = UserStore.getInstance(); +interface Dependencies { + userStore: UserPreferencesStore; +} + +const NonInjectedLensProxy = observer(({ userStore }: Dependencies) => { + const [proxy, setProxy] = React.useState(userStore.httpsProxy || ""); return (
    @@ -23,8 +27,8 @@ export const LensProxy = observer(() => { theme="round-black" placeholder="Type HTTP proxy url (example: http://proxy.acme.org:8080)" value={proxy} - onChange={v => setProxy(v)} - onBlur={() => UserStore.getInstance().httpsProxy = proxy} + onChange={setProxy} + onBlur={() => userStore.httpsProxy = proxy} /> Proxy is used only for non-cluster communication. @@ -35,7 +39,10 @@ export const LensProxy = observer(() => {
    - store.allowUntrustedCAs = !store.allowUntrustedCAs}> + userStore.allowUntrustedCAs = !userStore.allowUntrustedCAs} + > Allow untrusted Certificate Authorities @@ -47,3 +54,10 @@ export const LensProxy = observer(() => {
    ); }); + +export const LensProxy = withInjectables(NonInjectedLensProxy, { + getProps: (di, props) => ({ + userStore: di.inject(userPreferencesStoreInjectable), + ...props, + }), +}); diff --git a/src/renderer/components/+preferences/telemetry.tsx b/src/renderer/components/+preferences/telemetry.tsx index 30e34bbeaf..80f9a1f441 100644 --- a/src/renderer/components/+preferences/telemetry.tsx +++ b/src/renderer/components/+preferences/telemetry.tsx @@ -4,7 +4,7 @@ */ import { observer } from "mobx-react"; import React from "react"; -import { UserStore } from "../../../common/user-store"; +import type { UserPreferencesStore } from "../../../common/user-preferences"; import { sentryDsn } from "../../../common/vars"; import { Checkbox } from "../checkbox"; import { SubTitle } from "../layout/sub-title"; @@ -13,12 +13,14 @@ import type { RegisteredAppPreference } from "./app-preferences/app-preference-r import appPreferencesInjectable from "./app-preferences/app-preferences.injectable"; import type { IComputedValue } from "mobx"; import { withInjectables } from "@ogre-tools/injectable-react"; +import userPreferencesStoreInjectable from "../../../common/user-preferences/store.injectable"; interface Dependencies { - appPreferenceItems: IComputedValue + appPreferenceItems: IComputedValue; + userStore: UserPreferencesStore; } -const NonInjectedTelemetry: React.FC = ({ appPreferenceItems }) => { +const NonInjectedTelemetry = observer(({ appPreferenceItems, userStore }: Dependencies) => { const extensions = appPreferenceItems.get(); const telemetryExtensions = extensions.filter(e => e.showInPreferencesTab == "telemetry"); @@ -32,9 +34,9 @@ const NonInjectedTelemetry: React.FC = ({ appPreferenceItems }) => { - UserStore.getInstance().allowErrorReporting = value; + userStore.allowErrorReporting = value; }} />
    @@ -51,14 +53,11 @@ const NonInjectedTelemetry: React.FC = ({ appPreferenceItems }) => }
    ); -}; +}); -export const Telemetry = withInjectables( - observer(NonInjectedTelemetry), - - { - getProps: (di) => ({ - appPreferenceItems: di.inject(appPreferencesInjectable), - }), - }, -); +export const Telemetry = withInjectables(NonInjectedTelemetry, { + getProps: (di) => ({ + appPreferenceItems: di.inject(appPreferencesInjectable), + userStore: di.inject(userPreferencesStoreInjectable), + }), +}); diff --git a/src/renderer/components/+preferences/terminal.tsx b/src/renderer/components/+preferences/terminal.tsx new file mode 100644 index 0000000000..55c2148c1e --- /dev/null +++ b/src/renderer/components/+preferences/terminal.tsx @@ -0,0 +1,98 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import React from "react"; +import { observer } from "mobx-react"; +import type { UserPreferencesStore } from "../../../common/user-preferences"; +import { SubTitle } from "../layout/sub-title"; +import { Input, InputValidators } from "../input"; +import { isWindows } from "../../../common/vars"; +import { Switch } from "../switch"; +import { Select } from "../select"; +import { withInjectables } from "@ogre-tools/injectable-react"; +import type { ThemeStore } from "../../themes/store"; +import themeStoreInjectable from "../../themes/store.injectable"; +import userPreferencesStoreInjectable from "../../../common/user-preferences/store.injectable"; + +export interface TerminalProps {} + +const defaultShell = process.env.SHELL + || process.env.PTYSHELL + || ( + isWindows + ? "powershell.exe" + : "System default shell" + ); + +interface Dependencies { + userPreferencesStore: UserPreferencesStore; + themeStore: ThemeStore; +} + +const NonInjectedTerminal = observer(({ userPreferencesStore, themeStore }: Dependencies & TerminalProps) => ( +
    +
    + + userPreferencesStore.shell = value} + /> +
    + +
    + + userPreferencesStore.terminalCopyOnSelect = !userPreferencesStore.terminalCopyOnSelect} + > + Copy on select and paste on right-click + +
    + +
    + + userPreferencesStore.terminalConfig.fontSize=Number(value)} + /> +
    +
    + + userPreferencesStore.terminalConfig.fontFamily=value} + /> +
    +
    +)); + +export const Terminal = withInjectables(NonInjectedTerminal, { + getProps: (di, props) => ({ + themeStore: di.inject(themeStoreInjectable), + userPreferencesStore: di.inject(userPreferencesStoreInjectable), + ...props, + }), +}); diff --git a/src/renderer/components/+workloads-replicasets/replicaset-details.scss b/src/renderer/components/+replica-sets/details.scss similarity index 100% rename from src/renderer/components/+workloads-replicasets/replicaset-details.scss rename to src/renderer/components/+replica-sets/details.scss diff --git a/src/renderer/components/+replica-sets/details.tsx b/src/renderer/components/+replica-sets/details.tsx new file mode 100644 index 0000000000..8a58612af6 --- /dev/null +++ b/src/renderer/components/+replica-sets/details.tsx @@ -0,0 +1,134 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import "./details.scss"; +import React, { useEffect, useState } from "react"; +import { DrawerItem } from "../drawer"; +import { Badge } from "../badge"; +import type { ReplicaSetStore } from "./store"; +import { PodDetailsStatuses } from "../+pods/details-statuses"; +import { PodDetailsTolerations } from "../+pods/details-tolerations"; +import { PodDetailsAffinities } from "../+pods/details-affinities"; +import { observer } from "mobx-react"; +import type { PodStore } from "../+pods/store"; +import type { KubeObjectDetailsProps } from "../kube-object-details"; +import { getMetricsForReplicaSets, IPodMetrics, ReplicaSet } from "../../../common/k8s-api/endpoints"; +import { ResourceMetrics, ResourceMetricsText } from "../resource-metrics"; +import { PodCharts, podMetricTabs } from "../+pods/charts"; +import { PodDetailsList } from "../+pods/details-list"; +import { KubeObjectMeta } from "../kube-object-meta"; +import { ClusterMetricsResourceType } from "../../../common/cluster-types"; +import logger from "../../../common/logger"; +import { withInjectables } from "@ogre-tools/injectable-react"; +import podStoreInjectable from "../+pods/store.injectable"; +import replicaSetStoreInjectable from "./store.injectable"; +import isMetricHiddenInjectable from "../../utils/is-metrics-hidden.injectable"; +import kubeWatchApiInjectable from "../../kube-watch-api/kube-watch-api.injectable"; +import type { KubeWatchApi } from "../../kube-watch-api/kube-watch-api"; + +export interface ReplicaSetDetailsProps extends KubeObjectDetailsProps { +} + +interface Dependencies { + replicaSetStore: ReplicaSetStore; + podStore: PodStore; + isMetricHidden: boolean; + kubeWatchApi: KubeWatchApi; +} + +const NonInjectedReplicaSetDetails = observer(({ kubeWatchApi, isMetricHidden, podStore, replicaSetStore, object: replicaSet }: Dependencies & ReplicaSetDetailsProps) => { + const [metrics, setMetrics] = useState(null); + + useEffect(() => setMetrics(null), [replicaSet]); + useEffect(() => ( + kubeWatchApi.subscribeStores([ + podStore, + ]) + ), []); + + const loadMetrics =async () => { + setMetrics(await getMetricsForReplicaSets([replicaSet], replicaSet.getNs(), "")); + }; + + if (!replicaSet) { + return null; + } + + if (!(replicaSet instanceof ReplicaSet)) { + logger.error("[ReplicaSetDetails]: passed object that is not an instanceof ReplicaSet", replicaSet); + + return null; + } + + const { availableReplicas, replicas } = replicaSet.status; + const selectors = replicaSet.getSelectors(); + const nodeSelector = replicaSet.getNodeSelectors(); + const images = replicaSet.getImages(); + const childPods = replicaSetStore.getChildPods(replicaSet); + + return ( +
    + {(!isMetricHidden && podStore.isLoaded) && ( + + + + )} + + {selectors.length > 0 && + + { + selectors.map(label => ) + } + + } + {nodeSelector.length > 0 && + + { + nodeSelector.map(label => ) + } + + } + {images.length > 0 && + + { + images.map(image =>

    {image}

    ) + } +
    + } + + {`${availableReplicas || 0} current / ${replicas || 0} desired`} + + + + + + + + +
    + ); +}); + +export const ReplicaSetDetails = withInjectables(NonInjectedReplicaSetDetails, { + getProps: (di, props) => ({ + podStore: di.inject(podStoreInjectable), + replicaSetStore: di.inject(replicaSetStoreInjectable), + isMetricHidden: di.inject(isMetricHiddenInjectable, { + metricType: ClusterMetricsResourceType.ReplicaSet, + }), + kubeWatchApi: di.inject(kubeWatchApiInjectable), + ...props, + }), +}); + diff --git a/src/renderer/components/+config/index.ts b/src/renderer/components/+replica-sets/index.ts similarity index 71% rename from src/renderer/components/+config/index.ts rename to src/renderer/components/+replica-sets/index.ts index 5fa22d8bf5..ae0304aa31 100644 --- a/src/renderer/components/+config/index.ts +++ b/src/renderer/components/+replica-sets/index.ts @@ -3,4 +3,5 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -export * from "./config"; +export * from "./replica-sets"; +export * from "./details"; diff --git a/src/renderer/components/+replica-sets/item-menu.tsx b/src/renderer/components/+replica-sets/item-menu.tsx new file mode 100644 index 0000000000..d41100cade --- /dev/null +++ b/src/renderer/components/+replica-sets/item-menu.tsx @@ -0,0 +1,37 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { withInjectables } from "@ogre-tools/injectable-react"; +import { observer } from "mobx-react"; +import React from "react"; +import type { ReplicaSet } from "../../../common/k8s-api/endpoints"; +import { Icon } from "../icon"; +import type { KubeObjectMenuProps } from "../kube-object-menu"; +import { MenuItem } from "../menu"; +import openReplicaSetScaleDialogInjectable from "./scale-dialog-open.injectable"; + +export interface ReplicaSetMenuProps extends KubeObjectMenuProps { + +} + +interface Dependencies { + openReplicaSetScaleDialog: (replicaSet: ReplicaSet) => void; +} + +const NonInjectedReplicaSetMenu = observer(({ openReplicaSetScaleDialog, toolbar, object: replicaSet }: Dependencies & ReplicaSetMenuProps) => ( + <> + openReplicaSetScaleDialog(replicaSet)}> + + Scale + + +)); + +export const ReplicaSetMenu = withInjectables(NonInjectedReplicaSetMenu, { + getProps: (di, props) => ({ + openReplicaSetScaleDialog: di.inject(openReplicaSetScaleDialogInjectable), + ...props, + }), +}); diff --git a/src/renderer/components/+workloads-replicasets/replicasets.scss b/src/renderer/components/+replica-sets/replica-sets.scss similarity index 100% rename from src/renderer/components/+workloads-replicasets/replicasets.scss rename to src/renderer/components/+replica-sets/replica-sets.scss diff --git a/src/renderer/components/+replica-sets/replica-sets.tsx b/src/renderer/components/+replica-sets/replica-sets.tsx new file mode 100644 index 0000000000..ace9187859 --- /dev/null +++ b/src/renderer/components/+replica-sets/replica-sets.tsx @@ -0,0 +1,87 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import "./replica-sets.scss"; + +import React from "react"; +import { observer } from "mobx-react"; +import type { ReplicaSet } from "../../../common/k8s-api/endpoints"; +import type { ReplicaSetStore } from "./store"; +import { KubeObjectStatusIcon } from "../kube-object-status-icon"; +import type { RouteComponentProps } from "react-router"; +import { KubeObjectListLayout } from "../kube-object-list-layout"; +import type { ReplicaSetsRouteParams } from "../../../common/routes"; +import type { EventStore } from "../+events/store"; +import { withInjectables } from "@ogre-tools/injectable-react"; +import replicaSetStoreInjectable from "./store.injectable"; +import eventStoreInjectable from "../+events/store.injectable"; +import { ReplicaSetMenu } from "./item-menu"; + +enum columnId { + name = "name", + namespace = "namespace", + desired = "desired", + current = "current", + ready = "ready", + age = "age", +} + +export interface ReplicaSetsProps extends RouteComponentProps { +} + +interface Dependencies { + replicaSetStore: ReplicaSetStore; + eventStore: EventStore; +} + +const NonInjectedReplicaSets = observer(({ replicaSetStore, eventStore }: Dependencies & ReplicaSetsProps) => ( + replicaSet.getName(), + [columnId.namespace]: replicaSet => replicaSet.getNs(), + [columnId.desired]: replicaSet => replicaSet.getDesired(), + [columnId.current]: replicaSet => replicaSet.getCurrent(), + [columnId.ready]: replicaSet => replicaSet.getReady(), + [columnId.age]: replicaSet => replicaSet.getTimeDiffFromNow(), + }} + searchFilters={[ + replicaSet => replicaSet.getSearchFields(), + ]} + renderHeaderTitle="Replica Sets" + renderTableHeader={[ + { title: "Name", className: "name", sortBy: columnId.name, id: columnId.name }, + { className: "warning", showWithColumn: columnId.name }, + { title: "Namespace", className: "namespace", sortBy: columnId.namespace, id: columnId.namespace }, + { title: "Desired", className: "desired", sortBy: columnId.desired, id: columnId.desired }, + { title: "Current", className: "current", sortBy: columnId.current, id: columnId.current }, + { title: "Ready", className: "ready", sortBy: columnId.ready, id: columnId.ready }, + { title: "Age", className: "age", sortBy: columnId.age, id: columnId.age }, + ]} + renderTableContents={replicaSet => [ + replicaSet.getName(), + , + replicaSet.getNs(), + replicaSet.getDesired(), + replicaSet.getCurrent(), + replicaSet.getReady(), + replicaSet.getAge(), + ]} + renderItemMenu={(item: ReplicaSet) => } + /> +)); + +export const ReplicaSets = withInjectables(NonInjectedReplicaSets, { + getProps: (di, props) => ({ + replicaSetStore: di.inject(replicaSetStoreInjectable), + eventStore: di.inject(eventStoreInjectable), + ...props, + }), +}); + diff --git a/src/renderer/components/+replica-sets/scale-dialog-close.injectable.ts b/src/renderer/components/+replica-sets/scale-dialog-close.injectable.ts new file mode 100644 index 0000000000..2e72d16a22 --- /dev/null +++ b/src/renderer/components/+replica-sets/scale-dialog-close.injectable.ts @@ -0,0 +1,25 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { bind } from "../../utils"; +import type { ReplicaSetScaleDialogState } from "./scale-dialog.state.injectable"; +import replicaSetScaleDialogStateInjectable from "./scale-dialog.state.injectable"; + +interface Dependencies { + replicasetScaleDialogState: ReplicaSetScaleDialogState; +} + +function closeReplicaSetScaleDialog({ replicasetScaleDialogState }: Dependencies): void { + replicasetScaleDialogState.replicaSet = null; +} + +const closeReplicaSetScaleDialogInjectable = getInjectable({ + instantiate: (di) => bind(closeReplicaSetScaleDialog, null, { + replicasetScaleDialogState: di.inject(replicaSetScaleDialogStateInjectable), + }), + lifecycle: lifecycleEnum.singleton, +}); + +export default closeReplicaSetScaleDialogInjectable; diff --git a/src/renderer/components/+replica-sets/scale-dialog-open.injectable.ts b/src/renderer/components/+replica-sets/scale-dialog-open.injectable.ts new file mode 100644 index 0000000000..9a32cb41d6 --- /dev/null +++ b/src/renderer/components/+replica-sets/scale-dialog-open.injectable.ts @@ -0,0 +1,26 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import type { ReplicaSet } from "../../../common/k8s-api/endpoints"; +import { bind } from "../../utils"; +import type { ReplicaSetScaleDialogState } from "./scale-dialog.state.injectable"; +import replicaSetScaleDialogStateInjectable from "./scale-dialog.state.injectable"; + +interface Dependencies { + replicasetScaleDialogState: ReplicaSetScaleDialogState; +} + +function openReplicaSetScaleDialog({ replicasetScaleDialogState }: Dependencies, replicaset: ReplicaSet): void { + replicasetScaleDialogState.replicaSet = replicaset; +} + +const openReplicaSetScaleDialogInjectable = getInjectable({ + instantiate: (di) => bind(openReplicaSetScaleDialog, null, { + replicasetScaleDialogState: di.inject(replicaSetScaleDialogStateInjectable), + }), + lifecycle: lifecycleEnum.singleton, +}); + +export default openReplicaSetScaleDialogInjectable; diff --git a/src/renderer/components/+workloads-replicasets/replicaset-scale-dialog.scss b/src/renderer/components/+replica-sets/scale-dialog.scss similarity index 100% rename from src/renderer/components/+workloads-replicasets/replicaset-scale-dialog.scss rename to src/renderer/components/+replica-sets/scale-dialog.scss diff --git a/src/renderer/components/+replica-sets/scale-dialog.state.injectable.ts b/src/renderer/components/+replica-sets/scale-dialog.state.injectable.ts new file mode 100644 index 0000000000..889f47ea8b --- /dev/null +++ b/src/renderer/components/+replica-sets/scale-dialog.state.injectable.ts @@ -0,0 +1,20 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { observable } from "mobx"; +import type { ReplicaSet } from "../../../common/k8s-api/endpoints"; + +export interface ReplicaSetScaleDialogState { + replicaSet: ReplicaSet | null; +} + +const replicaSetScaleDialogStateInjectable = getInjectable({ + instantiate: () => observable.object({ + replicaSet: null, + }), + lifecycle: lifecycleEnum.singleton, +}); + +export default replicaSetScaleDialogStateInjectable; diff --git a/src/renderer/components/+replica-sets/scale-dialog.test.tsx b/src/renderer/components/+replica-sets/scale-dialog.test.tsx new file mode 100755 index 0000000000..d03e1e25dd --- /dev/null +++ b/src/renderer/components/+replica-sets/scale-dialog.test.tsx @@ -0,0 +1,186 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import "@testing-library/jest-dom/extend-expect"; + +import { ReplicaSetScaleDialog } from "./scale-dialog"; +import { fireEvent } from "@testing-library/react"; +import React from "react"; +import type { ReplicaSet, ReplicaSetApi } from "../../../common/k8s-api/endpoints/replica-set.api"; +import replicaSetScaleDialogStateInjectable from "./scale-dialog.state.injectable"; +import type { ConfigurableDependencyInjectionContainer } from "@ogre-tools/injectable"; +import { getDiForUnitTesting } from "../../getDiForUnitTesting"; +import { type DiRender, renderFor } from "../test-utils/renderFor"; +import replicaSetApiInjectable from "../../../common/k8s-api/endpoints/replica-set.api.injectable"; + +const dummyReplicaSet: ReplicaSet = { + apiVersion: "v1", + kind: "dummy", + metadata: { + uid: "dummy", + name: "dummy", + creationTimestamp: "dummy", + resourceVersion: "dummy", + selfLink: "link", + }, + selfLink: "link", + spec: { + replicas: 1, + selector: { + matchLabels: { "label": "label" }, + }, + template: { + metadata: { + labels: { + app: "label", + }, + }, + spec: { + containers: [{ + name: "dummy", + image: "dummy", + imagePullPolicy: "dummy", + }], + initContainers: [{ + name: "dummy", + image: "dummy", + imagePullPolicy: "dummy", + }], + priority: 1, + serviceAccountName: "dummy", + serviceAccount: "dummy", + securityContext: {}, + schedulerName: "dummy", + }, + }, + minReadySeconds: 1, + }, + status: { + replicas: 1, + fullyLabeledReplicas: 1, + readyReplicas: 1, + availableReplicas: 1, + observedGeneration: 1, + conditions: [{ + type: "dummy", + status: "dummy", + lastUpdateTime: "dummy", + lastTransitionTime: "dummy", + reason: "dummy", + message: "dummy", + }], + }, + getDesired: jest.fn(), + getCurrent: jest.fn(), + getReady: jest.fn(), + getImages: jest.fn(), + getSelectors: jest.fn(), + getTemplateLabels: jest.fn(), + getAffinity: jest.fn(), + getTolerations: jest.fn(), + getNodeSelectors: jest.fn(), + getAffinityNumber: jest.fn(), + getId: jest.fn(), + getResourceVersion: jest.fn(), + getName: jest.fn(), + getNs: jest.fn(), + getAge: jest.fn(), + getTimeDiffFromNow: jest.fn(), + getFinalizers: jest.fn(), + getLabels: jest.fn(), + getAnnotations: jest.fn(), + getOwnerRefs: jest.fn(), + getSearchFields: jest.fn(), + toPlainObject: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + patch: jest.fn(), +}; + +describe("", () => { + let di: ConfigurableDependencyInjectionContainer; + let render: DiRender; + + beforeEach(() => { + di = getDiForUnitTesting(); + render = renderFor(di); + + di.override(replicaSetScaleDialogStateInjectable, () => ({ + replicaSet: dummyReplicaSet, + })); + }); + + it("renders w/o errors", () => { + di.override(replicaSetApiInjectable, () => ({ + getReplicas: jest.fn().mockImplementationOnce(() => 1), + }) as any as ReplicaSetApi); + + const result = render(); + + expect(result.container).toBeInstanceOf(HTMLElement); + }); + + it("init with a dummy replica set and mocked current/desired scale", async () => { + // mock replicaSetApi.getReplicas() which will be called + // when rendered. + const initReplicas = 1; + + di.override(replicaSetApiInjectable, () => ({ + getReplicas: jest.fn().mockImplementationOnce(() => initReplicas), + }) as any as ReplicaSetApi); + + const result = render(); + + expect(await result.findByTestId("current-scale")).toHaveTextContent(`${initReplicas}`); + expect(await result.findByTestId("desired-scale")).toHaveTextContent(`${initReplicas}`); + }); + + it("changes the desired scale when clicking the icon buttons +/-", async () => { + const initReplicas = 1; + + di.override(replicaSetApiInjectable, () => ({ + getReplicas: jest.fn().mockImplementationOnce(() => initReplicas), + }) as any as ReplicaSetApi); + + const result = render(); + + expect(await result.findByTestId("desired-scale")).toHaveTextContent(`${initReplicas}`); + expect(await result.findByTestId("current-scale")).toHaveTextContent(`${initReplicas}`); + expect(result.baseElement.querySelector("input").value).toBe(`${initReplicas}`); + + const up = await result.findByTestId("desired-replicas-up"); + const down = await result.findByTestId("desired-replicas-down"); + + fireEvent.click(up); + expect(await result.findByTestId("desired-scale")).toHaveTextContent(`${initReplicas + 1}`); + expect(await result.findByTestId("current-scale")).toHaveTextContent(`${initReplicas}`); + expect((result.baseElement.querySelector("input").value)).toBe(`${initReplicas + 1}`); + + fireEvent.click(down); + expect(await result.findByTestId("desired-scale")).toHaveTextContent(`${initReplicas}`); + expect(await result.findByTestId("current-scale")).toHaveTextContent(`${initReplicas}`); + expect(result.baseElement.querySelector("input").value).toBe(`${initReplicas}`); + + // edge case, desiredScale must >= 0 + let times = 10; + + for (let i = 0; i < times; i++) { + fireEvent.click(down); + } + expect(await result.findByTestId("desired-scale")).toHaveTextContent("0"); + expect(result.baseElement.querySelector("input").value).toBe("0"); + + // edge case, desiredScale must <= scaleMax (100) + times = 120; + + for (let i = 0; i < times; i++) { + fireEvent.click(up); + } + expect(await result.findByTestId("desired-scale")).toHaveTextContent("100"); + expect((result.baseElement.querySelector("input").value)).toBe("100"); + expect(await result.findByTestId("warning")) + .toHaveTextContent("High number of replicas may cause cluster performance issues"); + }); +}); diff --git a/src/renderer/components/+replica-sets/scale-dialog.tsx b/src/renderer/components/+replica-sets/scale-dialog.tsx new file mode 100644 index 0000000000..3292369f42 --- /dev/null +++ b/src/renderer/components/+replica-sets/scale-dialog.tsx @@ -0,0 +1,138 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import "./scale-dialog.scss"; + +import React, { useState } from "react"; +import { observer } from "mobx-react"; +import { Dialog, DialogProps } from "../dialog"; +import { Wizard, WizardStep } from "../wizard"; +import { Icon } from "../icon"; +import { Slider } from "../slider"; +import { Notifications } from "../notifications"; +import { cssNames } from "../../utils"; +import type { ReplicaSet, ReplicaSetApi } from "../../../common/k8s-api/endpoints/replica-set.api"; +import { withInjectables } from "@ogre-tools/injectable-react"; +import replicaSetApiInjectable from "../../../common/k8s-api/endpoints/replica-set.api.injectable"; +import replicaSetScaleDialogStateInjectable from "./scale-dialog.state.injectable"; +import closeReplicaSetScaleDialogInjectable from "./scale-dialog-close.injectable"; + +export interface ReplicaSetScaleDialogProps extends Partial { +} + +interface Dependencies { + replicaSetApi: ReplicaSetApi + replicaSet: ReplicaSet | null; + closeReplicaSetScaleDialog: () => void; +} + +const defaultScaleMax = 50; + +const NonInjectedReplicaSetScaleDialog = observer(({ replicaSetApi, replicaSet, closeReplicaSetScaleDialog, className, ...dialogProps }: Dependencies & ReplicaSetScaleDialogProps) => { + const [ready, setReady] = useState(false); + const [currentReplicas, setCurrentReplicas] = useState(0); + const [desiredReplicas, setDesiredReplicas] = useState(0); + const isOpen = Boolean(replicaSet); + const scaleMax = Math.max(currentReplicas, defaultScaleMax) * 2; + const scaleMin = 0; + + const onOpen = async () => { + const replicas = await replicaSetApi.getReplicas({ + namespace: replicaSet.getNs(), + name: replicaSet.getName(), + }); + + setCurrentReplicas(replicas); + setDesiredReplicas(replicas); + setReady(true); + }; + + const onClose = () => setReady(false); + const onChange = (evt: React.ChangeEvent, value: number) => setDesiredReplicas(value); + const desiredReplicasUp = () => setDesiredReplicas(Math.min(scaleMax, desiredReplicas + 1)); + const desiredReplicasDown = () => setDesiredReplicas(Math.max(scaleMin, desiredReplicas - 1)); + + const scale = async () => { + try { + if (currentReplicas !== desiredReplicas) { + await replicaSetApi.scale({ + name: replicaSet.getName(), + namespace: replicaSet.getNs(), + }, desiredReplicas); + } + closeReplicaSetScaleDialog(); + } catch (err) { + Notifications.error(err); + } + }; + + return ( + + + Scale Replica Set {replicaSet?.getName()} + + )} + done={closeReplicaSetScaleDialog} + > + +
    + Current replica scale: {currentReplicas} +
    +
    +
    + Desired number of replicas: {desiredReplicas} +
    +
    + +
    +
    + + +
    +
    + {currentReplicas < 10 && desiredReplicas > 90 && ( +
    + + High number of replicas may cause cluster performance issues +
    + )} +
    +
    +
    + ); +}); + +export const ReplicaSetScaleDialog = withInjectables(NonInjectedReplicaSetScaleDialog, { + getProps: (di, props) => ({ + replicaSetApi: di.inject(replicaSetApiInjectable), + replicaSet: di.inject(replicaSetScaleDialogStateInjectable).replicaSet, + closeReplicaSetScaleDialog: di.inject(closeReplicaSetScaleDialogInjectable), + ...props, + }), +}); diff --git a/src/renderer/components/+replica-sets/store.injectable.ts b/src/renderer/components/+replica-sets/store.injectable.ts new file mode 100644 index 0000000000..8de9e6af6b --- /dev/null +++ b/src/renderer/components/+replica-sets/store.injectable.ts @@ -0,0 +1,15 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import apiManagerInjectable from "../../../common/k8s-api/api-manager.injectable"; +import type { ReplicaSetStore } from "../../../extensions/renderer-api/k8s-api"; + +const replicaSetStoreInjectable = getInjectable({ + instantiate: (di) => di.inject(apiManagerInjectable).getStore("/apis/apps/v1/replicasets") as ReplicaSetStore, + lifecycle: lifecycleEnum.singleton, +}); + +export default replicaSetStoreInjectable; diff --git a/src/renderer/components/+workloads-replicasets/replicasets.store.ts b/src/renderer/components/+replica-sets/store.ts similarity index 74% rename from src/renderer/components/+workloads-replicasets/replicasets.store.ts rename to src/renderer/components/+replica-sets/store.ts index 283a13f196..22368b43e1 100644 --- a/src/renderer/components/+workloads-replicasets/replicasets.store.ts +++ b/src/renderer/components/+replica-sets/store.ts @@ -4,17 +4,18 @@ */ import { makeObservable } from "mobx"; -import { podsStore } from "../+workloads-pods/pods.store"; -import { apiManager } from "../../../common/k8s-api/api-manager"; -import { Deployment, ReplicaSet, replicaSetApi } from "../../../common/k8s-api/endpoints"; -import { PodStatus } from "../../../common/k8s-api/endpoints/pods.api"; +import type { PodStore } from "../+pods/store"; +import type { Deployment, ReplicaSet, ReplicaSetApi } from "../../../common/k8s-api/endpoints"; +import { PodStatus } from "../../../common/k8s-api/endpoints/pod.api"; import { KubeObjectStore } from "../../../common/k8s-api/kube-object.store"; import { autoBind } from "../../utils"; -export class ReplicaSetStore extends KubeObjectStore { - api = replicaSetApi; +export interface ReplicaSetStoreDependencies { + podStore: PodStore; +} - constructor() { +export class ReplicaSetStore extends KubeObjectStore { + constructor(public readonly api:ReplicaSetApi, protected dependencies: ReplicaSetStoreDependencies) { super(); makeObservable(this); @@ -22,7 +23,7 @@ export class ReplicaSetStore extends KubeObjectStore { } getChildPods(replicaSet: ReplicaSet) { - return podsStore.getPodsByOwnerId(replicaSet.getId()); + return this.dependencies.podStore.getPodsByOwnerId(replicaSet.getId()); } getStatuses(replicaSets: ReplicaSet[]) { @@ -51,6 +52,3 @@ export class ReplicaSetStore extends KubeObjectStore { ); } } - -export const replicaSetStore = new ReplicaSetStore(); -apiManager.registerStore(replicaSetStore); diff --git a/src/renderer/components/+resource-quotas/add-dialog-close.injectable.ts b/src/renderer/components/+resource-quotas/add-dialog-close.injectable.ts new file mode 100644 index 0000000000..afa2289667 --- /dev/null +++ b/src/renderer/components/+resource-quotas/add-dialog-close.injectable.ts @@ -0,0 +1,27 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { AddResourceQuotaDialogState } from "./add-dialog.state.injectable"; +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { bind } from "../../utils"; +import addResourceQuotaDialogStateInjectable from "./add-dialog.state.injectable"; + +interface Dependencies { + addResourceQuotaDialogState: AddResourceQuotaDialogState; +} + +function closeAddResourceQuotaDialog({ addResourceQuotaDialogState }: Dependencies) { + addResourceQuotaDialogState.isOpen = false; +} + +const closeAddResourceQuotaDialogInjectable = getInjectable({ + instantiate: (di) => bind(closeAddResourceQuotaDialog, null, { + addResourceQuotaDialogState: di.inject(addResourceQuotaDialogStateInjectable), + }), + lifecycle: lifecycleEnum.singleton, +}); + +export default closeAddResourceQuotaDialogInjectable; + diff --git a/src/renderer/components/+resource-quotas/add-dialog-open.injectable.ts b/src/renderer/components/+resource-quotas/add-dialog-open.injectable.ts new file mode 100644 index 0000000000..aad234b4b8 --- /dev/null +++ b/src/renderer/components/+resource-quotas/add-dialog-open.injectable.ts @@ -0,0 +1,27 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { AddResourceQuotaDialogState } from "./add-dialog.state.injectable"; +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { bind } from "../../utils"; +import addResourceQuotaDialogStateInjectable from "./add-dialog.state.injectable"; + +interface Dependencies { + addResourceQuotaDialogState: AddResourceQuotaDialogState; +} + +function openAddResourceQuotaDialog({ addResourceQuotaDialogState }: Dependencies) { + addResourceQuotaDialogState.isOpen = true; +} + +const openAddResourceQuotaDialogInjectable = getInjectable({ + instantiate: (di) => bind(openAddResourceQuotaDialog, null, { + addResourceQuotaDialogState: di.inject(addResourceQuotaDialogStateInjectable), + }), + lifecycle: lifecycleEnum.singleton, +}); + +export default openAddResourceQuotaDialogInjectable; + diff --git a/src/renderer/components/+config-resource-quotas/add-quota-dialog.scss b/src/renderer/components/+resource-quotas/add-dialog.scss similarity index 100% rename from src/renderer/components/+config-resource-quotas/add-quota-dialog.scss rename to src/renderer/components/+resource-quotas/add-dialog.scss diff --git a/src/renderer/components/+resource-quotas/add-dialog.state.injectable.ts b/src/renderer/components/+resource-quotas/add-dialog.state.injectable.ts new file mode 100644 index 0000000000..c640bed2a4 --- /dev/null +++ b/src/renderer/components/+resource-quotas/add-dialog.state.injectable.ts @@ -0,0 +1,20 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { observable } from "mobx"; + +export interface AddResourceQuotaDialogState { + isOpen: boolean; +} + +const addResourceQuotaDialogStateInjectable = getInjectable({ + instantiate: () => observable.object({ + isOpen: false, + }), + lifecycle: lifecycleEnum.singleton, +}); + +export default addResourceQuotaDialogStateInjectable; diff --git a/src/renderer/components/+resource-quotas/add-dialog.tsx b/src/renderer/components/+resource-quotas/add-dialog.tsx new file mode 100644 index 0000000000..ace65ca427 --- /dev/null +++ b/src/renderer/components/+resource-quotas/add-dialog.tsx @@ -0,0 +1,175 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import "./add-dialog.scss"; + +import React, { useState } from "react"; +import { observable } from "mobx"; +import { observer } from "mobx-react"; +import { Dialog, DialogProps } from "../dialog"; +import { Wizard, WizardStep } from "../wizard"; +import { Input } from "../input"; +import { systemName } from "../input/input_validators"; +import { ResourceQuotaApi, resourceQuotaKinds, ResourceQuotaKinds } from "../../../common/k8s-api/endpoints"; +import { Select } from "../select"; +import { Icon } from "../icon"; +import { Button } from "../button"; +import { Notifications } from "../notifications"; +import { NamespaceSelect } from "../+namespaces/namespace-select"; +import { SubTitle } from "../layout/sub-title"; +import { withInjectables } from "@ogre-tools/injectable-react"; +import resourceQuotaApiInjectable from "../../../common/k8s-api/endpoints/resource-quota.api.injectable"; +import addResourceQuotaDialogStateInjectable, { AddResourceQuotaDialogState } from "./add-dialog.state.injectable"; +import closeAddResourceQuotaDialogInjectable from "./add-dialog-close.injectable"; +import { cssNames, iter } from "../../utils"; + +export interface AddQuotaDialogProps extends Omit { +} + +interface Dependencies { + resourceQuotaApi: ResourceQuotaApi; + state: AddResourceQuotaDialogState; + closeAddResourceQuotaDialog: () => void; +} + +const NonInjectedAddQuotaDialog = observer(({ resourceQuotaApi, state, closeAddResourceQuotaDialog, className, ...dialogProps }: Dependencies & AddQuotaDialogProps) => { + const [quotaName, setQuotaName] = useState(""); + const [quotaSelectValue, setQuotaSelectValue] = useState(resourceQuotaKinds[0]); + const [quotaInputValue, setQuotaInputValue] = useState(""); + const [namespace, setNamespace] = useState("default"); + const [quotas] = useState(observable.map(resourceQuotaKinds.map(resourceQuotaKind => [resourceQuotaKind, ""]))); + + const { isOpen } = state; + const quotaEntries = [...iter.filter(quotas.entries(), ([, value]) => value.trim().length === 0)]; + const quotaOptions = Array.from(quotas.keys(), quota => { + const isCompute = quota.endsWith(".cpu") || quota.endsWith(".memory"); + const isStorage = quota.endsWith(".storage") || quota === "persistentvolumeclaims"; + const isCount = quota.startsWith("count/"); + const icon = isCompute ? "memory" : isStorage ? "storage" : isCount ? "looks_one" : ""; + + return { + label: icon ? {quota} : quota, + value: quota, + }; + }); + + const reset = () => { + quotas.replace(resourceQuotaKinds.map(resourceQuotaKind => [resourceQuotaKind, ""])); + setQuotaName(""); + setQuotaSelectValue(resourceQuotaKinds[0]); + setQuotaInputValue(""); + setNamespace("default"); + }; + const setQuota = () => { + if (quotaSelectValue) { + quotas.set(quotaSelectValue, quotaInputValue); + setQuotaInputValue(""); + } + }; + const addQuota = async () => { + try { + await resourceQuotaApi.create({ namespace, name: quotaName }, { + spec: { + hard: Object.fromEntries(quotaEntries), + }, + }); + closeAddResourceQuotaDialog(); + } catch (err) { + Notifications.error(err); + } + }; + const onInputQuota = (evt: React.KeyboardEvent) => { + switch (evt.key) { + case "Enter": + setQuota(); + evt.preventDefault(); // don't submit form + break; + } + }; + + return ( + + Create ResourceQuota} done={closeAddResourceQuotaDialog}> + +
    + +
    + + + setNamespace(value)} + /> + + +
    + + +
    +
    + {quotaEntries.map(([quota, value]) => ( +
    +
    {quota}
    +
    {value}
    + quotas.set(quota, "")} /> +
    + ))} +
    +
    +
    +
    + ); +}); + +export const AddResourceQuotaDialog = withInjectables(NonInjectedAddQuotaDialog, { + getProps: (di, props) => ({ + resourceQuotaApi: di.inject(resourceQuotaApiInjectable), + state: di.inject(addResourceQuotaDialogStateInjectable), + closeAddResourceQuotaDialog: di.inject(closeAddResourceQuotaDialogInjectable), + ...props, + }), +}); diff --git a/src/renderer/components/+config-resource-quotas/resource-quota-details.scss b/src/renderer/components/+resource-quotas/details.scss similarity index 100% rename from src/renderer/components/+config-resource-quotas/resource-quota-details.scss rename to src/renderer/components/+resource-quotas/details.scss diff --git a/src/renderer/components/+config-resource-quotas/resource-quota-details.tsx b/src/renderer/components/+resource-quotas/details.tsx similarity index 98% rename from src/renderer/components/+config-resource-quotas/resource-quota-details.tsx rename to src/renderer/components/+resource-quotas/details.tsx index 2e4c91b677..7f6320c848 100644 --- a/src/renderer/components/+config-resource-quotas/resource-quota-details.tsx +++ b/src/renderer/components/+resource-quotas/details.tsx @@ -3,7 +3,7 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import "./resource-quota-details.scss"; +import "./details.scss"; import React from "react"; import kebabCase from "lodash/kebabCase"; import { observer } from "mobx-react"; diff --git a/src/renderer/components/+config-resource-quotas/index.ts b/src/renderer/components/+resource-quotas/index.ts similarity index 81% rename from src/renderer/components/+config-resource-quotas/index.ts rename to src/renderer/components/+resource-quotas/index.ts index 36cad725c1..90deafcbf2 100644 --- a/src/renderer/components/+config-resource-quotas/index.ts +++ b/src/renderer/components/+resource-quotas/index.ts @@ -4,4 +4,4 @@ */ export * from "./resource-quotas"; -export * from "./resource-quota-details"; +export * from "./details"; diff --git a/src/renderer/components/+config-resource-quotas/resource-quotas.scss b/src/renderer/components/+resource-quotas/resource-quotas.scss similarity index 100% rename from src/renderer/components/+config-resource-quotas/resource-quotas.scss rename to src/renderer/components/+resource-quotas/resource-quotas.scss diff --git a/src/renderer/components/+resource-quotas/resource-quotas.tsx b/src/renderer/components/+resource-quotas/resource-quotas.tsx new file mode 100644 index 0000000000..9584543c12 --- /dev/null +++ b/src/renderer/components/+resource-quotas/resource-quotas.tsx @@ -0,0 +1,77 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import "./resource-quotas.scss"; + +import React from "react"; +import { observer } from "mobx-react"; +import type { RouteComponentProps } from "react-router"; +import { KubeObjectListLayout } from "../kube-object-list-layout"; +import { AddResourceQuotaDialog } from "./add-dialog"; +import type { ResourceQuotaStore } from "./store"; +import { KubeObjectStatusIcon } from "../kube-object-status-icon"; +import type { ResourceQuotaRouteParams } from "../../../common/routes"; +import { withInjectables } from "@ogre-tools/injectable-react"; +import resourceQuotaStoreInjectable from "./store.injectable"; +import openAddResourceQuotaDialogInjectable from "./add-dialog-open.injectable"; + +enum columnId { + name = "name", + namespace = "namespace", + age = "age", +} + +export interface ResourceQuotasProps extends RouteComponentProps { +} + +interface Dependencies { + resourceQuotaStore: ResourceQuotaStore; + openAddResourceQuotaDialog: () => void; +} + +const NonInjectedResourceQuotas = observer(({ resourceQuotaStore, openAddResourceQuotaDialog }: Dependencies & ResourceQuotasProps) => ( + <> + item.getName(), + [columnId.namespace]: item => item.getNs(), + [columnId.age]: item => item.getTimeDiffFromNow(), + }} + searchFilters={[ + item => item.getSearchFields(), + item => item.getName(), + ]} + renderHeaderTitle="Resource Quotas" + renderTableHeader={[ + { title: "Name", className: "name", sortBy: columnId.name, id: columnId.name }, + { className: "warning", showWithColumn: columnId.name }, + { title: "Namespace", className: "namespace", sortBy: columnId.namespace, id: columnId.namespace }, + { title: "Age", className: "age", sortBy: columnId.age, id: columnId.age }, + ]} + renderTableContents={resourceQuota => [ + resourceQuota.getName(), + , + resourceQuota.getNs(), + resourceQuota.getAge(), + ]} + addRemoveButtons={{ + onAdd: openAddResourceQuotaDialog, + addTooltip: "Create new ResourceQuota", + }} + /> + + +)); + +export const ResourceQuotas = withInjectables(NonInjectedResourceQuotas, { + getProps: (di, props) => ({ + resourceQuotaStore: di.inject(resourceQuotaStoreInjectable), + openAddResourceQuotaDialog: di.inject(openAddResourceQuotaDialogInjectable), + ...props, + }), +}); diff --git a/src/renderer/components/+resource-quotas/store.injectable.ts b/src/renderer/components/+resource-quotas/store.injectable.ts new file mode 100644 index 0000000000..9fc12178a3 --- /dev/null +++ b/src/renderer/components/+resource-quotas/store.injectable.ts @@ -0,0 +1,14 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import apiManagerInjectable from "../../../common/k8s-api/api-manager.injectable"; +import type { ResourceQuotaStore } from "./store"; + +const resourceQuotaStoreInjectable = getInjectable({ + instantiate: (di) => di.inject(apiManagerInjectable).getStore("/api/v1/resourcequotas") as ResourceQuotaStore, + lifecycle: lifecycleEnum.singleton, +}); + +export default resourceQuotaStoreInjectable; diff --git a/src/renderer/components/+resource-quotas/store.ts b/src/renderer/components/+resource-quotas/store.ts new file mode 100644 index 0000000000..5dd4c3fa79 --- /dev/null +++ b/src/renderer/components/+resource-quotas/store.ts @@ -0,0 +1,13 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { KubeObjectStore } from "../../../common/k8s-api/kube-object.store"; +import type { ResourceQuota, ResourceQuotaApi } from "../../../common/k8s-api/endpoints/resource-quota.api"; + +export class ResourceQuotaStore extends KubeObjectStore { + constructor(public readonly api:ResourceQuotaApi) { + super(); + } +} diff --git a/src/renderer/components/+role-bindings/__tests__/dialog.test.tsx b/src/renderer/components/+role-bindings/__tests__/dialog.test.tsx new file mode 100644 index 0000000000..67dbb684bf --- /dev/null +++ b/src/renderer/components/+role-bindings/__tests__/dialog.test.tsx @@ -0,0 +1,67 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { ConfigurableDependencyInjectionContainer } from "@ogre-tools/injectable"; +import userEvent from "@testing-library/user-event"; +import React from "react"; +import { ClusterRoleStore } from "../../+cluster-roles/store"; +import clusterRoleStoreInjectable from "../../+cluster-roles/store.injectable"; +import { ClusterRole, ClusterRoleApi } from "../../../../common/k8s-api/endpoints"; +import { getDiForUnitTesting } from "../../../getDiForUnitTesting"; +import { type DiRender, renderFor } from "../../test-utils/renderFor"; +import { RoleBindingDialog } from "../dialog"; +import roleBindingDialogStateInjectable from "../dialog.state.injectable"; + +describe("RoleBindingDialog tests", () => { + let di: ConfigurableDependencyInjectionContainer; + let render: DiRender; + + beforeEach(() => { + di = getDiForUnitTesting(); + render = renderFor(di); + + di.override(clusterRoleStoreInjectable, () => { + const clusterRoleStore = new ClusterRoleStore(new ClusterRoleApi()); + + clusterRoleStore.items.replace([ + new ClusterRole({ + apiVersion: "rbac.authorization.k8s.io/v1", + kind: "ClusterRole", + metadata: { + name: "foobar", + resourceVersion: "1", + uid: "1", + }, + }), + ]); + + return clusterRoleStore; + }); + }); + + it("should render without any errors when closed", () => { + di.override(roleBindingDialogStateInjectable, () => ({ + isOpen: false, + roleBinding: null, + })); + const { container } = render(); + + expect(container).toBeInstanceOf(HTMLElement); + }); + + it("role select should be searchable when open", async () => { + di.override(roleBindingDialogStateInjectable, () => ({ + isOpen: true, + roleBinding: null, + })); + const res = render(); + + userEvent.click(await res.findByText("Select role", { exact: false })); + + await res.findAllByText("foobar", { + exact: false, + }); + }); +}); diff --git a/src/renderer/components/+role-bindings/close-dialog.injectable.ts b/src/renderer/components/+role-bindings/close-dialog.injectable.ts new file mode 100644 index 0000000000..3a92c24c7c --- /dev/null +++ b/src/renderer/components/+role-bindings/close-dialog.injectable.ts @@ -0,0 +1,29 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { runInAction } from "mobx"; +import { bind } from "../../utils"; +import type { RoleBindingDialogState } from "./dialog.state.injectable"; +import roleBindingDialogStateInjectable from "./dialog.state.injectable"; + +interface Dependencies { + state: RoleBindingDialogState; +} + +function closeRoleBindingDialog({ state }: Dependencies): void { + runInAction(() => { + state.isOpen = false; + state.roleBinding = null; + }); +} + +const closeRoleBindingDialogInjectable = getInjectable({ + instantiate: (di) => bind(closeRoleBindingDialog, null, { + state: di.inject(roleBindingDialogStateInjectable), + }), + lifecycle: lifecycleEnum.singleton, +}); + +export default closeRoleBindingDialogInjectable; diff --git a/src/renderer/components/+user-management/+role-bindings/details.scss b/src/renderer/components/+role-bindings/details.scss similarity index 100% rename from src/renderer/components/+user-management/+role-bindings/details.scss rename to src/renderer/components/+role-bindings/details.scss diff --git a/src/renderer/components/+role-bindings/details.tsx b/src/renderer/components/+role-bindings/details.tsx new file mode 100644 index 0000000000..3152e0a498 --- /dev/null +++ b/src/renderer/components/+role-bindings/details.tsx @@ -0,0 +1,130 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import "./details.scss"; + +import { observer } from "mobx-react"; +import React, { useEffect, useState } from "react"; +import { RoleBinding, RoleBindingSubject } from "../../../common/k8s-api/endpoints"; +import { prevDefault } from "../../utils"; +import { AddRemoveButtons } from "../add-remove-buttons"; +import type { ConfirmDialogParams } from "../confirm-dialog"; +import { DrawerTitle } from "../drawer"; +import type { KubeObjectDetailsProps } from "../kube-object-details"; +import { KubeObjectMeta } from "../kube-object-meta"; +import { Table, TableCell, TableHead, TableRow } from "../table"; +import type { RoleBindingStore } from "./store"; +import { ObservableHashSet } from "../../../common/utils/hash-set"; +import { hashRoleBindingSubject } from "./hashers"; +import { withInjectables } from "@ogre-tools/injectable-react"; +import roleBindingStoreInjectable from "./store.injectable"; +import openConfirmDialogInjectable from "../confirm-dialog/dialog-open.injectable"; +import logger from "../../../common/logger"; +import openRoleBindingDialogInjectable from "./open-dialog.injectable"; + +export interface RoleBindingDetailsProps extends KubeObjectDetailsProps { +} + +interface Dependencies { + roleBindingStore: RoleBindingStore; + openConfirmDialog: (params: ConfirmDialogParams) => void; + openRoleBindingDialog: (roleBinding?: RoleBinding) => void; +} + +const NonInjectedRoleBindingDetails = observer(({ roleBindingStore, object: roleBinding, openConfirmDialog, openRoleBindingDialog }: Dependencies & RoleBindingDetailsProps) => { + const [selectedSubjects] = useState(new ObservableHashSet([], hashRoleBindingSubject)); + + useEffect(() => selectedSubjects.clear(), [roleBinding]); + + const removeSelectedSubjects = () => { + openConfirmDialog({ + ok: () => roleBindingStore.removeSubjects(roleBinding, selectedSubjects.toJSON()), + labelOk: `Remove`, + message: ( +

    Remove selected bindings for {roleBinding.getName()}?

    + ), + }); + }; + + if (!roleBinding) { + return null; + } + + if (!(roleBinding instanceof RoleBinding)) { + logger.error("[RoleBindingDetails]: passed object that is not an instanceof RoleBinding", roleBinding); + + return null; + } + + const { roleRef } = roleBinding; + const subjects = roleBinding.getSubjects(); + + return ( +
    + + + + + + Kind + Name + API Group + + + {roleRef.kind} + {roleRef.name} + {roleRef.apiGroup} + +
    + + + {subjects.length > 0 && ( + + + + Type + Name + Namespace + + { + subjects.map((subject, i) => { + const { kind, name, namespace } = subject; + const isSelected = selectedSubjects.has(subject); + + return ( + selectedSubjects.toggle(subject))} + > + + {kind} + {name} + {namespace || "-"} + + ); + }) + } +
    + )} + + openRoleBindingDialog(roleBinding)} + onRemove={selectedSubjects.size ? removeSelectedSubjects : null} + addTooltip={`Edit bindings of ${roleRef.name}`} + removeTooltip={`Remove selected bindings from ${roleRef.name}`} + /> +
    + ); +}); + +export const RoleBindingDetails = withInjectables(NonInjectedRoleBindingDetails, { + getProps: (di, props) => ({ + roleBindingStore: di.inject(roleBindingStoreInjectable), + openConfirmDialog: di.inject(openConfirmDialogInjectable), + openRoleBindingDialog: di.inject(openRoleBindingDialogInjectable), + ...props, + }), +}); diff --git a/src/renderer/components/+user-management/+role-bindings/dialog.scss b/src/renderer/components/+role-bindings/dialog.scss similarity index 100% rename from src/renderer/components/+user-management/+role-bindings/dialog.scss rename to src/renderer/components/+role-bindings/dialog.scss diff --git a/src/renderer/components/+role-bindings/dialog.state.injectable.ts b/src/renderer/components/+role-bindings/dialog.state.injectable.ts new file mode 100644 index 0000000000..f4ce9755f4 --- /dev/null +++ b/src/renderer/components/+role-bindings/dialog.state.injectable.ts @@ -0,0 +1,22 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { observable } from "mobx"; +import type { RoleBinding } from "../../../common/k8s-api/endpoints"; + +export interface RoleBindingDialogState { + isOpen: boolean; + roleBinding: RoleBinding | null; +} + +const roleBindingDialogStateInjectable = getInjectable({ + instantiate: () => observable.object({ + isOpen: false, + roleBinding: null, + }), + lifecycle: lifecycleEnum.singleton, +}); + +export default roleBindingDialogStateInjectable; diff --git a/src/renderer/components/+role-bindings/dialog.tsx b/src/renderer/components/+role-bindings/dialog.tsx new file mode 100644 index 0000000000..d0775969e3 --- /dev/null +++ b/src/renderer/components/+role-bindings/dialog.tsx @@ -0,0 +1,256 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import "./dialog.scss"; + +import { observable, action } from "mobx"; +import { observer } from "mobx-react"; +import React, { useState } from "react"; + +import type { RoleStore } from "../+roles/store"; +import type { ServiceAccountStore } from "../+service-accounts/store"; +import { NamespaceSelect } from "../+namespaces/namespace-select"; +import type { ClusterRole, Role, RoleApi, RoleBindingSubject, ServiceAccount } from "../../../common/k8s-api/endpoints"; +import { Dialog, DialogProps } from "../dialog"; +import { EditableList } from "../editable-list"; +import { Icon } from "../icon"; +import { showDetails } from "../kube-detail-params"; +import { SubTitle } from "../layout/sub-title"; +import { Notifications } from "../notifications"; +import { Select, SelectOption } from "../select"; +import { Wizard, WizardStep } from "../wizard"; +import type { RoleBindingStore } from "./store"; +import type { ClusterRoleStore } from "../+cluster-roles/store"; +import { Input } from "../input"; +import { ObservableHashSet, nFircate, cssNames } from "../../utils"; +import { withInjectables } from "@ogre-tools/injectable-react"; +import roleBindingStoreInjectable from "./store.injectable"; +import clusterRoleStoreInjectable from "../+cluster-roles/store.injectable"; +import roleBindingDialogStateInjectable, { RoleBindingDialogState } from "./dialog.state.injectable"; +import closeRoleBindingDialogInjectable from "./close-dialog.injectable"; +import roleApiInjectable from "../../../common/k8s-api/endpoints/role.api.injectable"; +import roleStoreInjectable from "../+roles/store.injectable"; +import serviceAccountStoreInjectable from "../+service-accounts/store.injectable"; + +export interface RoleBindingDialogProps extends Partial { +} + +interface Dependencies { + roleBindingStore: RoleBindingStore; + clusterRoleStore: ClusterRoleStore; + roleApi: RoleApi; + roleStore: RoleStore; + serviceAccountStore: ServiceAccountStore; + state: RoleBindingDialogState; + closeRoleBindingDialog: () => void; +} + +const NonInjectedRoleBindingDialog = observer(({ roleApi, roleStore, serviceAccountStore, roleBindingStore, clusterRoleStore, state, className, closeRoleBindingDialog, ...dialogProps }: Dependencies & RoleBindingDialogProps) => { + const [selectedRoleRef, setSelectedRoleRef] = useState(null); + const [bindingName, setBindingName] = useState(""); + const [bindingNamespace, setBindingNamespace] = useState(""); + const [selectedAccounts] = useState(new ObservableHashSet([], sa => sa.metadata.uid)); + const [selectedUsers] = useState(observable.set([])); + const [selectedGroups] = useState(observable.set([])); + const { isOpen, roleBinding } = state; + const isEditing = Boolean(roleBinding); + + const selectedBindings: RoleBindingSubject[] = [ + ...Array.from(selectedAccounts, sa => ({ + name: sa.getName(), + kind: "ServiceAccount" as const, + namespace: sa.getNs(), + })), + ...Array.from(selectedUsers, user => ({ + name: user, + kind: "User" as const, + })), + ...Array.from(selectedGroups, group => ({ + name: group, + kind: "Group" as const, + })), + ]; + const roleRefOptions: SelectOption[] = [ + ...roleStore.items + .filter(role => role.getNs() === bindingNamespace) + .map(value => ({ value, label: value.getName() })), + ...clusterRoleStore.items + .map(value => ({ value, label: value.getName() })), + ]; + const serviceAccountOptions: SelectOption[] = ( + serviceAccountStore.items + .map(account => ({ + value: account, + label: `${account.getName()} (${account.getNs()})`, + })) + ); + + const selectedServiceAccountOptions = serviceAccountOptions.filter(({ value }) => selectedAccounts.has(value)); + + const onOpen = action(() => { + if (!roleBinding) { + return reset(); + } + + setSelectedRoleRef( + ( + roleBinding.roleRef.kind === roleApi.kind + ? roleStore + : clusterRoleStore + ) + .getByName(roleBinding.roleRef.name), + ); + setBindingName(roleBinding.getName()); + setBindingNamespace(roleBinding.getNs()); + + const [saSubjects, uSubjects, gSubjects] = nFircate(roleBinding.getSubjects(), "kind", ["ServiceAccount", "User", "Group"]); + const accountNames = new Set(saSubjects.map(acc => acc.name)); + + selectedAccounts.replace( + serviceAccountStore.items + .filter(sa => accountNames.has(sa.getName())), + ); + selectedUsers.replace(uSubjects.map(user => user.name)); + selectedGroups.replace(gSubjects.map(group => group.name)); + }); + + const reset = action(() => { + setSelectedRoleRef(null); + setBindingName(""); + setBindingNamespace(""); + selectedAccounts.clear(); + selectedUsers.clear(); + selectedGroups.clear(); + }); + + const createBindings = async () => { + try { + const { selfLink } = isEditing + ? await roleBindingStore.updateSubjects(roleBinding, selectedBindings) + : await roleBindingStore.create({ name: bindingName, namespace: bindingNamespace }, { + subjects: selectedBindings, + roleRef: { + name: selectedRoleRef.getName(), + kind: selectedRoleRef.kind, + }, + }); + + showDetails(selfLink); + closeRoleBindingDialog(); + } catch (err) { + Notifications.error(err); + } + }; + + const renderContents = () => ( + <> + + setBindingNamespace(value)} /> + + + + + + + Users + selectedUsers.add(newUser)} + items={Array.from(selectedUsers)} + remove={({ oldItem }) => selectedUsers.delete(oldItem)} /> + + Groups + selectedGroups.add(newGroup)} + items={Array.from(selectedGroups)} + remove={({ oldItem }) => selectedGroups.delete(oldItem)} /> + + Service Accounts + + + setNamespace(value)} + /> + + +
    + ); +}); + +export const AddRoleDialog = withInjectables(NonInjectedAddRoleDialog, { + getProps: (di, props) => ({ + roleStore: di.inject(roleStoreInjectable), + closeAddRoleDialog: di.inject(closeAddRoleDialogInjectable), + state: di.inject(addRoleDialogStateInjectable), + ...props, + }), +}); diff --git a/src/renderer/components/+roles/close-add-dialog.injectable.ts b/src/renderer/components/+roles/close-add-dialog.injectable.ts new file mode 100644 index 0000000000..d4cc70e431 --- /dev/null +++ b/src/renderer/components/+roles/close-add-dialog.injectable.ts @@ -0,0 +1,28 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { runInAction } from "mobx"; +import { bind } from "../../utils"; +import type { RoleAddDialogState } from "./add-dialog.state.injectable"; +import RoleDialogStateInjectable from "./add-dialog.state.injectable"; + +interface Dependencies { + state: RoleAddDialogState; +} + +function closeAddRoleDialog({ state }: Dependencies): void { + runInAction(() => { + state.isOpen = false; + }); +} + +const closeAddRoleDialogInjectable = getInjectable({ + instantiate: (di) => bind(closeAddRoleDialog, null, { + state: di.inject(RoleDialogStateInjectable), + }), + lifecycle: lifecycleEnum.singleton, +}); + +export default closeAddRoleDialogInjectable; diff --git a/src/renderer/components/+user-management/+roles/details.scss b/src/renderer/components/+roles/details.scss similarity index 100% rename from src/renderer/components/+user-management/+roles/details.scss rename to src/renderer/components/+roles/details.scss diff --git a/src/renderer/components/+roles/details.tsx b/src/renderer/components/+roles/details.tsx new file mode 100644 index 0000000000..d7066cd404 --- /dev/null +++ b/src/renderer/components/+roles/details.tsx @@ -0,0 +1,82 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import "./details.scss"; + +import { observer } from "mobx-react"; +import React from "react"; + +import { Role } from "../../../common/k8s-api/endpoints"; +import { DrawerTitle } from "../drawer"; +import type { KubeObjectDetailsProps } from "../kube-object-details"; +import { KubeObjectMeta } from "../kube-object-meta"; +import { withInjectables } from "@ogre-tools/injectable-react"; +import logger from "../../../common/logger"; + +export interface RoleDetailsProps extends KubeObjectDetailsProps { +} + +interface Dependencies { + +} + +const NonInjectedRoleDetails = observer(({ object: role }: Dependencies & RoleDetailsProps) => { + if (!role) { + return null; + } + + if (!(role instanceof Role)) { + logger.error("[RoleDetails]: passed object that is not an instanceof Role", role); + + return null; + } + + return ( +
    + + + {role.getRules().map(({ resourceNames, apiGroups, resources, verbs }, index) => ( +
    + {resources && ( + <> +
    Resources
    +
    {resources.join(", ")}
    + + )} + {verbs && ( + <> +
    Verbs
    +
    {verbs.join(", ")}
    + + )} + {apiGroups && ( + <> +
    Api Groups
    +
    + {apiGroups + .map(apiGroup => apiGroup === "" ? `'${apiGroup}'` : apiGroup) + .join(", ")} +
    + + )} + {resourceNames && ( + <> +
    Resource Names
    +
    {resourceNames.join(", ")}
    + + )} +
    + ))} +
    + ); +}); + +export const RoleDetails = withInjectables(NonInjectedRoleDetails, { + getProps: (di, props) => ({ + + ...props, + }), +}); + diff --git a/src/renderer/components/+user-management/+roles/index.ts b/src/renderer/components/+roles/index.ts similarity index 100% rename from src/renderer/components/+user-management/+roles/index.ts rename to src/renderer/components/+roles/index.ts diff --git a/src/renderer/components/+roles/open-add-dialog.injectable.ts b/src/renderer/components/+roles/open-add-dialog.injectable.ts new file mode 100644 index 0000000000..bdfae2f401 --- /dev/null +++ b/src/renderer/components/+roles/open-add-dialog.injectable.ts @@ -0,0 +1,28 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { runInAction } from "mobx"; +import { bind } from "../../utils"; +import type { RoleAddDialogState } from "./add-dialog.state.injectable"; +import RoleAddDialogStateInjectable from "./add-dialog.state.injectable"; + +interface Dependencies { + state: RoleAddDialogState; +} + +function openAddRoleDialog({ state }: Dependencies): void { + runInAction(() => { + state.isOpen = true; + }); +} + +const openAddRoleDialogInjectable = getInjectable({ + instantiate: (di) => bind(openAddRoleDialog, null, { + state: di.inject(RoleAddDialogStateInjectable), + }), + lifecycle: lifecycleEnum.singleton, +}); + +export default openAddRoleDialogInjectable; diff --git a/src/renderer/components/+roles/store.injectable.ts b/src/renderer/components/+roles/store.injectable.ts new file mode 100644 index 0000000000..5e7b91947d --- /dev/null +++ b/src/renderer/components/+roles/store.injectable.ts @@ -0,0 +1,14 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import apiManagerInjectable from "../../../common/k8s-api/api-manager.injectable"; +import type { RoleStore } from "./store"; + +const roleStoreInjectable = getInjectable({ + instantiate: (di) => di.inject(apiManagerInjectable).getStore("/apis/rbac.authorization.k8s.io/v1/roles") as RoleStore, + lifecycle: lifecycleEnum.singleton, +}); + +export default roleStoreInjectable; diff --git a/src/renderer/components/+roles/store.ts b/src/renderer/components/+roles/store.ts new file mode 100644 index 0000000000..6d86aba433 --- /dev/null +++ b/src/renderer/components/+roles/store.ts @@ -0,0 +1,25 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import type { Role, RoleApi } from "../../../common/k8s-api/endpoints"; +import { KubeObjectStore } from "../../../common/k8s-api/kube-object.store"; +import { autoBind } from "../../utils"; + +export class RoleStore extends KubeObjectStore { + constructor(public readonly api:RoleApi) { + super(); + autoBind(this); + } + + protected sortItems(items: Role[]) { + return super.sortItems(items, [ + role => role.kind, + role => role.getName(), + ]); + } + + protected createItem(params: { name: string; namespace?: string }, data?: Partial) { + return this.api.create(params, data); + } +} diff --git a/src/renderer/components/+user-management/+roles/view.scss b/src/renderer/components/+roles/view.scss similarity index 100% rename from src/renderer/components/+user-management/+roles/view.scss rename to src/renderer/components/+roles/view.scss diff --git a/src/renderer/components/+roles/view.tsx b/src/renderer/components/+roles/view.tsx new file mode 100644 index 0000000000..beef2fc538 --- /dev/null +++ b/src/renderer/components/+roles/view.tsx @@ -0,0 +1,78 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import "./view.scss"; + +import { observer } from "mobx-react"; +import React from "react"; +import type { RouteComponentProps } from "react-router"; +import { KubeObjectListLayout } from "../kube-object-list-layout"; +import { KubeObjectStatusIcon } from "../kube-object-status-icon"; +import { AddRoleDialog } from "./add-dialog"; +import type { RoleStore } from "./store"; +import type { RolesRouteParams } from "../../../common/routes"; +import { withInjectables } from "@ogre-tools/injectable-react"; +import roleStoreInjectable from "./store.injectable"; +import openAddRoleDialogInjectable from "./open-add-dialog.injectable"; + +enum columnId { + name = "name", + namespace = "namespace", + age = "age", +} + +export interface RolesProps extends RouteComponentProps { +} + +interface Dependencies { + roleStore: RoleStore; + openAddRoleDialog: () => void; +} + +const NonInjectedRoles = observer(({ roleStore, openAddRoleDialog }: Dependencies & RolesProps) => ( + <> + role.getName(), + [columnId.namespace]: role => role.getNs(), + [columnId.age]: role => role.getTimeDiffFromNow(), + }} + searchFilters={[ + role => role.getSearchFields(), + ]} + renderHeaderTitle="Roles" + renderTableHeader={[ + { title: "Name", className: "name", sortBy: columnId.name, id: columnId.name }, + { className: "warning", showWithColumn: columnId.name }, + { title: "Namespace", className: "namespace", sortBy: columnId.namespace, id: columnId.namespace }, + { title: "Age", className: "age", sortBy: columnId.age, id: columnId.age }, + ]} + renderTableContents={role => [ + role.getName(), + , + role.getNs(), + role.getAge(), + ]} + addRemoveButtons={{ + onAdd: openAddRoleDialog, + addTooltip: "Create new Role", + }} + /> + + +)); + +export const Roles = withInjectables(NonInjectedRoles, { + getProps: (di, props) => ({ + roleStore: di.inject(roleStoreInjectable), + openAddRoleDialog: di.inject(openAddRoleDialogInjectable), + ...props, + }), +}); + diff --git a/src/renderer/components/+secrets/__tests__/secret-details.test.tsx b/src/renderer/components/+secrets/__tests__/secret-details.test.tsx new file mode 100644 index 0000000000..ae9ecf9863 --- /dev/null +++ b/src/renderer/components/+secrets/__tests__/secret-details.test.tsx @@ -0,0 +1,49 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import React from "react"; +import { SecretDetails } from "../details"; +import { Secret, SecretType } from "../../../../common/k8s-api/endpoints"; +import type { ConfigurableDependencyInjectionContainer } from "@ogre-tools/injectable"; +import lookupApiLinkInjectable from "../../../../common/k8s-api/lookup-api-link.injectable"; +import { getDiForUnitTesting } from "../../../getDiForUnitTesting"; +import getStatusItemsForKubeObjectInjectable from "../../kube-object-status-icon/status-items-for-object.injectable"; +import localeTimezoneInjectable from "../../locale-date/locale-timezone.injectable"; +import { type DiRender, renderFor } from "../../test-utils/renderFor"; +import { computed } from "mobx"; + +describe("SecretDetails tests", () => { + let render: DiRender; + let di: ConfigurableDependencyInjectionContainer; + + beforeEach(() => { + di = getDiForUnitTesting(); + render = renderFor(di); + di.override(lookupApiLinkInjectable, () => () => ""); + di.override(localeTimezoneInjectable, () => computed(() => "Europe/Helsinki")); + di.override(getStatusItemsForKubeObjectInjectable, () => () => []); + }); + + it("should show the visibility toggle when the secret value is ''", () => { + const secret = new Secret({ + apiVersion: "v1", + kind: "secret", + metadata: { + name: "test", + resourceVersion: "1", + uid: "uid", + creationTimestamp: "", + selfLink: "", + }, + data: { + foobar: "", + }, + type: SecretType.Opaque, + }); + const result = render(); + + expect(result.getByTestId("foobar-secret-entry").querySelector(".Icon")).toBeDefined(); + }); +}); diff --git a/src/renderer/components/+secrets/add-dialog-close.injectable.ts b/src/renderer/components/+secrets/add-dialog-close.injectable.ts new file mode 100644 index 0000000000..d9cbea6281 --- /dev/null +++ b/src/renderer/components/+secrets/add-dialog-close.injectable.ts @@ -0,0 +1,27 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { AddSecretDialogState } from "./add-dialog.state.injectable"; +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { bind } from "../../utils"; +import addSecretDialogStateInjectable from "./add-dialog.state.injectable"; + +interface Dependencies { + addSecretDialogState: AddSecretDialogState; +} + +function closeAddSecretDialog({ addSecretDialogState }: Dependencies) { + addSecretDialogState.isOpen = false; +} + +const closeAddSecretDialogInjectable = getInjectable({ + instantiate: (di) => bind(closeAddSecretDialog, null, { + addSecretDialogState: di.inject(addSecretDialogStateInjectable), + }), + lifecycle: lifecycleEnum.singleton, +}); + +export default closeAddSecretDialogInjectable; + diff --git a/src/renderer/components/+secrets/add-dialog-open.injectable.ts b/src/renderer/components/+secrets/add-dialog-open.injectable.ts new file mode 100644 index 0000000000..4cb3a5acf4 --- /dev/null +++ b/src/renderer/components/+secrets/add-dialog-open.injectable.ts @@ -0,0 +1,27 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { AddSecretDialogState } from "./add-dialog.state.injectable"; +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { bind } from "../../utils"; +import addSecretDialogStateInjectable from "./add-dialog.state.injectable"; + +interface Dependencies { + addSecretDialogState: AddSecretDialogState; +} + +function openAddSecretDialog({ addSecretDialogState }: Dependencies) { + addSecretDialogState.isOpen = true; +} + +const openAddSecretDialogInjectable = getInjectable({ + instantiate: (di) => bind(openAddSecretDialog, null, { + addSecretDialogState: di.inject(addSecretDialogStateInjectable), + }), + lifecycle: lifecycleEnum.singleton, +}); + +export default openAddSecretDialogInjectable; + diff --git a/src/renderer/components/+config-secrets/add-secret-dialog.scss b/src/renderer/components/+secrets/add-dialog.scss similarity index 100% rename from src/renderer/components/+config-secrets/add-secret-dialog.scss rename to src/renderer/components/+secrets/add-dialog.scss diff --git a/src/renderer/components/+secrets/add-dialog.state.injectable.ts b/src/renderer/components/+secrets/add-dialog.state.injectable.ts new file mode 100644 index 0000000000..9735c4bda2 --- /dev/null +++ b/src/renderer/components/+secrets/add-dialog.state.injectable.ts @@ -0,0 +1,20 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { observable } from "mobx"; + +export interface AddSecretDialogState { + isOpen: boolean; +} + +const addSecretDialogStateInjectable = getInjectable({ + instantiate: () => observable.object({ + isOpen: false, + }), + lifecycle: lifecycleEnum.singleton, +}); + +export default addSecretDialogStateInjectable; diff --git a/src/renderer/components/+secrets/add-dialog.tsx b/src/renderer/components/+secrets/add-dialog.tsx new file mode 100644 index 0000000000..8684145b53 --- /dev/null +++ b/src/renderer/components/+secrets/add-dialog.tsx @@ -0,0 +1,223 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import "./add-dialog.scss"; + +import React, { useEffect, useState } from "react"; +import { action, observable } from "mobx"; +import { observer } from "mobx-react"; +import { Dialog, DialogProps } from "../dialog"; +import { Wizard, WizardStep } from "../wizard"; +import { Input } from "../input"; +import { systemName } from "../input/input_validators"; +import { SecretApi, SecretType } from "../../../common/k8s-api/endpoints"; +import { SubTitle } from "../layout/sub-title"; +import { NamespaceSelect } from "../+namespaces/namespace-select"; +import { Select, SelectOption } from "../select"; +import { Icon } from "../icon"; +import type { KubeObjectMetadata } from "../../../common/k8s-api/kube-object"; +import { base64, cssNames } from "../../utils"; +import { Notifications } from "../notifications"; +import upperFirst from "lodash/upperFirst"; +import { showDetails } from "../kube-detail-params"; +import { withInjectables } from "@ogre-tools/injectable-react"; +import addSecretDialogStateInjectable, { AddSecretDialogState } from "./add-dialog.state.injectable"; +import closeAddSecretDialogInjectable from "./add-dialog-close.injectable"; +import secretApiInjectable from "../../../common/k8s-api/endpoints/secret.api.injectable"; + +export interface AddSecretDialogProps extends Partial { +} + +interface ISecretTemplateField { + key: string; + value?: string; + required?: boolean; +} + +interface ISecretTemplate { + [field: string]: ISecretTemplateField[]; + annotations?: ISecretTemplateField[]; + labels?: ISecretTemplateField[]; + data?: ISecretTemplateField[]; +} + +type ISecretField = keyof ISecretTemplate; + +interface Dependencies { + state: AddSecretDialogState; + closeAddSecretDialog: () => void; + secretApi: SecretApi; +} + +const NonInjectedAddSecretDialog = observer(({ secretApi, state, closeAddSecretDialog, className, ...dialogProps }: Dependencies & AddSecretDialogProps) => { + const [secret] = useState(observable.map()); + const [name, setName] = useState(""); + const [namespace, setNamespace] = useState("default"); + const [type, setType] = useState(SecretType.Opaque); + const secretTypes = [...secret.keys()]; + const { isOpen } = state; + + const reset = action(() => { + setName(""); + secret.clear(); + secret + .set(SecretType.Opaque, {}) + .set(SecretType.ServiceAccountToken, { + annotations: [ + { key: "kubernetes.io/service-account.name", required: true }, + { key: "kubernetes.io/service-account.uid", required: true }, + ], + }); + }); + + useEffect(() => reset(), []); // initialize secret map + + const getDataFromFields = (fields: ISecretTemplateField[] = [], processValue?: (val: string) => string) => { + return fields.reduce((data, field) => { + const { key, value } = field; + + if (key) { + data[key] = processValue ? processValue(value) : value; + } + + return data; + }, {}); + }; + + const createSecret = async () => { + const { data = [], labels = [], annotations = [] } = secret.get(type); + + try { + const newSecret = await secretApi.create({ namespace, name }, { + type, + data: getDataFromFields(data, val => val ? base64.encode(val) : ""), + metadata: { + name, + namespace, + annotations: getDataFromFields(annotations), + labels: getDataFromFields(labels), + } as KubeObjectMetadata, + }); + + showDetails(newSecret.selfLink); + close(); + } catch (err) { + Notifications.error(err); + } + }; + + const addField = (field: ISecretField) => { + (secret.get(type)[field] ??= []).push({ key: "", value: "" }); + }; + + const removeField = (field: ISecretField, index: number) => { + (secret.get(type)[field] ??= []).splice(index, 1); + }; + + const renderFields = (field: ISecretField) => ( + <> + + addField(field)} + /> + +
    + {secret.get(type)[field]?.map((item, index) => { + const { key = "", value = "", required } = item; + + return ( +
    + item.key = v} + /> + item.value = v} + /> + removeField(field, index)} + /> +
    + ); + })} +
    + + ); + + return ( + + Create Secret} done={closeAddSecretDialog}> + +
    + + +
    +
    +
    + + setNamespace(value)} + /> +
    +
    + + editData(name, value, !revealSecret)} + /> + {typeof decodedVal === "string" && ( + secretsToReveal.toggle(name)} + /> + )} +
    +
    + ); + }; + + const renderData = () => { + const secrets = Object.entries(data); + + if (secrets.length === 0) { + return null; + } + + return ( + <> + + {secrets.map(renderSecret)} +
    - ); - } -} diff --git a/src/renderer/components/+user-management/+cluster-roles/details.tsx b/src/renderer/components/+user-management/+cluster-roles/details.tsx deleted file mode 100644 index 4fadd76bbe..0000000000 --- a/src/renderer/components/+user-management/+cluster-roles/details.tsx +++ /dev/null @@ -1,70 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import "./details.scss"; - -import { observer } from "mobx-react"; -import React from "react"; - -import { DrawerTitle } from "../../drawer"; -import type { KubeObjectDetailsProps } from "../../kube-object-details"; -import { KubeObjectMeta } from "../../kube-object-meta"; -import type { ClusterRole } from "../../../../common/k8s-api/endpoints"; - -interface Props extends KubeObjectDetailsProps { -} - -@observer -export class ClusterRoleDetails extends React.Component { - render() { - const { object: clusterRole } = this.props; - - if (!clusterRole) return null; - const rules = clusterRole.getRules(); - - return ( -
    - - - - {rules.map(({ resourceNames, apiGroups, resources, verbs }, index) => { - return ( -
    - {resources && ( - <> -
    Resources
    -
    {resources.join(", ")}
    - - )} - {verbs && ( - <> -
    Verbs
    -
    {verbs.join(", ")}
    - - )} - {apiGroups && ( - <> -
    Api Groups
    -
    - {apiGroups - .map(apiGroup => apiGroup === "" ? `'${apiGroup}'` : apiGroup) - .join(", ") - } -
    - - )} - {resourceNames && ( - <> -
    Resource Names
    -
    {resourceNames.join(", ")}
    - - )} -
    - ); - })} -
    - ); - } -} diff --git a/src/renderer/components/+user-management/+cluster-roles/store.ts b/src/renderer/components/+user-management/+cluster-roles/store.ts deleted file mode 100644 index b83c1de0a1..0000000000 --- a/src/renderer/components/+user-management/+cluster-roles/store.ts +++ /dev/null @@ -1,28 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ -import { apiManager } from "../../../../common/k8s-api/api-manager"; -import { ClusterRole, clusterRoleApi } from "../../../../common/k8s-api/endpoints"; -import { KubeObjectStore } from "../../../../common/k8s-api/kube-object.store"; -import { autoBind } from "../../../utils"; - -export class ClusterRolesStore extends KubeObjectStore { - api = clusterRoleApi; - - constructor() { - super(); - autoBind(this); - } - - protected sortItems(items: ClusterRole[]) { - return super.sortItems(items, [ - clusterRole => clusterRole.kind, - clusterRole => clusterRole.getName(), - ]); - } -} - -export const clusterRolesStore = new ClusterRolesStore(); - -apiManager.registerStore(clusterRolesStore); diff --git a/src/renderer/components/+user-management/+cluster-roles/view.tsx b/src/renderer/components/+user-management/+cluster-roles/view.tsx deleted file mode 100644 index 89c918c721..0000000000 --- a/src/renderer/components/+user-management/+cluster-roles/view.tsx +++ /dev/null @@ -1,63 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import "./view.scss"; - -import { observer } from "mobx-react"; -import React from "react"; -import type { RouteComponentProps } from "react-router"; -import { KubeObjectListLayout } from "../../kube-object-list-layout"; -import { KubeObjectStatusIcon } from "../../kube-object-status-icon"; -import { AddClusterRoleDialog } from "./add-dialog"; -import { clusterRolesStore } from "./store"; -import type { ClusterRolesRouteParams } from "../../../../common/routes"; - -enum columnId { - name = "name", - namespace = "namespace", - age = "age", -} - -interface Props extends RouteComponentProps { -} - -@observer -export class ClusterRoles extends React.Component { - render() { - return ( - <> - clusterRole.getName(), - [columnId.age]: clusterRole => clusterRole.getTimeDiffFromNow(), - }} - searchFilters={[ - clusterRole => clusterRole.getSearchFields(), - ]} - renderHeaderTitle="Cluster Roles" - renderTableHeader={[ - { title: "Name", className: "name", sortBy: columnId.name, id: columnId.name }, - { className: "warning", showWithColumn: columnId.name }, - { title: "Age", className: "age", sortBy: columnId.age, id: columnId.age }, - ]} - renderTableContents={clusterRole => [ - clusterRole.getName(), - , - clusterRole.getAge(), - ]} - addRemoveButtons={{ - onAdd: () => AddClusterRoleDialog.open(), - addTooltip: "Create new ClusterRole", - }} - /> - - - ); - } -} diff --git a/src/renderer/components/+user-management/+role-bindings/__tests__/dialog.test.tsx b/src/renderer/components/+user-management/+role-bindings/__tests__/dialog.test.tsx deleted file mode 100644 index 470a7006af..0000000000 --- a/src/renderer/components/+user-management/+role-bindings/__tests__/dialog.test.tsx +++ /dev/null @@ -1,63 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import userEvent from "@testing-library/user-event"; -import React from "react"; -import { clusterRolesStore } from "../../+cluster-roles/store"; -import { ClusterRole } from "../../../../../common/k8s-api/endpoints"; -import { RoleBindingDialog } from "../dialog"; -import { getDiForUnitTesting } from "../../../../getDiForUnitTesting"; -import type { DiRender } from "../../../test-utils/renderFor"; -import { renderFor } from "../../../test-utils/renderFor"; -import directoryForUserDataInjectable - from "../../../../../common/app-paths/directory-for-user-data/directory-for-user-data.injectable"; - -jest.mock("../../+cluster-roles/store"); - -describe("RoleBindingDialog tests", () => { - let render: DiRender; - - beforeEach(async () => { - const di = getDiForUnitTesting({ doGeneralOverrides: true }); - - di.override(directoryForUserDataInjectable, () => "some-directory-for-user-data"); - - await di.runSetups(); - - render = renderFor(di); - - (clusterRolesStore as any).items = [new ClusterRole({ - apiVersion: "rbac.authorization.k8s.io/v1", - kind: "ClusterRole", - metadata: { - name: "foobar", - resourceVersion: "1", - uid: "1", - }, - })]; - }); - - afterEach(() => { - RoleBindingDialog.close(); - jest.resetAllMocks(); - }); - - it("should render without any errors", () => { - const { container } = render(); - - expect(container).toBeInstanceOf(HTMLElement); - }); - - it("role select should be searchable", async () => { - RoleBindingDialog.open(); - const res = render(); - - userEvent.click(await res.findByText("Select role", { exact: false })); - - await res.findAllByText("foobar", { - exact: false, - }); - }); -}); diff --git a/src/renderer/components/+user-management/+role-bindings/details.tsx b/src/renderer/components/+user-management/+role-bindings/details.tsx deleted file mode 100644 index dd82a11b89..0000000000 --- a/src/renderer/components/+user-management/+role-bindings/details.tsx +++ /dev/null @@ -1,121 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import "./details.scss"; - -import { reaction } from "mobx"; -import { disposeOnUnmount, observer } from "mobx-react"; -import React from "react"; -import type { RoleBinding, RoleBindingSubject } from "../../../../common/k8s-api/endpoints"; -import { prevDefault, boundMethod } from "../../../utils"; -import { AddRemoveButtons } from "../../add-remove-buttons"; -import { ConfirmDialog } from "../../confirm-dialog"; -import { DrawerTitle } from "../../drawer"; -import type { KubeObjectDetailsProps } from "../../kube-object-details"; -import { KubeObjectMeta } from "../../kube-object-meta"; -import { Table, TableCell, TableHead, TableRow } from "../../table"; -import { RoleBindingDialog } from "./dialog"; -import { roleBindingsStore } from "./store"; -import { ObservableHashSet } from "../../../../common/utils/hash-set"; -import { hashRoleBindingSubject } from "./hashers"; - -interface Props extends KubeObjectDetailsProps { -} - -@observer -export class RoleBindingDetails extends React.Component { - selectedSubjects = new ObservableHashSet([], hashRoleBindingSubject); - - async componentDidMount() { - disposeOnUnmount(this, [ - reaction(() => this.props.object, () => { - this.selectedSubjects.clear(); - }), - ]); - } - - @boundMethod - removeSelectedSubjects() { - const { object: roleBinding } = this.props; - const { selectedSubjects } = this; - - ConfirmDialog.open({ - ok: () => roleBindingsStore.removeSubjects(roleBinding, selectedSubjects.toJSON()), - labelOk: `Remove`, - message: ( -

    Remove selected bindings for {roleBinding.getName()}?

    - ), - }); - } - - render() { - const { selectedSubjects } = this; - const { object: roleBinding } = this.props; - - if (!roleBinding) { - return null; - } - const { roleRef } = roleBinding; - const subjects = roleBinding.getSubjects(); - - return ( -
    - - - - - - Kind - Name - API Group - - - {roleRef.kind} - {roleRef.name} - {roleRef.apiGroup} - -
    - - - {subjects.length > 0 && ( - - - - Type - Name - Namespace - - { - subjects.map((subject, i) => { - const { kind, name, namespace } = subject; - const isSelected = selectedSubjects.has(subject); - - return ( - this.selectedSubjects.toggle(subject))} - > - - {kind} - {name} - {namespace || "-"} - - ); - }) - } -
    - )} - - RoleBindingDialog.open(roleBinding)} - onRemove={selectedSubjects.size ? this.removeSelectedSubjects : null} - addTooltip={`Edit bindings of ${roleRef.name}`} - removeTooltip={`Remove selected bindings from ${roleRef.name}`} - /> -
    - ); - } -} diff --git a/src/renderer/components/+user-management/+role-bindings/dialog.tsx b/src/renderer/components/+user-management/+role-bindings/dialog.tsx deleted file mode 100644 index 8cb4d893d9..0000000000 --- a/src/renderer/components/+user-management/+role-bindings/dialog.tsx +++ /dev/null @@ -1,282 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import "./dialog.scss"; - -import { computed, observable, makeObservable, action } from "mobx"; -import { observer } from "mobx-react"; -import React from "react"; - -import { rolesStore } from "../+roles/store"; -import { serviceAccountsStore } from "../+service-accounts/store"; -import { NamespaceSelect } from "../../+namespaces/namespace-select"; -import { ClusterRole, Role, roleApi, RoleBinding, RoleBindingSubject, ServiceAccount } from "../../../../common/k8s-api/endpoints"; -import { Dialog, DialogProps } from "../../dialog"; -import { EditableList } from "../../editable-list"; -import { Icon } from "../../icon"; -import { showDetails } from "../../kube-detail-params"; -import { SubTitle } from "../../layout/sub-title"; -import { Notifications } from "../../notifications"; -import { Select, SelectOption } from "../../select"; -import { Wizard, WizardStep } from "../../wizard"; -import { roleBindingsStore } from "./store"; -import { clusterRolesStore } from "../+cluster-roles/store"; -import { Input } from "../../input"; -import { ObservableHashSet, nFircate } from "../../../utils"; - -interface Props extends Partial { -} - -interface DialogState { - isOpen: boolean; - data?: RoleBinding; -} - -@observer -export class RoleBindingDialog extends React.Component { - static state = observable.object({ - isOpen: false, - }); - - constructor(props: Props) { - super(props); - makeObservable(this); - } - - static open(roleBinding?: RoleBinding) { - RoleBindingDialog.state.isOpen = true; - RoleBindingDialog.state.data = roleBinding; - } - - static close() { - RoleBindingDialog.state.isOpen = false; - RoleBindingDialog.state.data = undefined; - } - - get roleBinding(): RoleBinding { - return RoleBindingDialog.state.data; - } - - @computed get isEditing() { - return !!this.roleBinding; - } - - @observable.ref selectedRoleRef: Role | ClusterRole | undefined = undefined; - @observable bindingName = ""; - @observable bindingNamespace = ""; - selectedAccounts = new ObservableHashSet([], sa => sa.metadata.uid); - selectedUsers = observable.set([]); - selectedGroups = observable.set([]); - - @computed get selectedBindings(): RoleBindingSubject[] { - const serviceAccounts = Array.from(this.selectedAccounts, sa => ({ - name: sa.getName(), - kind: "ServiceAccount" as const, - namespace: sa.getNs(), - })); - const users = Array.from(this.selectedUsers, user => ({ - name: user, - kind: "User" as const, - })); - const groups = Array.from(this.selectedGroups, group => ({ - name: group, - kind: "Group" as const, - })); - - return [ - ...serviceAccounts, - ...users, - ...groups, - ]; - } - - @computed get roleRefOptions(): SelectOption[] { - const roles = rolesStore.items - .filter(role => role.getNs() === this.bindingNamespace) - .map(value => ({ value, label: value.getName() })); - const clusterRoles = clusterRolesStore.items - .map(value => ({ value, label: value.getName() })); - - return [ - ...roles, - ...clusterRoles, - ]; - } - - @computed get serviceAccountOptions(): SelectOption[] { - return serviceAccountsStore.items.map(account => ({ - value: account, - label: `${account.getName()} (${account.getNs()})`, - })); - } - - @computed get selectedServiceAccountOptions(): SelectOption[] { - return this.serviceAccountOptions.filter(({ value }) => this.selectedAccounts.has(value)); - } - - @action - onOpen = () => { - const binding = this.roleBinding; - - if (!binding) { - return this.reset(); - } - - this.selectedRoleRef = (binding.roleRef.kind === roleApi.kind ? rolesStore : clusterRolesStore) - .items - .find(item => item.getName() === binding.roleRef.name); - - this.bindingName = binding.getName(); - this.bindingNamespace = binding.getNs(); - - const [saSubjects, uSubjects, gSubjects] = nFircate(binding.getSubjects(), "kind", ["ServiceAccount", "User", "Group"]); - const accountNames = new Set(saSubjects.map(acc => acc.name)); - - this.selectedAccounts.replace( - serviceAccountsStore.items - .filter(sa => accountNames.has(sa.getName())), - ); - this.selectedUsers.replace(uSubjects.map(user => user.name)); - this.selectedGroups.replace(gSubjects.map(group => group.name)); - }; - - @action - reset = () => { - this.selectedRoleRef = undefined; - this.bindingName = ""; - this.bindingNamespace = ""; - this.selectedAccounts.clear(); - this.selectedUsers.clear(); - this.selectedGroups.clear(); - }; - - createBindings = async () => { - const { selectedRoleRef, bindingNamespace: namespace, selectedBindings } = this; - - try { - const roleBinding = this.isEditing - ? await roleBindingsStore.updateSubjects(this.roleBinding, selectedBindings) - : await roleBindingsStore.create({ name: this.bindingName, namespace }, { - subjects: selectedBindings, - roleRef: { - name: selectedRoleRef.getName(), - kind: selectedRoleRef.kind, - }, - }); - - showDetails(roleBinding.selfLink); - RoleBindingDialog.close(); - } catch (err) { - Notifications.error(err); - } - }; - - renderContents() { - return ( - <> - - this.bindingNamespace = value} - /> - - - this.bindingName = value} - /> - - - - Users - this.selectedUsers.add(newUser)} - items={Array.from(this.selectedUsers)} - remove={({ oldItem }) => this.selectedUsers.delete(oldItem)} - /> - - Groups - this.selectedGroups.add(newGroup)} - items={Array.from(this.selectedGroups)} - remove={({ oldItem }) => this.selectedGroups.delete(oldItem)} - /> - - Service Accounts - this.roleName = v} - /> - - this.namespace = value} - /> - - - - ); - } -} diff --git a/src/renderer/components/+user-management/+roles/details.tsx b/src/renderer/components/+user-management/+roles/details.tsx deleted file mode 100644 index 36675382ef..0000000000 --- a/src/renderer/components/+user-management/+roles/details.tsx +++ /dev/null @@ -1,69 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import "./details.scss"; - -import { observer } from "mobx-react"; -import React from "react"; - -import type { Role } from "../../../../common/k8s-api/endpoints"; -import { DrawerTitle } from "../../drawer"; -import type { KubeObjectDetailsProps } from "../../kube-object-details"; -import { KubeObjectMeta } from "../../kube-object-meta"; - -interface Props extends KubeObjectDetailsProps { -} - -@observer -export class RoleDetails extends React.Component { - render() { - const { object: role } = this.props; - - if (!role) return null; - const rules = role.getRules(); - - return ( -
    - - - {rules.map(({ resourceNames, apiGroups, resources, verbs }, index) => { - return ( -
    - {resources && ( - <> -
    Resources
    -
    {resources.join(", ")}
    - - )} - {verbs && ( - <> -
    Verbs
    -
    {verbs.join(", ")}
    - - )} - {apiGroups && ( - <> -
    Api Groups
    -
    - {apiGroups - .map(apiGroup => apiGroup === "" ? `'${apiGroup}'` : apiGroup) - .join(", ") - } -
    - - )} - {resourceNames && ( - <> -
    Resource Names
    -
    {resourceNames.join(", ")}
    - - )} -
    - ); - })} -
    - ); - } -} diff --git a/src/renderer/components/+user-management/+roles/store.ts b/src/renderer/components/+user-management/+roles/store.ts deleted file mode 100644 index 7518899093..0000000000 --- a/src/renderer/components/+user-management/+roles/store.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ -import { apiManager } from "../../../../common/k8s-api/api-manager"; -import { Role, roleApi } from "../../../../common/k8s-api/endpoints"; -import { KubeObjectStore } from "../../../../common/k8s-api/kube-object.store"; -import { autoBind } from "../../../utils"; - -export class RolesStore extends KubeObjectStore { - api = roleApi; - - constructor() { - super(); - autoBind(this); - } - - protected sortItems(items: Role[]) { - return super.sortItems(items, [ - role => role.kind, - role => role.getName(), - ]); - } - - protected async createItem(params: { name: string; namespace?: string }, data?: Partial) { - return roleApi.create(params, data); - } -} - -export const rolesStore = new RolesStore(); - -apiManager.registerStore(rolesStore); diff --git a/src/renderer/components/+user-management/+roles/view.tsx b/src/renderer/components/+user-management/+roles/view.tsx deleted file mode 100644 index 1acd0b2f35..0000000000 --- a/src/renderer/components/+user-management/+roles/view.tsx +++ /dev/null @@ -1,66 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import "./view.scss"; - -import { observer } from "mobx-react"; -import React from "react"; -import type { RouteComponentProps } from "react-router"; -import { KubeObjectListLayout } from "../../kube-object-list-layout"; -import { KubeObjectStatusIcon } from "../../kube-object-status-icon"; -import { AddRoleDialog } from "./add-dialog"; -import { rolesStore } from "./store"; -import type { RolesRouteParams } from "../../../../common/routes"; - -enum columnId { - name = "name", - namespace = "namespace", - age = "age", -} - -interface Props extends RouteComponentProps { -} - -@observer -export class Roles extends React.Component { - render() { - return ( - <> - role.getName(), - [columnId.namespace]: role => role.getNs(), - [columnId.age]: role => role.getTimeDiffFromNow(), - }} - searchFilters={[ - role => role.getSearchFields(), - ]} - renderHeaderTitle="Roles" - renderTableHeader={[ - { title: "Name", className: "name", sortBy: columnId.name, id: columnId.name }, - { className: "warning", showWithColumn: columnId.name }, - { title: "Namespace", className: "namespace", sortBy: columnId.namespace, id: columnId.namespace }, - { title: "Age", className: "age", sortBy: columnId.age, id: columnId.age }, - ]} - renderTableContents={role => [ - role.getName(), - , - role.getNs(), - role.getAge(), - ]} - addRemoveButtons={{ - onAdd: () => AddRoleDialog.open(), - addTooltip: "Create new Role", - }} - /> - - - ); - } -} diff --git a/src/renderer/components/+user-management/+service-accounts/create-dialog.tsx b/src/renderer/components/+user-management/+service-accounts/create-dialog.tsx deleted file mode 100644 index 1804880d64..0000000000 --- a/src/renderer/components/+user-management/+service-accounts/create-dialog.tsx +++ /dev/null @@ -1,93 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import "./create-dialog.scss"; - -import React from "react"; -import { makeObservable, observable } from "mobx"; -import { observer } from "mobx-react"; - -import { NamespaceSelect } from "../../+namespaces/namespace-select"; -import { Dialog, DialogProps } from "../../dialog"; -import { Input } from "../../input"; -import { systemName } from "../../input/input_validators"; -import { showDetails } from "../../kube-detail-params"; -import { SubTitle } from "../../layout/sub-title"; -import { Notifications } from "../../notifications"; -import { Wizard, WizardStep } from "../../wizard"; -import { serviceAccountsStore } from "./store"; - -interface Props extends Partial { -} - -@observer -export class CreateServiceAccountDialog extends React.Component { - static isOpen = observable.box(false); - - @observable name = ""; - @observable namespace = "default"; - - constructor(props: Props) { - super(props); - makeObservable(this); - } - - static open() { - CreateServiceAccountDialog.isOpen.set(true); - } - - static close() { - CreateServiceAccountDialog.isOpen.set(false); - } - - createAccount = async () => { - const { name, namespace } = this; - - try { - const serviceAccount = await serviceAccountsStore.create({ namespace, name }); - - this.name = ""; - showDetails(serviceAccount.selfLink); - CreateServiceAccountDialog.close(); - } catch (err) { - Notifications.error(err); - } - }; - - render() { - const { ...dialogProps } = this.props; - const { name, namespace } = this; - const header =
    Create Service Account
    ; - - return ( - - - - - this.name = v.toLowerCase()} - /> - - this.namespace = value} - /> - - - - ); - } -} diff --git a/src/renderer/components/+user-management/+service-accounts/details.tsx b/src/renderer/components/+user-management/+service-accounts/details.tsx deleted file mode 100644 index 081d44aa81..0000000000 --- a/src/renderer/components/+user-management/+service-accounts/details.tsx +++ /dev/null @@ -1,150 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import "./details.scss"; - -import { autorun, observable, makeObservable } from "mobx"; -import { disposeOnUnmount, observer } from "mobx-react"; -import React from "react"; -import { Link } from "react-router-dom"; - -import { secretsStore } from "../../+config-secrets/secrets.store"; -import { Secret, SecretType, ServiceAccount } from "../../../../common/k8s-api/endpoints"; -import { DrawerItem, DrawerTitle } from "../../drawer"; -import { Icon } from "../../icon"; -import type { KubeObjectDetailsProps } from "../../kube-object-details"; -import { KubeObjectMeta } from "../../kube-object-meta"; -import { Spinner } from "../../spinner"; -import { ServiceAccountsSecret } from "./secret"; -import { getDetailsUrl } from "../../kube-detail-params"; - -interface Props extends KubeObjectDetailsProps { -} - -@observer -export class ServiceAccountsDetails extends React.Component { - @observable secrets: Secret[]; - @observable imagePullSecrets: Secret[]; - - @disposeOnUnmount - loadSecrets = autorun(async () => { - this.secrets = null; - this.imagePullSecrets = null; - const { object: serviceAccount } = this.props; - - if (!serviceAccount) { - return; - } - const namespace = serviceAccount.getNs(); - const secrets = serviceAccount.getSecrets().map(({ name }) => { - return secretsStore.load({ name, namespace }); - }); - - this.secrets = await Promise.all(secrets); - const imagePullSecrets = serviceAccount.getImagePullSecrets().map(async ({ name }) => { - return secretsStore.load({ name, namespace }).catch(() => this.generateDummySecretObject(name)); - }); - - this.imagePullSecrets = await Promise.all(imagePullSecrets); - }); - - constructor(props: Props) { - super(props); - makeObservable(this); - } - - renderSecrets() { - const { secrets } = this; - - if (!secrets) { - return ; - } - - return secrets.map(secret => - , - ); - } - - renderImagePullSecrets() { - const { imagePullSecrets } = this; - - if (!imagePullSecrets) { - return ; - } - - return this.renderSecretLinks(imagePullSecrets); - } - - renderSecretLinks(secrets: Secret[]) { - return secrets.map((secret) => { - if (secret.getId() === null) { - return ( -
    - {secret.getName()} - -
    - ); - } - - return ( - - {secret.getName()} - - ); - }); - } - - generateDummySecretObject(name: string) { - return new Secret({ - apiVersion: "v1", - kind: "Secret", - metadata: { - name, - uid: null, - selfLink: null, - resourceVersion: null, - }, - type: SecretType.Opaque, - }); - } - - render() { - const { object: serviceAccount } = this.props; - - if (!serviceAccount) { - return null; - } - const tokens = secretsStore.items.filter(secret => - secret.getNs() == serviceAccount.getNs() && - secret.getAnnotations().some(annot => annot == `kubernetes.io/service-account.name: ${serviceAccount.getName()}`), - ); - const imagePullSecrets = serviceAccount.getImagePullSecrets(); - - return ( -
    - - - {tokens.length > 0 && - - {this.renderSecretLinks(tokens)} - - } - {imagePullSecrets.length > 0 && - - {this.renderImagePullSecrets()} - - } - - -
    - {this.renderSecrets()} -
    -
    - ); - } -} diff --git a/src/renderer/components/+user-management/+service-accounts/secret.tsx b/src/renderer/components/+user-management/+service-accounts/secret.tsx deleted file mode 100644 index 97959dbac9..0000000000 --- a/src/renderer/components/+user-management/+service-accounts/secret.tsx +++ /dev/null @@ -1,77 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import "./secret.scss"; - -import moment from "moment"; -import React from "react"; - -import type { Secret } from "../../../../common/k8s-api/endpoints/secret.api"; -import { prevDefault } from "../../../utils"; -import { Icon } from "../../icon"; - -interface Props { - secret: Secret; -} - -interface State { - showToken: boolean; -} - -export class ServiceAccountsSecret extends React.Component { - public state: State = { - showToken: false, - }; - - renderSecretValue() { - const { secret } = this.props; - const { showToken } = this.state; - - return ( - <> - {!showToken && ( - <> - {"•".repeat(16)} - this.setState({ showToken: true }))} - /> - - )} - {showToken && ( - {secret.getToken()} - )} - - ); - } - - render() { - const { metadata: { name, creationTimestamp }, type } = this.props.secret; - - return ( -
    -
    - Name: - {name} -
    -
    - Value: - {this.renderSecretValue()} -
    -
    - Created at: - - {moment(creationTimestamp).format("LLL")} - -
    -
    - Type: - {type} -
    -
    - ); - } -} diff --git a/src/renderer/components/+user-management/+service-accounts/store.ts b/src/renderer/components/+user-management/+service-accounts/store.ts deleted file mode 100644 index 96ae5cc547..0000000000 --- a/src/renderer/components/+user-management/+service-accounts/store.ts +++ /dev/null @@ -1,27 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import { apiManager } from "../../../../common/k8s-api/api-manager"; -import { ServiceAccount, serviceAccountsApi } from "../../../../common/k8s-api/endpoints"; -import { KubeObjectStore } from "../../../../common/k8s-api/kube-object.store"; -import { autoBind } from "../../../utils"; - -export class ServiceAccountsStore extends KubeObjectStore { - api = serviceAccountsApi; - - constructor() { - super(); - autoBind(this); - } - - protected async createItem(params: { name: string; namespace?: string }) { - await super.createItem(params); - - return this.api.get(params); // hackfix: load freshly created account, cause it doesn't have "secrets" field yet - } -} - -export const serviceAccountsStore = new ServiceAccountsStore(); -apiManager.registerStore(serviceAccountsStore); diff --git a/src/renderer/components/+user-management/+service-accounts/view.tsx b/src/renderer/components/+user-management/+service-accounts/view.tsx deleted file mode 100644 index 84721fe8d5..0000000000 --- a/src/renderer/components/+user-management/+service-accounts/view.tsx +++ /dev/null @@ -1,84 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import "./view.scss"; - -import { observer } from "mobx-react"; -import React from "react"; -import type { RouteComponentProps } from "react-router"; -import type { ServiceAccount } from "../../../../common/k8s-api/endpoints/service-accounts.api"; -import { Icon } from "../../icon"; -import { KubeObjectListLayout } from "../../kube-object-list-layout"; -import { KubeObjectStatusIcon } from "../../kube-object-status-icon"; -import type { KubeObjectMenuProps } from "../../kube-object-menu"; -import { openServiceAccountKubeConfig } from "../../kubeconfig-dialog"; -import { MenuItem } from "../../menu"; -import { CreateServiceAccountDialog } from "./create-dialog"; -import { serviceAccountsStore } from "./store"; -import type { ServiceAccountsRouteParams } from "../../../../common/routes"; - -enum columnId { - name = "name", - namespace = "namespace", - age = "age", -} - -interface Props extends RouteComponentProps { -} - -@observer -export class ServiceAccounts extends React.Component { - render() { - return ( - <> - account.getName(), - [columnId.namespace]: account => account.getNs(), - [columnId.age]: account => account.getTimeDiffFromNow(), - }} - searchFilters={[ - account => account.getSearchFields(), - ]} - renderHeaderTitle="Service Accounts" - renderTableHeader={[ - { title: "Name", className: "name", sortBy: columnId.name, id: columnId.name }, - { className: "warning", showWithColumn: columnId.name }, - { title: "Namespace", className: "namespace", sortBy: columnId.namespace, id: columnId.namespace }, - { title: "Age", className: "age", sortBy: columnId.age, id: columnId.age }, - ]} - renderTableContents={account => [ - account.getName(), - , - account.getNs(), - account.getAge(), - ]} - renderItemMenu={(item: ServiceAccount) => { - return ; - }} - addRemoveButtons={{ - onAdd: () => CreateServiceAccountDialog.open(), - addTooltip: "Create new Service Account", - }} - /> - - - ); - } -} - -export function ServiceAccountMenu(props: KubeObjectMenuProps) { - const { object, toolbar } = props; - - return ( - openServiceAccountKubeConfig(object)}> - - Kubeconfig - - ); -} diff --git a/src/renderer/components/+user-management/index.ts b/src/renderer/components/+user-management/index.ts deleted file mode 100644 index 8d292d0a2d..0000000000 --- a/src/renderer/components/+user-management/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -export * from "./user-management"; diff --git a/src/renderer/components/+user-management/layout.tsx b/src/renderer/components/+user-management/layout.tsx new file mode 100644 index 0000000000..aa4def1c51 --- /dev/null +++ b/src/renderer/components/+user-management/layout.tsx @@ -0,0 +1,31 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import React from "react"; +import { observer } from "mobx-react"; +import { TabLayout, TabLayoutRoute } from "../layout/tab-layout"; +import type { IComputedValue } from "mobx"; +import { withInjectables } from "@ogre-tools/injectable-react"; +import userManagementRoutesInjectable from "./routes.injectable"; + +export interface UserManagementLayoutProps {} + +interface Dependencies { + routes: IComputedValue; +} + +const NonInjectedUserManagementLayout = observer(({ routes }: Dependencies & UserManagementLayoutProps) => ( + +)); + +export const UserManagementLayout = withInjectables(NonInjectedUserManagementLayout, { + getProps: (di, props) => ({ + routes: di.inject(userManagementRoutesInjectable), + ...props, + }), +}); diff --git a/src/renderer/components/+user-management/user-management.tsx b/src/renderer/components/+user-management/routes.injectable.ts similarity index 63% rename from src/renderer/components/+user-management/user-management.tsx rename to src/renderer/components/+user-management/routes.injectable.ts index 1d39eba153..5d786f7f90 100644 --- a/src/renderer/components/+user-management/user-management.tsx +++ b/src/renderer/components/+user-management/routes.injectable.ts @@ -2,24 +2,25 @@ * Copyright (c) OpenLens Authors. All rights reserved. * Licensed under MIT License. See LICENSE in root directory for more information. */ - -import "./user-management.scss"; - -import React from "react"; -import { observer } from "mobx-react"; -import { TabLayout, TabLayoutRoute } from "../layout/tab-layout"; +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import type { KubeResource } from "../../../common/rbac"; +import isAllowedResourceInjectable from "../../utils/allowed-resource.injectable"; +import type { TabLayoutRoute } from "../layout/tab-layout"; import { PodSecurityPolicies } from "../+pod-security-policies"; -import { isAllowedResource } from "../../../common/utils/allowed-resource"; import * as routes from "../../../common/routes"; -import { ClusterRoleBindings } from "./+cluster-role-bindings"; -import { ServiceAccounts } from "./+service-accounts"; -import { Roles } from "./+roles"; -import { RoleBindings } from "./+role-bindings"; -import { ClusterRoles } from "./+cluster-roles"; +import { ClusterRoleBindings } from "../+cluster-role-bindings"; +import { ServiceAccounts } from "../+service-accounts"; +import { Roles } from "../+roles"; +import { RoleBindings } from "../+role-bindings"; +import { ClusterRoles } from "../+cluster-roles"; +import { computed, IComputedValue } from "mobx"; -@observer -export class UserManagement extends React.Component { - static get tabRoutes() { +interface Dependencies { + isAllowedResource: (resource: KubeResource) => boolean; +} + +function getUserManagementRoutes({ isAllowedResource }: Dependencies): IComputedValue { + return computed(() => { const tabRoutes: TabLayoutRoute[] = []; if (isAllowedResource("serviceaccounts")) { @@ -77,11 +78,14 @@ export class UserManagement extends React.Component { } return tabRoutes; - } - - render() { - return ( - - ); - } + }); } + +const userManagementRoutesInjectable = getInjectable({ + instantiate: (di) => getUserManagementRoutes({ + isAllowedResource: di.inject(isAllowedResourceInjectable), + }), + lifecycle: lifecycleEnum.singleton, +}); + +export default userManagementRoutesInjectable; diff --git a/src/renderer/components/+user-management/sidebar-item.tsx b/src/renderer/components/+user-management/sidebar-item.tsx new file mode 100644 index 0000000000..98b4a8a2f9 --- /dev/null +++ b/src/renderer/components/+user-management/sidebar-item.tsx @@ -0,0 +1,45 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { withInjectables } from "@ogre-tools/injectable-react"; +import type { IComputedValue } from "mobx"; +import { observer } from "mobx-react"; +import React from "react"; +import { usersManagementRoute, usersManagementURL } from "../../../common/routes"; +import { isActiveRoute } from "../../navigation"; +import { Icon } from "../icon"; +import { SidebarItem } from "../layout/sidebar-item"; +import type { TabLayoutRoute } from "../layout/tab-layout"; +import { TabRouteTree } from "../layout/tab-route-tree"; +import userManagementRoutesInjectable from "./routes.injectable"; + +export interface UserManagementSidebarItemProps {} + +interface Dependencies { + routes: IComputedValue; +} + +const NonInjectedUserManagementSidebarItem = observer(({ routes }: Dependencies & UserManagementSidebarItemProps) => { + const tabRoutes = routes.get(); + + return ( + } + > + + + ); +}); + +export const UserManagementSidebarItem = withInjectables(NonInjectedUserManagementSidebarItem, { + getProps: (di, props) => ({ + routes: di.inject(userManagementRoutesInjectable), + ...props, + }), +}); diff --git a/src/renderer/components/+user-management/user-management.scss b/src/renderer/components/+user-management/user-management.scss deleted file mode 100644 index d6788626da..0000000000 --- a/src/renderer/components/+user-management/user-management.scss +++ /dev/null @@ -1,7 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -.UserManagement { -} diff --git a/src/renderer/components/+welcome/__test__/welcome.test.tsx b/src/renderer/components/+welcome/__test__/welcome.test.tsx index 5b62bb6140..1117ee3646 100644 --- a/src/renderer/components/+welcome/__test__/welcome.test.tsx +++ b/src/renderer/components/+welcome/__test__/welcome.test.tsx @@ -58,7 +58,7 @@ describe("", () => { const { container } = render(); - expect(screen.queryByTestId(testId)).toBeInTheDocument(); + expect(await screen.findByTestId(testId)).toBeInTheDocument(); expect(container.getElementsByClassName("logo").length).toBe(0); }); @@ -75,14 +75,14 @@ describe("", () => { render(); - expect(screen.queryByTestId("welcome-banner-container")).toHaveStyle({ + expect(await screen.findByTestId("welcome-banner-container")).toHaveStyle({ // should take the max width of the banners (if > defaultWidth) width: `800px`, }); - expect(screen.queryByTestId("welcome-text-container")).toHaveStyle({ + expect(await screen.findByTestId("welcome-text-container")).toHaveStyle({ width: `${defaultWidth}px`, }); - expect(screen.queryByTestId("welcome-menu-container")).toHaveStyle({ + expect(await screen.findByTestId("welcome-menu-container")).toHaveStyle({ width: `${defaultWidth}px`, }); }); diff --git a/src/renderer/components/+workloads-cronjobs/cronjob-details.tsx b/src/renderer/components/+workloads-cronjobs/cronjob-details.tsx deleted file mode 100644 index 928e44d0f1..0000000000 --- a/src/renderer/components/+workloads-cronjobs/cronjob-details.tsx +++ /dev/null @@ -1,125 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import "./cronjob-details.scss"; - -import React from "react"; -import kebabCase from "lodash/kebabCase"; -import { disposeOnUnmount, observer } from "mobx-react"; -import { DrawerItem, DrawerTitle } from "../drawer"; -import { Badge } from "../badge/badge"; -import { jobStore } from "../+workloads-jobs/job.store"; -import { Link } from "react-router-dom"; -import { cronJobStore } from "./cronjob.store"; -import type { KubeObjectDetailsProps } from "../kube-object-details"; -import { getDetailsUrl } from "../kube-detail-params"; -import { CronJob, Job } from "../../../common/k8s-api/endpoints"; -import { KubeObjectMeta } from "../kube-object-meta"; -import logger from "../../../common/logger"; -import type { KubeObjectStore } from "../../../common/k8s-api/kube-object.store"; -import type { KubeObject } from "../../../common/k8s-api/kube-object"; -import type { Disposer } from "../../../common/utils"; -import { withInjectables } from "@ogre-tools/injectable-react"; -import kubeWatchApiInjectable - from "../../kube-watch-api/kube-watch-api.injectable"; - -interface Props extends KubeObjectDetailsProps { -} - -interface Dependencies { - subscribeStores: (stores: KubeObjectStore[]) => Disposer -} - -@observer -class NonInjectedCronJobDetails extends React.Component { - componentDidMount() { - disposeOnUnmount(this, [ - this.props.subscribeStores([ - jobStore, - ]), - ]); - } - - render() { - const { object: cronJob } = this.props; - - if (!cronJob) { - return null; - } - - if (!(cronJob instanceof CronJob)) { - logger.error("[CronJobDetails]: passed object that is not an instanceof CronJob", cronJob); - - return null; - } - - const childJobs = jobStore.getJobsByOwner(cronJob); - - return ( -
    - - - { - cronJob.isNeverRun() - ? `never (${cronJob.getSchedule()})` - : cronJob.getSchedule() - } - - - {cronJobStore.getActiveJobsNum(cronJob)} - - - {cronJob.getSuspendFlag()} - - - {cronJob.getLastScheduleTime()} - - {childJobs.length > 0 && - <> - - {childJobs.map((job: Job) => { - const selectors = job.getSelectors(); - const condition = job.getCondition(); - - return ( -
    -
    - - {job.getName()} - -
    - - {condition && ( - - )} - - - { - selectors.map(label => ) - } - -
    - );}) - } - - } -
    - ); - } -} - -export const CronJobDetails = withInjectables( - NonInjectedCronJobDetails, - - { - getProps: (di, props) => ({ - subscribeStores: di.inject(kubeWatchApiInjectable).subscribeStores, - ...props, - }), - }, -); diff --git a/src/renderer/components/+workloads-cronjobs/cronjob-trigger-dialog.tsx b/src/renderer/components/+workloads-cronjobs/cronjob-trigger-dialog.tsx deleted file mode 100644 index 689dd87ee1..0000000000 --- a/src/renderer/components/+workloads-cronjobs/cronjob-trigger-dialog.tsx +++ /dev/null @@ -1,152 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import "./cronjob-trigger-dialog.scss"; - -import React, { Component } from "react"; -import { observable, makeObservable } from "mobx"; -import { observer } from "mobx-react"; -import { Dialog, DialogProps } from "../dialog"; -import { Wizard, WizardStep } from "../wizard"; -import { CronJob, cronJobApi, jobApi } from "../../../common/k8s-api/endpoints"; -import { Notifications } from "../notifications"; -import { cssNames } from "../../utils"; -import { Input } from "../input"; -import { systemName, maxLength } from "../input/input_validators"; -import type { KubeObjectMetadata } from "../../../common/k8s-api/kube-object"; - -interface Props extends Partial { -} - -const dialogState = observable.object({ - isOpen: false, - data: null as CronJob, -}); - -@observer -export class CronJobTriggerDialog extends Component { - @observable jobName = ""; - @observable ready = false; - - constructor(props: Props) { - super(props); - makeObservable(this); - } - - static open(cronjob: CronJob) { - dialogState.isOpen = true; - dialogState.data = cronjob; - } - - static close() { - dialogState.isOpen = false; - } - - get cronjob() { - return dialogState.data; - } - - close = () => { - CronJobTriggerDialog.close(); - }; - - onOpen = async () => { - const { cronjob } = this; - - this.jobName = cronjob ? `${cronjob.getName()}-manual-${Math.random().toString(36).slice(2, 7)}` : ""; - this.jobName = this.jobName.slice(0, 63); - this.ready = true; - }; - - onClose = () => { - this.ready = false; - }; - - trigger = async () => { - const { cronjob } = this; - const { close } = this; - - try { - const cronjobDefinition = await cronJobApi.get({ - name: cronjob.getName(), - namespace: cronjob.getNs(), - }); - - await jobApi.create({ - name: this.jobName, - namespace: cronjob.getNs(), - }, { - spec: cronjobDefinition.spec.jobTemplate.spec, - metadata: { - ownerReferences: [{ - apiVersion: cronjob.apiVersion, - blockOwnerDeletion: true, - controller: true, - kind: cronjob.kind, - name: cronjob.metadata.name, - uid: cronjob.metadata.uid, - }], - } as KubeObjectMetadata, - }); - - close(); - } catch (err) { - Notifications.error(err); - } - }; - - renderContents() { - return ( - <> -
    - Job name: -
    -
    - this.jobName = v.toLowerCase()} - className="box grow" - /> -
    - - ); - } - - render() { - const { className, ...dialogProps } = this.props; - const cronjobName = this.cronjob ? this.cronjob.getName() : ""; - const header = ( -
    - Trigger CronJob {cronjobName} -
    - ); - - return ( - - - - {this.renderContents()} - - - - ); - } -} diff --git a/src/renderer/components/+workloads-cronjobs/cronjobs.tsx b/src/renderer/components/+workloads-cronjobs/cronjobs.tsx deleted file mode 100644 index 2bb2de3277..0000000000 --- a/src/renderer/components/+workloads-cronjobs/cronjobs.tsx +++ /dev/null @@ -1,142 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import "./cronjobs.scss"; - -import React from "react"; -import { observer } from "mobx-react"; -import type { RouteComponentProps } from "react-router"; -import { CronJob, cronJobApi } from "../../../common/k8s-api/endpoints/cron-job.api"; -import { MenuItem } from "../menu"; -import { Icon } from "../icon"; -import { cronJobStore } from "./cronjob.store"; -import { jobStore } from "../+workloads-jobs/job.store"; -import { eventStore } from "../+events/event.store"; -import type { KubeObjectMenuProps } from "../kube-object-menu"; -import { KubeObjectListLayout } from "../kube-object-list-layout"; -import { CronJobTriggerDialog } from "./cronjob-trigger-dialog"; -import { KubeObjectStatusIcon } from "../kube-object-status-icon"; -import { ConfirmDialog } from "../confirm-dialog/confirm-dialog"; -import { Notifications } from "../notifications/notifications"; -import type { CronJobsRouteParams } from "../../../common/routes"; -import moment from "moment"; - -enum columnId { - name = "name", - namespace = "namespace", - schedule = "schedule", - suspend = "suspend", - active = "active", - lastSchedule = "last-schedule", - age = "age", -} - -interface Props extends RouteComponentProps { -} - -@observer -export class CronJobs extends React.Component { - render() { - return ( - cronJob.getName(), - [columnId.namespace]: cronJob => cronJob.getNs(), - [columnId.suspend]: cronJob => cronJob.getSuspendFlag(), - [columnId.active]: cronJob => cronJobStore.getActiveJobsNum(cronJob), - [columnId.lastSchedule]: cronJob => ( - cronJob.status?.lastScheduleTime - ? moment().diff(cronJob.status.lastScheduleTime) - : 0 - ), - [columnId.age]: cronJob => cronJob.getTimeDiffFromNow(), - }} - searchFilters={[ - cronJob => cronJob.getSearchFields(), - cronJob => cronJob.getSchedule(), - ]} - renderHeaderTitle="Cron Jobs" - renderTableHeader={[ - { title: "Name", className: "name", sortBy: columnId.name, id: columnId.name }, - { className: "warning", showWithColumn: columnId.name }, - { title: "Namespace", className: "namespace", sortBy: columnId.namespace, id: columnId.namespace }, - { title: "Schedule", className: "schedule", id: columnId.schedule }, - { title: "Suspend", className: "suspend", sortBy: columnId.suspend, id: columnId.suspend }, - { title: "Active", className: "active", sortBy: columnId.active, id: columnId.active }, - { title: "Last schedule", className: "last-schedule", sortBy: columnId.lastSchedule, id: columnId.lastSchedule }, - { title: "Age", className: "age", sortBy: columnId.age, id: columnId.age }, - ]} - renderTableContents={cronJob => [ - cronJob.getName(), - , - cronJob.getNs(), - cronJob.isNeverRun() ? "never" : cronJob.getSchedule(), - cronJob.getSuspendFlag(), - cronJobStore.getActiveJobsNum(cronJob), - cronJob.getLastScheduleTime(), - cronJob.getAge(), - ]} - renderItemMenu={(item: CronJob) => { - return ; - }} - /> - ); - } -} - -export function CronJobMenu(props: KubeObjectMenuProps) { - const { object, toolbar } = props; - - return ( - <> - CronJobTriggerDialog.open(object)}> - - Trigger - - - {object.isSuspend() ? - ConfirmDialog.open({ - ok: async () => { - try { - await cronJobApi.resume({ namespace: object.getNs(), name: object.getName() }); - } catch (err) { - Notifications.error(err); - } - }, - labelOk: `Resume`, - message: ( -

    - Resume CronJob {object.getName()}? -

    ), - })}> - - Resume -
    - - : ConfirmDialog.open({ - ok: async () => { - try { - await cronJobApi.suspend({ namespace: object.getNs(), name: object.getName() }); - } catch (err) { - Notifications.error(err); - } - }, - labelOk: `Suspend`, - message: ( -

    - Suspend CronJob {object.getName()}? -

    ), - })}> - - Suspend -
    - } - - ); -} diff --git a/src/renderer/components/+workloads-daemonsets/daemonset-details.tsx b/src/renderer/components/+workloads-daemonsets/daemonset-details.tsx deleted file mode 100644 index f46752e93b..0000000000 --- a/src/renderer/components/+workloads-daemonsets/daemonset-details.tsx +++ /dev/null @@ -1,144 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import "./daemonset-details.scss"; - -import React from "react"; -import { disposeOnUnmount, observer } from "mobx-react"; -import { DrawerItem } from "../drawer"; -import { Badge } from "../badge"; -import { PodDetailsStatuses } from "../+workloads-pods/pod-details-statuses"; -import { PodDetailsTolerations } from "../+workloads-pods/pod-details-tolerations"; -import { PodDetailsAffinities } from "../+workloads-pods/pod-details-affinities"; -import { daemonSetStore } from "./daemonsets.store"; -import { podsStore } from "../+workloads-pods/pods.store"; -import type { KubeObjectDetailsProps } from "../kube-object-details"; -import { DaemonSet, getMetricsForDaemonSets, IPodMetrics } from "../../../common/k8s-api/endpoints"; -import { ResourceMetrics, ResourceMetricsText } from "../resource-metrics"; -import { PodCharts, podMetricTabs } from "../+workloads-pods/pod-charts"; -import { makeObservable, observable, reaction } from "mobx"; -import { PodDetailsList } from "../+workloads-pods/pod-details-list"; -import { KubeObjectMeta } from "../kube-object-meta"; -import { getActiveClusterEntity } from "../../api/catalog-entity-registry"; -import { ClusterMetricsResourceType } from "../../../common/cluster-types"; -import { boundMethod, Disposer } from "../../utils"; -import logger from "../../../common/logger"; -import type { KubeObjectStore } from "../../../common/k8s-api/kube-object.store"; -import type { KubeObject } from "../../../common/k8s-api/kube-object"; -import { withInjectables } from "@ogre-tools/injectable-react"; -import kubeWatchApiInjectable - from "../../kube-watch-api/kube-watch-api.injectable"; - -interface Props extends KubeObjectDetailsProps { -} - -interface Dependencies { - subscribeStores: (stores: KubeObjectStore[]) => Disposer -} - -@observer -class NonInjectedDaemonSetDetails extends React.Component { - @observable metrics: IPodMetrics = null; - - constructor(props: Props & Dependencies) { - super(props); - makeObservable(this); - } - - componentDidMount() { - disposeOnUnmount(this, [ - reaction(() => this.props.object, () => { - this.metrics = null; - }), - this.props.subscribeStores([ - podsStore, - ]), - ]); - } - - @boundMethod - async loadMetrics() { - const { object: daemonSet } = this.props; - - this.metrics = await getMetricsForDaemonSets([daemonSet], daemonSet.getNs(), ""); - } - - render() { - const { object: daemonSet } = this.props; - - if (!daemonSet) { - return null; - } - - if (!(daemonSet instanceof DaemonSet)) { - logger.error("[DaemonSetDetails]: passed object that is not an instanceof DaemonSet", daemonSet); - - return null; - } - - const { spec } = daemonSet; - const selectors = daemonSet.getSelectors(); - const images = daemonSet.getImages(); - const nodeSelector = daemonSet.getNodeSelectors(); - const childPods = daemonSetStore.getChildPods(daemonSet); - const isMetricHidden = getActiveClusterEntity()?.isMetricHidden(ClusterMetricsResourceType.DaemonSet); - - return ( -
    - {!isMetricHidden && podsStore.isLoaded && ( - - - - )} - - {selectors.length > 0 && - - { - selectors.map(label => ) - } - - } - {nodeSelector.length > 0 && - - { - nodeSelector.map(label => ()) - } - - } - {images.length > 0 && - - { - images.map(image =>

    {image}

    ) - } -
    - } - - {spec.updateStrategy.type} - - - - - - - - -
    - ); - } -} - -export const DaemonSetDetails = withInjectables( - NonInjectedDaemonSetDetails, - - { - getProps: (di, props) => ({ - subscribeStores: di.inject(kubeWatchApiInjectable).subscribeStores, - ...props, - }), - }, -); diff --git a/src/renderer/components/+workloads-daemonsets/daemonsets.tsx b/src/renderer/components/+workloads-daemonsets/daemonsets.tsx deleted file mode 100644 index 73befe42df..0000000000 --- a/src/renderer/components/+workloads-daemonsets/daemonsets.tsx +++ /dev/null @@ -1,76 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import "./daemonsets.scss"; - -import React from "react"; -import { observer } from "mobx-react"; -import type { RouteComponentProps } from "react-router"; -import type { DaemonSet } from "../../../common/k8s-api/endpoints"; -import { eventStore } from "../+events/event.store"; -import { daemonSetStore } from "./daemonsets.store"; -import { podsStore } from "../+workloads-pods/pods.store"; -import { KubeObjectListLayout } from "../kube-object-list-layout"; -import { Badge } from "../badge"; -import { KubeObjectStatusIcon } from "../kube-object-status-icon"; -import type { DaemonSetsRouteParams } from "../../../common/routes"; - -enum columnId { - name = "name", - namespace = "namespace", - pods = "pods", - labels = "labels", - age = "age", -} - -interface Props extends RouteComponentProps { -} - -@observer -export class DaemonSets extends React.Component { - getPodsLength(daemonSet: DaemonSet) { - return daemonSetStore.getChildPods(daemonSet).length; - } - - render() { - return ( - daemonSet.getName(), - [columnId.namespace]: daemonSet => daemonSet.getNs(), - [columnId.pods]: daemonSet => this.getPodsLength(daemonSet), - [columnId.age]: daemonSet => daemonSet.getTimeDiffFromNow(), - }} - searchFilters={[ - daemonSet => daemonSet.getSearchFields(), - daemonSet => daemonSet.getLabels(), - ]} - renderHeaderTitle="Daemon Sets" - renderTableHeader={[ - { title: "Name", className: "name", sortBy: columnId.name, id: columnId.name }, - { title: "Namespace", className: "namespace", sortBy: columnId.namespace, id: columnId.namespace }, - { title: "Pods", className: "pods", sortBy: columnId.pods, id: columnId.pods }, - { className: "warning", showWithColumn: columnId.pods }, - { title: "Node Selector", className: "labels scrollable", id: columnId.labels }, - { title: "Age", className: "age", sortBy: columnId.age, id: columnId.age }, - ]} - renderTableContents={daemonSet => [ - daemonSet.getName(), - daemonSet.getNs(), - this.getPodsLength(daemonSet), - , - daemonSet.getNodeSelectors().map(selector => ( - - )), - daemonSet.getAge(), - ]} - /> - ); - } -} diff --git a/src/renderer/components/+workloads-deployments/deployment-details.tsx b/src/renderer/components/+workloads-deployments/deployment-details.tsx deleted file mode 100644 index cc7be031fa..0000000000 --- a/src/renderer/components/+workloads-deployments/deployment-details.tsx +++ /dev/null @@ -1,169 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import "./deployment-details.scss"; - -import React from "react"; -import kebabCase from "lodash/kebabCase"; -import { disposeOnUnmount, observer } from "mobx-react"; -import { DrawerItem } from "../drawer"; -import { Badge } from "../badge"; -import { Deployment, getMetricsForDeployments, IPodMetrics } from "../../../common/k8s-api/endpoints"; -import { PodDetailsTolerations } from "../+workloads-pods/pod-details-tolerations"; -import { PodDetailsAffinities } from "../+workloads-pods/pod-details-affinities"; -import { podsStore } from "../+workloads-pods/pods.store"; -import type { KubeObjectDetailsProps } from "../kube-object-details"; -import { ResourceMetrics, ResourceMetricsText } from "../resource-metrics"; -import { deploymentStore } from "./deployments.store"; -import { PodCharts, podMetricTabs } from "../+workloads-pods/pod-charts"; -import { makeObservable, observable, reaction } from "mobx"; -import { PodDetailsList } from "../+workloads-pods/pod-details-list"; -import { KubeObjectMeta } from "../kube-object-meta"; -import { replicaSetStore } from "../+workloads-replicasets/replicasets.store"; -import { DeploymentReplicaSets } from "./deployment-replicasets"; -import { getActiveClusterEntity } from "../../api/catalog-entity-registry"; -import { ClusterMetricsResourceType } from "../../../common/cluster-types"; -import { boundMethod, Disposer } from "../../utils"; -import logger from "../../../common/logger"; -import type { KubeObjectStore } from "../../../common/k8s-api/kube-object.store"; -import type { KubeObject } from "../../../common/k8s-api/kube-object"; -import { withInjectables } from "@ogre-tools/injectable-react"; -import kubeWatchApiInjectable - from "../../kube-watch-api/kube-watch-api.injectable"; - -interface Props extends KubeObjectDetailsProps { -} - -interface Dependencies { - subscribeStores: (stores: KubeObjectStore[]) => Disposer -} - -@observer -class NonInjectedDeploymentDetails extends React.Component { - @observable metrics: IPodMetrics = null; - - constructor(props: Props & Dependencies) { - super(props); - makeObservable(this); - } - - componentDidMount() { - disposeOnUnmount(this, [ - reaction(() => this.props.object, () => { - this.metrics = null; - }), - - this.props.subscribeStores([ - podsStore, - replicaSetStore, - ]), - ]); - } - - @boundMethod - async loadMetrics() { - const { object: deployment } = this.props; - - this.metrics = await getMetricsForDeployments([deployment], deployment.getNs(), ""); - } - - render() { - const { object: deployment } = this.props; - - if (!deployment) { - return null; - } - - if (!(deployment instanceof Deployment)) { - logger.error("[DeploymentDetails]: passed object that is not an instanceof Deployment", deployment); - - return null; - } - - const { status, spec } = deployment; - const nodeSelector = deployment.getNodeSelectors(); - const selectors = deployment.getSelectors(); - const childPods = deploymentStore.getChildPods(deployment); - const replicaSets = replicaSetStore.getReplicaSetsByOwner(deployment); - const isMetricHidden = getActiveClusterEntity()?.isMetricHidden(ClusterMetricsResourceType.Deployment); - - return ( -
    - {!isMetricHidden && podsStore.isLoaded && ( - - - - )} - - - {`${spec.replicas} desired, ${status.updatedReplicas || 0} updated`},{" "} - {`${status.replicas || 0} total, ${status.availableReplicas || 0} available`},{" "} - {`${status.unavailableReplicas || 0} unavailable`} - - {selectors.length > 0 && - - { - selectors.map(label => ) - } - - } - {nodeSelector.length > 0 && - - { - nodeSelector.map(label => ( - - )) - } - - } - - {spec.strategy.type} - - - { - deployment.getConditions().map(condition => { - const { type, message, lastTransitionTime, status } = condition; - - return ( - -

    {message}

    -

    Last transition time: {lastTransitionTime}

    - - )} - /> - ); - }) - } -
    - - - - - -
    - ); - } -} - -export const DeploymentDetails = withInjectables( - NonInjectedDeploymentDetails, - - { - getProps: (di, props) => ({ - subscribeStores: di.inject(kubeWatchApiInjectable).subscribeStores, - ...props, - }), - }, -); - diff --git a/src/renderer/components/+workloads-deployments/deployment-replicasets.tsx b/src/renderer/components/+workloads-deployments/deployment-replicasets.tsx deleted file mode 100644 index 2905c1accb..0000000000 --- a/src/renderer/components/+workloads-deployments/deployment-replicasets.tsx +++ /dev/null @@ -1,104 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import "./deployment-replicasets.scss"; - -import React from "react"; -import { observer } from "mobx-react"; -import type { ReplicaSet } from "../../../common/k8s-api/endpoints"; -import { KubeObjectMenu, KubeObjectMenuProps } from "../kube-object-menu"; -import { Spinner } from "../spinner"; -import { prevDefault, stopPropagation } from "../../utils"; -import { DrawerTitle } from "../drawer"; -import { Table, TableCell, TableHead, TableRow } from "../table"; -import { KubeObjectStatusIcon } from "../kube-object-status-icon"; -import { replicaSetStore } from "../+workloads-replicasets/replicasets.store"; -import { showDetails } from "../kube-detail-params"; - - -enum sortBy { - name = "name", - namespace = "namespace", - pods = "pods", - age = "age", -} - -interface Props { - replicaSets: ReplicaSet[]; -} - -@observer -export class DeploymentReplicaSets extends React.Component { - private sortingCallbacks = { - [sortBy.name]: (replicaSet: ReplicaSet) => replicaSet.getName(), - [sortBy.namespace]: (replicaSet: ReplicaSet) => replicaSet.getNs(), - [sortBy.age]: (replicaSet: ReplicaSet) => replicaSet.metadata.creationTimestamp, - [sortBy.pods]: (replicaSet: ReplicaSet) => this.getPodsLength(replicaSet), - }; - - getPodsLength(replicaSet: ReplicaSet) { - return replicaSetStore.getChildPods(replicaSet).length; - } - - render() { - const { replicaSets } = this.props; - - if (!replicaSets.length && !replicaSetStore.isLoaded) return ( -
    - ); - if (!replicaSets.length) return null; - - return ( -
    - - - - Name - - Namespace - Pods - Age - - - { - replicaSets.map(replica => { - return ( - showDetails(replica.selfLink, false))} - > - {replica.getName()} - - {replica.getNs()} - {this.getPodsLength(replica)} - {replica.getAge()} - - - - - ); - }) - } -
    -
    - ); - } -} - -export function ReplicaSetMenu(props: KubeObjectMenuProps) { - return ( - - ); -} diff --git a/src/renderer/components/+workloads-deployments/deployment-scale-dialog.tsx b/src/renderer/components/+workloads-deployments/deployment-scale-dialog.tsx deleted file mode 100644 index 93f8593534..0000000000 --- a/src/renderer/components/+workloads-deployments/deployment-scale-dialog.tsx +++ /dev/null @@ -1,185 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import "./deployment-scale-dialog.scss"; - -import React, { Component } from "react"; -import { computed, observable, makeObservable } from "mobx"; -import { observer } from "mobx-react"; -import { Dialog, DialogProps } from "../dialog"; -import { Wizard, WizardStep } from "../wizard"; -import { Deployment, DeploymentApi, deploymentApi } from "../../../common/k8s-api/endpoints"; -import { Icon } from "../icon"; -import { Slider } from "../slider"; -import { Notifications } from "../notifications"; -import { cssNames } from "../../utils"; - -interface Props extends Partial { - deploymentApi: DeploymentApi -} - -const dialogState = observable.object({ - isOpen: false, - data: null as Deployment, -}); - -@observer -export class DeploymentScaleDialog extends Component { - static defaultProps = { - deploymentApi, - }; - - @observable ready = false; - @observable currentReplicas = 0; - @observable desiredReplicas = 0; - - constructor(props: Props) { - super(props); - makeObservable(this); - } - - static open(deployment: Deployment) { - dialogState.isOpen = true; - dialogState.data = deployment; - } - - static close() { - dialogState.isOpen = false; - } - - get deployment() { - return dialogState.data; - } - - close = () => { - DeploymentScaleDialog.close(); - }; - - @computed get scaleMax() { - const { currentReplicas } = this; - const defaultMax = 50; - - return currentReplicas <= defaultMax - ? defaultMax * 2 - : currentReplicas * 2; - } - - onOpen = async () => { - const { deployment } = this; - - this.currentReplicas = await this.props.deploymentApi.getReplicas({ - namespace: deployment.getNs(), - name: deployment.getName(), - }); - this.desiredReplicas = this.currentReplicas; - this.ready = true; - }; - - onClose = () => { - this.ready = false; - }; - - onChange = (evt: React.ChangeEvent, value: number) => { - this.desiredReplicas = value; - }; - - scale = async () => { - const { deployment } = this; - const { currentReplicas, desiredReplicas, close } = this; - - try { - if (currentReplicas !== desiredReplicas) { - await this.props.deploymentApi.scale({ - name: deployment.getName(), - namespace: deployment.getNs(), - }, desiredReplicas); - } - close(); - } catch (err) { - Notifications.error(err); - } - }; - - private readonly scaleMin = 0; - - desiredReplicasUp = () => { - this.desiredReplicas = Math.min(this.scaleMax, this.desiredReplicas + 1); - }; - - desiredReplicasDown = () => { - this.desiredReplicas = Math.max(this.scaleMin, this.desiredReplicas - 1); - }; - - renderContents() { - const { currentReplicas, desiredReplicas, onChange, scaleMax } = this; - const warning = currentReplicas < 10 && desiredReplicas > 90; - - return ( - <> -
    - Current replica scale: {currentReplicas} -
    -
    -
    - Desired number of replicas: {desiredReplicas} -
    -
    - -
    -
    - - -
    -
    - {warning && -
    - - High number of replicas may cause cluster performance issues -
    - } - - ); - } - - render() { - const { className, ...dialogProps } = this.props; - const deploymentName = this.deployment ? this.deployment.getName() : ""; - const header = ( -
    - Scale Deployment {deploymentName} -
    - ); - - return ( - - - - {this.renderContents()} - - - - ); - } -} diff --git a/src/renderer/components/+workloads-deployments/deployments.tsx b/src/renderer/components/+workloads-deployments/deployments.tsx deleted file mode 100644 index 09c7c676f4..0000000000 --- a/src/renderer/components/+workloads-deployments/deployments.tsx +++ /dev/null @@ -1,133 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import "./deployments.scss"; - -import React from "react"; -import { observer } from "mobx-react"; -import type { RouteComponentProps } from "react-router"; -import { Deployment, deploymentApi } from "../../../common/k8s-api/endpoints"; -import type { KubeObjectMenuProps } from "../kube-object-menu"; -import { MenuItem } from "../menu"; -import { Icon } from "../icon"; -import { DeploymentScaleDialog } from "./deployment-scale-dialog"; -import { ConfirmDialog } from "../confirm-dialog"; -import { deploymentStore } from "./deployments.store"; -import { eventStore } from "../+events/event.store"; -import { KubeObjectListLayout } from "../kube-object-list-layout"; -import { cssNames } from "../../utils"; -import kebabCase from "lodash/kebabCase"; -import orderBy from "lodash/orderBy"; -import { KubeObjectStatusIcon } from "../kube-object-status-icon"; -import { Notifications } from "../notifications"; -import type { DeploymentsRouteParams } from "../../../common/routes"; - -enum columnId { - name = "name", - namespace = "namespace", - pods = "pods", - replicas = "replicas", - age = "age", - condition = "condition", -} - -interface Props extends RouteComponentProps { -} - -@observer -export class Deployments extends React.Component { - renderPods(deployment: Deployment) { - const { replicas, availableReplicas } = deployment.status; - - return `${availableReplicas || 0}/${replicas || 0}`; - } - - renderConditions(deployment: Deployment) { - const conditions = orderBy(deployment.getConditions(true), "type", "asc"); - - return conditions.map(({ type, message }) => ( - - {type} - - )); - } - - render() { - return ( - deployment.getName(), - [columnId.namespace]: deployment => deployment.getNs(), - [columnId.replicas]: deployment => deployment.getReplicas(), - [columnId.age]: deployment => deployment.getTimeDiffFromNow(), - [columnId.condition]: deployment => deployment.getConditionsText(), - }} - searchFilters={[ - deployment => deployment.getSearchFields(), - deployment => deployment.getConditionsText(), - ]} - renderHeaderTitle="Deployments" - renderTableHeader={[ - { title: "Name", className: "name", sortBy: columnId.name, id: columnId.name }, - { className: "warning", showWithColumn: columnId.name }, - { title: "Namespace", className: "namespace", sortBy: columnId.namespace, id: columnId.namespace }, - { title: "Pods", className: "pods", id: columnId.pods }, - { title: "Replicas", className: "replicas", sortBy: columnId.replicas, id: columnId.replicas }, - { title: "Age", className: "age", sortBy: columnId.age, id: columnId.age }, - { title: "Conditions", className: "conditions", sortBy: columnId.condition, id: columnId.condition }, - ]} - renderTableContents={deployment => [ - deployment.getName(), - , - deployment.getNs(), - this.renderPods(deployment), - deployment.getReplicas(), - deployment.getAge(), - this.renderConditions(deployment), - ]} - renderItemMenu={item => } - /> - ); - } -} - -export function DeploymentMenu(props: KubeObjectMenuProps) { - const { object, toolbar } = props; - - return ( - <> - DeploymentScaleDialog.open(object)}> - - Scale - - ConfirmDialog.open({ - ok: async () => - { - try { - await deploymentApi.restart({ - namespace: object.getNs(), - name: object.getName(), - }); - } catch (err) { - Notifications.error(err); - } - }, - labelOk: `Restart`, - message: ( -

    - Are you sure you want to restart deployment {object.getName()}? -

    - ), - })}> - - Restart -
    - - ); -} diff --git a/src/renderer/components/+workloads-jobs/job-details.tsx b/src/renderer/components/+workloads-jobs/job-details.tsx deleted file mode 100644 index ba468a14c1..0000000000 --- a/src/renderer/components/+workloads-jobs/job-details.tsx +++ /dev/null @@ -1,178 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import "./job-details.scss"; - -import React from "react"; -import kebabCase from "lodash/kebabCase"; -import { disposeOnUnmount, observer } from "mobx-react"; -import { DrawerItem } from "../drawer"; -import { Badge } from "../badge"; -import { PodDetailsStatuses } from "../+workloads-pods/pod-details-statuses"; -import { Link } from "react-router-dom"; -import { PodDetailsTolerations } from "../+workloads-pods/pod-details-tolerations"; -import { PodDetailsAffinities } from "../+workloads-pods/pod-details-affinities"; -import { podsStore } from "../+workloads-pods/pods.store"; -import { jobStore } from "./job.store"; -import type { KubeObjectDetailsProps } from "../kube-object-details"; -import { getMetricsForJobs, IPodMetrics, Job } from "../../../common/k8s-api/endpoints"; -import { PodDetailsList } from "../+workloads-pods/pod-details-list"; -import { KubeObjectMeta } from "../kube-object-meta"; -import { makeObservable, observable, reaction } from "mobx"; -import { podMetricTabs, PodCharts } from "../+workloads-pods/pod-charts"; -import { ClusterMetricsResourceType } from "../../../common/cluster-types"; -import { getActiveClusterEntity } from "../../api/catalog-entity-registry"; -import { ResourceMetrics } from "../resource-metrics"; -import { boundMethod } from "autobind-decorator"; -import { getDetailsUrl } from "../kube-detail-params"; -import { apiManager } from "../../../common/k8s-api/api-manager"; -import logger from "../../../common/logger"; -import type { KubeObjectStore } from "../../../common/k8s-api/kube-object.store"; -import type { KubeObject } from "../../../common/k8s-api/kube-object"; -import type { Disposer } from "../../../common/utils"; -import { withInjectables } from "@ogre-tools/injectable-react"; -import kubeWatchApiInjectable - from "../../kube-watch-api/kube-watch-api.injectable"; - -interface Props extends KubeObjectDetailsProps { -} - -interface Dependencies { - subscribeStores: (stores: KubeObjectStore[]) => Disposer -} - -@observer -class NonInjectedJobDetails extends React.Component { - @observable metrics: IPodMetrics = null; - - constructor(props: Props & Dependencies) { - super(props); - makeObservable(this); - } - - componentDidMount() { - disposeOnUnmount(this, [ - reaction(() => this.props.object, () => { - this.metrics = null; - }), - this.props.subscribeStores([ - podsStore, - ]), - ]); - } - - @boundMethod - async loadMetrics() { - const { object: job } = this.props; - - this.metrics = await getMetricsForJobs([job], job.getNs(), ""); - } - - render() { - const { object: job } = this.props; - - if (!job) { - return null; - } - - if (!(job instanceof Job)) { - logger.error("[JobDetails]: passed object that is not an instanceof Job", job); - - return null; - } - - const selectors = job.getSelectors(); - const nodeSelector = job.getNodeSelectors(); - const images = job.getImages(); - const childPods = jobStore.getChildPods(job); - const ownerRefs = job.getOwnerRefs(); - const condition = job.getCondition(); - const isMetricHidden = getActiveClusterEntity()?.isMetricHidden(ClusterMetricsResourceType.Job); - - return ( -
    - {!isMetricHidden && ( - - - - )} - - - { - Object.keys(selectors).map(label => ) - } - - {nodeSelector.length > 0 && - - { - nodeSelector.map(label => ( - - )) - } - - } - {images.length > 0 && - - { - images.map(image =>

    {image}

    ) - } -
    - } - {ownerRefs.length > 0 && - - { - ownerRefs.map(ref => { - const { name, kind } = ref; - const detailsUrl = getDetailsUrl(apiManager.lookupApiLink(ref, job)); - - return ( -

    - {kind} {name} -

    - ); - }) - } -
    - } - - {condition && ( - - )} - - - {job.getDesiredCompletions()} - - - {job.getParallelism()} - - - - - - - -
    - ); - } -} - -export const JobDetails = withInjectables( - NonInjectedJobDetails, - - { - getProps: (di, props) => ({ - subscribeStores: di.inject(kubeWatchApiInjectable).subscribeStores, - ...props, - }), - }, -); - diff --git a/src/renderer/components/+workloads-jobs/jobs.tsx b/src/renderer/components/+workloads-jobs/jobs.tsx deleted file mode 100644 index ae3c8c931d..0000000000 --- a/src/renderer/components/+workloads-jobs/jobs.tsx +++ /dev/null @@ -1,74 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import "./jobs.scss"; - -import React from "react"; -import { observer } from "mobx-react"; -import type { RouteComponentProps } from "react-router"; -import { jobStore } from "./job.store"; -import { eventStore } from "../+events/event.store"; -import { KubeObjectListLayout } from "../kube-object-list-layout"; -import kebabCase from "lodash/kebabCase"; -import { KubeObjectStatusIcon } from "../kube-object-status-icon"; -import type { JobsRouteParams } from "../../../common/routes"; - -enum columnId { - name = "name", - namespace = "namespace", - completions = "completions", - conditions = "conditions", - age = "age", -} - -interface Props extends RouteComponentProps { -} - -@observer -export class Jobs extends React.Component { - render() { - return ( - job.getName(), - [columnId.namespace]: job => job.getNs(), - [columnId.conditions]: job => job.getCondition() != null ? job.getCondition().type : "", - [columnId.age]: job => job.getTimeDiffFromNow(), - }} - searchFilters={[ - job => job.getSearchFields(), - ]} - renderHeaderTitle="Jobs" - renderTableHeader={[ - { title: "Name", className: "name", sortBy: columnId.name, id: columnId.name }, - { title: "Namespace", className: "namespace", sortBy: columnId.namespace, id: columnId.namespace }, - { title: "Completions", className: "completions", id: columnId.completions }, - { className: "warning", showWithColumn: columnId.completions }, - { title: "Age", className: "age", sortBy: columnId.age, id: columnId.age }, - { title: "Conditions", className: "conditions", sortBy: columnId.conditions, id: columnId.conditions }, - ]} - renderTableContents={job => { - const condition = job.getCondition(); - - return [ - job.getName(), - job.getNs(), - `${job.getCompletions()} / ${job.getDesiredCompletions()}`, - , - job.getAge(), - condition && { - title: condition.type, - className: kebabCase(condition.type), - }, - ]; - }} - /> - ); - } -} diff --git a/src/renderer/components/+workloads-overview/overview-statuses.tsx b/src/renderer/components/+workloads-overview/overview-statuses.tsx deleted file mode 100644 index ad2f02c5ca..0000000000 --- a/src/renderer/components/+workloads-overview/overview-statuses.tsx +++ /dev/null @@ -1,81 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import "./overview-statuses.scss"; - -import React from "react"; -import { observer } from "mobx-react"; -import { OverviewWorkloadStatus } from "./overview-workload-status"; -import { Link } from "react-router-dom"; -import { workloadStores } from "../+workloads"; -import type { NamespaceStore } from "../+namespaces/namespace-store/namespace.store"; -import type { KubeResource } from "../../../common/rbac"; -import { ResourceNames } from "../../utils/rbac"; -import { boundMethod } from "../../utils"; -import { workloadURL } from "../../../common/routes"; -import { isAllowedResource } from "../../../common/utils/allowed-resource"; -import { withInjectables } from "@ogre-tools/injectable-react"; -import namespaceStoreInjectable from "../+namespaces/namespace-store/namespace-store.injectable"; - -const resources: KubeResource[] = [ - "pods", - "deployments", - "statefulsets", - "daemonsets", - "replicasets", - "jobs", - "cronjobs", -]; - -interface Dependencies { - namespaceStore: NamespaceStore -} - -@observer -class NonInjectedOverviewStatuses extends React.Component { - @boundMethod - renderWorkload(resource: KubeResource): React.ReactElement { - const store = workloadStores.get(resource); - - if (!store) { - return null; - } - - const items = store.getAllByNs(this.props.namespaceStore.contextNamespaces); - - return ( -
    -
    - {ResourceNames[resource]} ({items.length}) -
    - -
    - ); - } - - render() { - const workloads = resources - .filter(isAllowedResource) - .map(this.renderWorkload); - - return ( -
    -
    - {workloads} -
    -
    - ); - } -} - -export const OverviewStatuses = withInjectables( - NonInjectedOverviewStatuses, - - { - getProps: (di) => ({ - namespaceStore: di.inject(namespaceStoreInjectable), - }), - }, -); diff --git a/src/renderer/components/+workloads-overview/overview.tsx b/src/renderer/components/+workloads-overview/overview.tsx index d0fdbd2588..a39eddf02d 100644 --- a/src/renderer/components/+workloads-overview/overview.tsx +++ b/src/renderer/components/+workloads-overview/overview.tsx @@ -8,14 +8,6 @@ import "./overview.scss"; import React from "react"; import { disposeOnUnmount, observer } from "mobx-react"; import type { RouteComponentProps } from "react-router"; -import { eventStore } from "../+events/event.store"; -import { podsStore } from "../+workloads-pods/pods.store"; -import { deploymentStore } from "../+workloads-deployments/deployments.store"; -import { daemonSetStore } from "../+workloads-daemonsets/daemonsets.store"; -import { statefulSetStore } from "../+workloads-statefulsets/statefulset.store"; -import { replicaSetStore } from "../+workloads-replicasets/replicasets.store"; -import { jobStore } from "../+workloads-jobs/job.store"; -import { cronJobStore } from "../+workloads-cronjobs/cronjob.store"; import { WorkloadsOverviewDetailRegistry } from "../../../extensions/registries"; import type { WorkloadsOverviewRouteParams } from "../../../common/routes"; import { makeObservable, observable, reaction } from "mobx"; @@ -24,19 +16,43 @@ import { Icon } from "../icon"; import { TooltipPosition } from "../tooltip"; import { withInjectables } from "@ogre-tools/injectable-react"; import clusterFrameContextInjectable from "../../cluster-frame-context/cluster-frame-context.injectable"; -import type { ClusterFrameContext } from "../../cluster-frame-context/cluster-frame-context"; +import type { FrameContext } from "../../cluster-frame-context/cluster-frame-context"; import type { KubeObjectStore } from "../../../common/k8s-api/kube-object.store"; import type { KubeObject } from "../../../common/k8s-api/kube-object"; import type { Disposer } from "../../../common/utils"; import kubeWatchApiInjectable from "../../kube-watch-api/kube-watch-api.injectable"; import type { KubeWatchSubscribeStoreOptions } from "../../kube-watch-api/kube-watch-api"; +import type { CronJobStore } from "../+cronjobs/store"; +import type { DaemonSetStore } from "../+daemonsets/store"; +import type { DeploymentStore } from "../+deployments/store"; +import type { EventStore } from "../+events/store"; +import type { JobStore } from "../+jobs/store"; +import type { PodStore } from "../+pods/store"; +import type { ReplicaSetStore } from "../+replica-sets/store"; +import type { StatefulSetStore } from "../+stateful-sets/store"; +import cronJobStoreInjectable from "../+cronjobs/store.injectable"; +import daemonSetStoreInjectable from "../+daemonsets/store.injectable"; +import deploymentStoreInjectable from "../+deployments/store.injectable"; +import eventStoreInjectable from "../+events/store.injectable"; +import jobStoreInjectable from "../+jobs/store.injectable"; +import podStoreInjectable from "../+pods/store.injectable"; +import replicaSetStoreInjectable from "../+replica-sets/store.injectable"; +import statefulSetStoreInjectable from "../+stateful-sets/store.injectable"; interface Props extends RouteComponentProps { } interface Dependencies { - clusterFrameContext: ClusterFrameContext + clusterFrameContext: FrameContext; subscribeStores: (stores: KubeObjectStore[], options: KubeWatchSubscribeStoreOptions) => Disposer + cronJobStore: CronJobStore; + daemonSetStore: DaemonSetStore; + deploymentStore: DeploymentStore; + eventStore: EventStore; + jobStore: JobStore; + podStore: PodStore; + replicaSetStore: ReplicaSetStore; + statefulSetStore: StatefulSetStore; } @observer @@ -51,14 +67,14 @@ class NonInjectedWorkloadsOverview extends React.Component componentDidMount() { disposeOnUnmount(this, [ this.props.subscribeStores([ - cronJobStore, - daemonSetStore, - deploymentStore, - eventStore, - jobStore, - podsStore, - replicaSetStore, - statefulSetStore, + this.props.cronJobStore, + this.props.daemonSetStore, + this.props.deploymentStore, + this.props.eventStore, + this.props.jobStore, + this.props.podStore, + this.props.replicaSetStore, + this.props.statefulSetStore, ], { onLoadFailure: error => this.loadErrors.push(String(error)), }), @@ -118,6 +134,14 @@ export const WorkloadsOverview = withInjectables( getProps: (di, props) => ({ clusterFrameContext: di.inject(clusterFrameContextInjectable), subscribeStores: di.inject(kubeWatchApiInjectable).subscribeStores, + cronJobStore: di.inject(cronJobStoreInjectable), + daemonSetStore: di.inject(daemonSetStoreInjectable), + deploymentStore: di.inject(deploymentStoreInjectable), + eventStore: di.inject(eventStoreInjectable), + jobStore: di.inject(jobStoreInjectable), + podStore: di.inject(podStoreInjectable), + replicaSetStore: di.inject(replicaSetStoreInjectable), + statefulSetStore: di.inject(statefulSetStoreInjectable), ...props, }), }, diff --git a/src/renderer/components/+workloads-overview/overview-statuses.scss b/src/renderer/components/+workloads-overview/statuses.scss similarity index 100% rename from src/renderer/components/+workloads-overview/overview-statuses.scss rename to src/renderer/components/+workloads-overview/statuses.scss diff --git a/src/renderer/components/+workloads-overview/statuses.tsx b/src/renderer/components/+workloads-overview/statuses.tsx new file mode 100644 index 0000000000..2cfef2f4c3 --- /dev/null +++ b/src/renderer/components/+workloads-overview/statuses.tsx @@ -0,0 +1,101 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import "./statuses.scss"; + +import React from "react"; +import { observer } from "mobx-react"; +import { OverviewWorkloadStatus } from "./workload-status"; +import { Link } from "react-router-dom"; +import type { KubeResource } from "../../../common/rbac"; +import { ResourceNames } from "../../utils/rbac"; +import { workloadURL } from "../../../common/routes"; +import isAllowedResourceInjectable from "../../utils/allowed-resource.injectable"; +import { withInjectables } from "@ogre-tools/injectable-react"; +import type { CronJobStore } from "../+cronjobs/store"; +import type { DaemonSetStore } from "../+daemonsets/store"; +import type { DeploymentStore } from "../+deployments/store"; +import type { JobStore } from "../+jobs/store"; +import type { PodStore } from "../+pods/store"; +import type { ReplicaSetStore } from "../+replica-sets/store"; +import type { StatefulSetStore } from "../+stateful-sets/store"; +import cronJobStoreInjectable from "../+cronjobs/store.injectable"; +import daemonSetStoreInjectable from "../+daemonsets/store.injectable"; +import deploymentStoreInjectable from "../+deployments/store.injectable"; +import jobStoreInjectable from "../+jobs/store.injectable"; +import podStoreInjectable from "../+pods/store.injectable"; +import replicaSetStoreInjectable from "../+replica-sets/store.injectable"; +import statefulSetStoreInjectable from "../+stateful-sets/store.injectable"; +import type { IComputedValue } from "mobx"; +import selectedNamespacesInjectable from "../+namespaces/selected-namespaces.injectable"; +import type { KubeObjectStore } from "../../../common/k8s-api/kube-object.store"; +import type { KubeObject } from "../../../common/k8s-api/kube-object"; + +export interface OverviewStatusesProps {} + +interface Dependencies { + selectedNamespaces: IComputedValue; + cronJobStore: CronJobStore; + daemonSetStore: DaemonSetStore; + deploymentStore: DeploymentStore; + jobStore: JobStore; + podStore: PodStore; + replicaSetStore: ReplicaSetStore; + statefulSetStore: StatefulSetStore; + isAllowedResource: (resource: KubeResource) => boolean; +} + +const NonInjectedOverviewStatuses = observer(({ + selectedNamespaces, + cronJobStore, + daemonSetStore, + deploymentStore, + jobStore, + podStore, + replicaSetStore, + statefulSetStore, + isAllowedResource, +}: Dependencies & OverviewStatusesProps) => { + const renderWorkload = (resource: KubeResource, store: KubeObjectStore) => { + const items = store.getAllByNs(selectedNamespaces.get()); + + return ( +
    +
    + {ResourceNames[resource]} ({items.length}) +
    + +
    + ); + }; + + return ( +
    +
    + {isAllowedResource("pods") && renderWorkload("pods", podStore)} + {isAllowedResource("deployments") && renderWorkload("deployments", deploymentStore)} + {isAllowedResource("statefulsets") && renderWorkload("statefulsets", statefulSetStore)} + {isAllowedResource("daemonsets") && renderWorkload("daemonsets", daemonSetStore)} + {isAllowedResource("replicasets") && renderWorkload("replicasets", replicaSetStore)} + {isAllowedResource("jobs") && renderWorkload("jobs", jobStore)} + {isAllowedResource("cronjobs") && renderWorkload("cronjobs", cronJobStore)} +
    +
    + ); +}); + +export const OverviewStatuses = withInjectables( NonInjectedOverviewStatuses, { + getProps: (di) => ({ + selectedNamespaces: di.inject(selectedNamespacesInjectable), + cronJobStore: di.inject(cronJobStoreInjectable), + daemonSetStore: di.inject(daemonSetStoreInjectable), + deploymentStore: di.inject(deploymentStoreInjectable), + jobStore: di.inject(jobStoreInjectable), + podStore: di.inject(podStoreInjectable), + replicaSetStore: di.inject(replicaSetStoreInjectable), + statefulSetStore: di.inject(statefulSetStoreInjectable), + isAllowedResource: di.inject(isAllowedResourceInjectable), + }), +}); diff --git a/src/renderer/components/+workloads-overview/overview-workload-status.scss b/src/renderer/components/+workloads-overview/workload-status.scss similarity index 100% rename from src/renderer/components/+workloads-overview/overview-workload-status.scss rename to src/renderer/components/+workloads-overview/workload-status.scss diff --git a/src/renderer/components/+workloads-overview/overview-workload-status.tsx b/src/renderer/components/+workloads-overview/workload-status.tsx similarity index 51% rename from src/renderer/components/+workloads-overview/overview-workload-status.tsx rename to src/renderer/components/+workloads-overview/workload-status.tsx index d042ba1597..527e2bb2ca 100644 --- a/src/renderer/components/+workloads-overview/overview-workload-status.tsx +++ b/src/renderer/components/+workloads-overview/workload-status.tsx @@ -3,41 +3,47 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import "./overview-workload-status.scss"; +import "./workload-status.scss"; -import React from "react"; +import React, { useRef } from "react"; import capitalize from "lodash/capitalize"; import { observer } from "mobx-react"; import { PieChart } from "../chart"; import { cssVar } from "../../utils"; import type { ChartData } from "chart.js"; -import { ThemeStore } from "../../theme.store"; +import { withInjectables } from "@ogre-tools/injectable-react"; +import type { IComputedValue } from "mobx"; +import type { Theme } from "../../themes/store"; +import activeThemeInjectable from "../../themes/active-theme.injectable"; -interface Props { +export interface OverviewWorkloadStatusProps { status: Record; } -@observer -export class OverviewWorkloadStatus extends React.Component { - elem?: HTMLElement; +interface Dependencies { + activeTheme: IComputedValue; +} - renderChart() { - if (!this.elem) { +const NonInjectedOverviewWorkloadStatus = observer(({ activeTheme, status }: Dependencies & OverviewWorkloadStatusProps) => { + const elem = useRef(); + + const renderChart = () => { + if (!elem.current) { return null; } - const cssVars = cssVar(this.elem); + const cssVars = cssVar(elem.current); const chartData: Required = { labels: [], datasets: [], }; - const statuses = Object.entries(this.props.status).filter(([, val]) => val > 0); + const statuses = Object.entries(status).filter(([, val]) => val > 0); if (statuses.length === 0) { chartData.datasets.push({ data: [1], - backgroundColor: [ThemeStore.getInstance().activeTheme.colors.pieChartDefaultColor], + backgroundColor: [activeTheme.get().colors.pieChartDefaultColor], label: "Empty", }); } else { @@ -69,15 +75,20 @@ export class OverviewWorkloadStatus extends React.Component { }} /> ); - } + }; - render() { - return ( -
    this.elem = e}> -
    - {this.renderChart()} -
    + return ( +
    +
    + {renderChart()}
    - ); - } -} +
    + ); +}); + +export const OverviewWorkloadStatus = withInjectables(NonInjectedOverviewWorkloadStatus, { + getProps: (di, props) => ({ + activeTheme: di.inject(activeThemeInjectable), + ...props, + }), +}); diff --git a/src/renderer/components/+workloads-pods/pod-container-port.tsx b/src/renderer/components/+workloads-pods/pod-container-port.tsx deleted file mode 100644 index 2dc5c5dd82..0000000000 --- a/src/renderer/components/+workloads-pods/pod-container-port.tsx +++ /dev/null @@ -1,205 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import "./pod-container-port.scss"; - -import React from "react"; -import { disposeOnUnmount, observer } from "mobx-react"; -import type { Pod } from "../../../common/k8s-api/endpoints"; -import { action, makeObservable, observable, reaction } from "mobx"; -import { cssNames } from "../../utils"; -import { Notifications } from "../notifications"; -import { Button } from "../button"; -import type { ForwardedPort } from "../../port-forward"; -import { - aboutPortForwarding, - notifyErrorPortForwarding, - openPortForward, - PortForwardStore, - predictProtocol, -} from "../../port-forward"; - -import { Spinner } from "../spinner"; -import { withInjectables } from "@ogre-tools/injectable-react"; -import portForwardStoreInjectable from "../../port-forward/port-forward-store/port-forward-store.injectable"; -import portForwardDialogModelInjectable from "../../port-forward/port-forward-dialog-model/port-forward-dialog-model.injectable"; -import logger from "../../../common/logger"; - -interface Props { - pod: Pod; - port: { - name?: string; - containerPort: number; - protocol: string; - }; -} - -interface Dependencies { - portForwardStore: PortForwardStore; - openPortForwardDialog: (item: ForwardedPort, options: { openInBrowser: boolean, onClose: () => void }) => void; -} - -@observer -class NonInjectedPodContainerPort extends React.Component { - @observable waiting = false; - @observable forwardPort = 0; - @observable isPortForwarded = false; - @observable isActive = false; - - constructor(props: Props & Dependencies) { - super(props); - makeObservable(this); - this.checkExistingPortForwarding(); - } - - componentDidMount() { - disposeOnUnmount(this, [ - reaction(() => this.props.pod, () => this.checkExistingPortForwarding()), - ]); - } - - get portForwardStore() { - return this.props.portForwardStore; - } - - @action - async checkExistingPortForwarding() { - const { pod, port } = this.props; - let portForward: ForwardedPort = { - kind: "pod", - name: pod.getName(), - namespace: pod.getNs(), - port: port.containerPort, - forwardPort: this.forwardPort, - }; - - try { - portForward = await this.portForwardStore.getPortForward(portForward); - } catch (error) { - this.isPortForwarded = false; - this.isActive = false; - - return; - } - - this.forwardPort = portForward.forwardPort; - this.isPortForwarded = true; - this.isActive = portForward.status === "Active"; - } - - @action - async portForward() { - const { pod, port } = this.props; - let portForward: ForwardedPort = { - kind: "pod", - name: pod.getName(), - namespace: pod.getNs(), - port: port.containerPort, - forwardPort: this.forwardPort, - protocol: predictProtocol(port.name), - status: "Active", - }; - - this.waiting = true; - - try { - // determine how many port-forwards already exist - const { length } = this.portForwardStore.getPortForwards(); - - if (!this.isPortForwarded) { - portForward = await this.portForwardStore.add(portForward); - } else if (!this.isActive) { - portForward = await this.portForwardStore.start(portForward); - } - - if (portForward.status === "Active") { - openPortForward(portForward); - - // if this is the first port-forward show the about notification - if (!length) { - aboutPortForwarding(); - } - } else { - notifyErrorPortForwarding(`Error occurred starting port-forward, the local port may not be available or the ${portForward.kind} ${portForward.name} may not be reachable`); - } - } catch (error) { - logger.error("[POD-CONTAINER-PORT]:", error, portForward); - } finally { - this.checkExistingPortForwarding(); - this.waiting = false; - } - } - - @action - async stopPortForward() { - const { pod, port } = this.props; - const portForward: ForwardedPort = { - kind: "pod", - name: pod.getName(), - namespace: pod.getNs(), - port: port.containerPort, - forwardPort: this.forwardPort, - }; - - this.waiting = true; - - try { - await this.portForwardStore.remove(portForward); - } catch (error) { - Notifications.error(`Error occurred stopping the port-forward from port ${portForward.forwardPort}.`); - } finally { - this.checkExistingPortForwarding(); - this.forwardPort = 0; - this.waiting = false; - } - } - - render() { - const { pod, port } = this.props; - const { name, containerPort, protocol } = port; - const text = `${name ? `${name}: ` : ""}${containerPort}/${protocol}`; - - const portForwardAction = action(async () => { - if (this.isPortForwarded) { - await this.stopPortForward(); - } else { - const portForward: ForwardedPort = { - kind: "pod", - name: pod.getName(), - namespace: pod.getNs(), - port: port.containerPort, - forwardPort: this.forwardPort, - protocol: predictProtocol(port.name), - }; - - this.props.openPortForwardDialog(portForward, { openInBrowser: true, onClose: () => this.checkExistingPortForwarding() }); - } - }); - - return ( -
    - this.portForward()}> - {text} - - - {this.waiting && ( - - )} -
    - ); - } -} - -export const PodContainerPort = withInjectables( - NonInjectedPodContainerPort, - - { - getProps: (di, props) => ({ - portForwardStore: di.inject(portForwardStoreInjectable), - openPortForwardDialog: di.inject(portForwardDialogModelInjectable).open, - ...props, - }), - }, -); diff --git a/src/renderer/components/+workloads-pods/pod-details-container.tsx b/src/renderer/components/+workloads-pods/pod-details-container.tsx deleted file mode 100644 index 067c1b085e..0000000000 --- a/src/renderer/components/+workloads-pods/pod-details-container.tsx +++ /dev/null @@ -1,203 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import "./pod-details-container.scss"; - -import React from "react"; -import type { IPodContainer, IPodContainerStatus, Pod } from "../../../common/k8s-api/endpoints"; -import { DrawerItem } from "../drawer"; -import { cssNames } from "../../utils"; -import { StatusBrick } from "../status-brick"; -import { Badge } from "../badge"; -import { ContainerEnvironment } from "./pod-container-env"; -import { PodContainerPort } from "./pod-container-port"; -import { ResourceMetrics } from "../resource-metrics"; -import type { IMetrics } from "../../../common/k8s-api/endpoints/metrics.api"; -import { ContainerCharts } from "./container-charts"; -import { LocaleDate } from "../locale-date"; -import { getActiveClusterEntity } from "../../api/catalog-entity-registry"; -import { ClusterMetricsResourceType } from "../../../common/cluster-types"; -import type { PortForwardStore } from "../../port-forward"; -import { disposeOnUnmount, observer } from "mobx-react"; -import { withInjectables } from "@ogre-tools/injectable-react"; -import portForwardStoreInjectable from "../../port-forward/port-forward-store/port-forward-store.injectable"; - -interface Props { - pod: Pod; - container: IPodContainer; - metrics?: { [key: string]: IMetrics }; -} - -interface Dependencies { - portForwardStore: PortForwardStore -} - -@observer -class NonInjectedPodDetailsContainer extends React.Component { - - componentDidMount() { - disposeOnUnmount(this, [ - this.props.portForwardStore.watch(), - ]); - } - - renderStatus(state: string, status: IPodContainerStatus) { - const ready = status ? status.ready : ""; - - return ( - - {state}{ready ? `, ready` : ""} - {state === "terminated" ? ` - ${status.state.terminated.reason} (exit code: ${status.state.terminated.exitCode})` : ""} - - ); - } - - renderLastState(lastState: string, status: IPodContainerStatus) { - if (lastState === "terminated") { - return ( - - {lastState}
    - Reason: {status.lastState.terminated.reason} - exit code: {status.lastState.terminated.exitCode}
    - Started at: {}
    - Finished at: {}
    -
    - ); - } - - return null; - } - - render() { - const { pod, container, metrics } = this.props; - - if (!pod || !container) return null; - const { name, image, imagePullPolicy, ports, volumeMounts, command, args } = container; - const status = pod.getContainerStatuses().find(status => status.name === container.name); - const state = status ? Object.keys(status.state)[0] : ""; - const lastState = status ? Object.keys(status.lastState)[0] : ""; - const ready = status ? status.ready : ""; - const imageId = status? status.imageID : ""; - const liveness = pod.getLivenessProbe(container); - const readiness = pod.getReadinessProbe(container); - const startup = pod.getStartupProbe(container); - const isInitContainer = !!pod.getInitContainers().find(c => c.name == name); - const metricTabs = [ - "CPU", - "Memory", - "Filesystem", - ]; - const isMetricHidden = getActiveClusterEntity()?.isMetricHidden(ClusterMetricsResourceType.Container); - - return ( -
    -
    - {name} -
    - {!isMetricHidden && !isInitContainer && - - - - } - {status && - - {this.renderStatus(state, status)} - - } - {lastState && - - {this.renderLastState(lastState, status)} - - } - - - - {imagePullPolicy && imagePullPolicy !== "IfNotPresent" && - - {imagePullPolicy} - - } - {ports && ports.length > 0 && - - { - ports.map((port) => { - const key = `${container.name}-port-${port.containerPort}-${port.protocol}`; - - return ( - - ); - }) - } - - } - {} - {volumeMounts && volumeMounts.length > 0 && - - { - volumeMounts.map(mount => { - const { name, mountPath, readOnly } = mount; - - return ( - - {mountPath} - from {name} ({readOnly ? "ro" : "rw"}) - - ); - }) - } - - } - {liveness.length > 0 && - - { - liveness.map((value, index) => ( - - )) - } - - } - {readiness.length > 0 && - - { - readiness.map((value, index) => ( - - )) - } - - } - {startup.length > 0 && - - { - startup.map((value, index) => ( - - )) - } - - } - {command && - - {command.join(" ")} - - } - - {args && - - {args.join(" ")} - - } -
    - ); - } -} - -export const PodDetailsContainer = withInjectables( - NonInjectedPodDetailsContainer, - - { - getProps: (di, props) => ({ - portForwardStore: di.inject(portForwardStoreInjectable), - ...props, - }), - }, -); diff --git a/src/renderer/components/+workloads-pods/pod-details-list.tsx b/src/renderer/components/+workloads-pods/pod-details-list.tsx deleted file mode 100644 index 52a2ede8f7..0000000000 --- a/src/renderer/components/+workloads-pods/pod-details-list.tsx +++ /dev/null @@ -1,171 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import "./pod-details-list.scss"; - -import React from "react"; -import kebabCase from "lodash/kebabCase"; -import { reaction } from "mobx"; -import { disposeOnUnmount, observer } from "mobx-react"; -import { podsStore } from "./pods.store"; -import type { Pod } from "../../../common/k8s-api/endpoints"; -import { boundMethod, bytesToUnits, cssNames, interval, prevDefault } from "../../utils"; -import { LineProgress } from "../line-progress"; -import type { KubeObject } from "../../../common/k8s-api/kube-object"; -import { Table, TableCell, TableHead, TableRow } from "../table"; -import { Spinner } from "../spinner"; -import { DrawerTitle } from "../drawer"; -import { KubeObjectStatusIcon } from "../kube-object-status-icon"; -import { showDetails } from "../kube-detail-params"; - -enum sortBy { - name = "name", - namespace = "namespace", - cpu = "cpu", - memory = "memory", -} - -interface Props extends OptionalProps { - pods: Pod[]; - owner: KubeObject; -} - -interface OptionalProps { - maxCpu?: number; - maxMemory?: number; -} - -@observer -export class PodDetailsList extends React.Component { - private metricsWatcher = interval(120, () => { - podsStore.loadKubeMetrics(this.props.owner.getNs()); - }); - - componentDidMount() { - this.metricsWatcher.start(true); - disposeOnUnmount(this, [ - reaction(() => this.props.owner, () => this.metricsWatcher.restart(true)), - ]); - } - - componentWillUnmount() { - this.metricsWatcher.stop(); - } - - renderCpuUsage(id: string, usage: number) { - const { maxCpu } = this.props; - const value = usage.toFixed(3); - const tooltip = ( -

    CPU: {Math.ceil(usage * 100) / maxCpu}%
    {usage.toFixed(3)}

    - ); - - if (!maxCpu) { - if (parseFloat(value) === 0) return 0; - - return value; - } - - return ( - - ); - } - - renderMemoryUsage(id: string, usage: number) { - const { maxMemory } = this.props; - const tooltip = ( -

    Memory: {Math.ceil(usage * 100 / maxMemory)}%
    {bytesToUnits(usage, 3)}

    - ); - - if (!maxMemory) return usage ? bytesToUnits(usage) : 0; - - return ( - - ); - } - - @boundMethod - getTableRow(uid: string) { - const { pods } = this.props; - const pod = pods.find(pod => pod.getId() == uid); - const metrics = podsStore.getPodKubeMetrics(pod); - - return ( - showDetails(pod.selfLink, false))} - > - {pod.getName()} - - {pod.getNs()} - {pod.getRunningContainers().length}/{pod.getContainers().length} - {this.renderCpuUsage(`cpu-${pod.getId()}`, metrics.cpu)} - {this.renderMemoryUsage(`memory-${pod.getId()}`, metrics.memory)} - {pod.getStatusMessage()} - - ); - } - - render() { - const { pods } = this.props; - - if (!podsStore.isLoaded) { - return ( -
    - -
    - ); - } - - if (!pods.length) { - return null; - } - - const virtual = pods.length > 20; - - return ( -
    - - pod.getName(), - [sortBy.namespace]: pod => pod.getNs(), - [sortBy.cpu]: pod => podsStore.getPodKubeMetrics(pod).cpu, - [sortBy.memory]: pod => podsStore.getPodKubeMetrics(pod).memory, - }} - sortByDefault={{ sortBy: sortBy.cpu, orderBy: "desc" }} - sortSyncWithUrl={false} - getTableRow={this.getTableRow} - renderRow={!virtual && (pod => this.getTableRow(pod.getId()))} - className="box grow" - > - - Name - - Namespace - Ready - CPU - Memory - Status - -
    -
    - ); - } -} diff --git a/src/renderer/components/+workloads-pods/pod-details-secrets.tsx b/src/renderer/components/+workloads-pods/pod-details-secrets.tsx deleted file mode 100644 index 1c354b227f..0000000000 --- a/src/renderer/components/+workloads-pods/pod-details-secrets.tsx +++ /dev/null @@ -1,71 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import "./pod-details-secrets.scss"; - -import React, { Component } from "react"; -import { Link } from "react-router-dom"; -import { autorun, observable, makeObservable } from "mobx"; -import { disposeOnUnmount, observer } from "mobx-react"; -import { Pod, Secret, secretsApi } from "../../../common/k8s-api/endpoints"; -import { getDetailsUrl } from "../kube-detail-params"; - -interface Props { - pod: Pod; -} - -@observer -export class PodDetailsSecrets extends Component { - @observable secrets: Map = observable.map(); - - @disposeOnUnmount - secretsLoader = autorun(async () => { - const { pod } = this.props; - - const secrets = await Promise.all( - pod.getSecrets().map(secretName => secretsApi.get({ - name: secretName, - namespace: pod.getNs(), - })), - ); - - secrets.forEach(secret => secret && this.secrets.set(secret.getName(), secret)); - }); - - constructor(props: Props) { - super(props); - makeObservable(this); - } - - render() { - const { pod } = this.props; - - return ( -
    - { - pod.getSecrets().map(secretName => { - const secret = this.secrets.get(secretName); - - if (secret) { - return this.renderSecretLink(secret); - } else { - return ( - {secretName} - ); - } - }) - } -
    - ); - } - - protected renderSecretLink(secret: Secret) { - return ( - - {secret.getName()} - - ); - } -} diff --git a/src/renderer/components/+workloads-pods/pod-details.tsx b/src/renderer/components/+workloads-pods/pod-details.tsx deleted file mode 100644 index d1c2127f35..0000000000 --- a/src/renderer/components/+workloads-pods/pod-details.tsx +++ /dev/null @@ -1,246 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import "./pod-details.scss"; - -import React from "react"; -import kebabCase from "lodash/kebabCase"; -import { disposeOnUnmount, observer } from "mobx-react"; -import { Link } from "react-router-dom"; -import { observable, reaction, makeObservable } from "mobx"; -import { IPodMetrics, nodesApi, Pod, pvcApi, configMapApi, getMetricsForPods } from "../../../common/k8s-api/endpoints"; -import { DrawerItem, DrawerTitle } from "../drawer"; -import { Badge } from "../badge"; -import { boundMethod, cssNames, toJS } from "../../utils"; -import { PodDetailsContainer } from "./pod-details-container"; -import { PodDetailsAffinities } from "./pod-details-affinities"; -import { PodDetailsTolerations } from "./pod-details-tolerations"; -import { Icon } from "../icon"; -import { PodDetailsSecrets } from "./pod-details-secrets"; -import { ResourceMetrics } from "../resource-metrics"; -import type { KubeObjectDetailsProps } from "../kube-object-details"; -import { getItemMetrics } from "../../../common/k8s-api/endpoints/metrics.api"; -import { PodCharts, podMetricTabs } from "./pod-charts"; -import { KubeObjectMeta } from "../kube-object-meta"; -import { getActiveClusterEntity } from "../../api/catalog-entity-registry"; -import { ClusterMetricsResourceType } from "../../../common/cluster-types"; -import { getDetailsUrl } from "../kube-detail-params"; -import logger from "../../../common/logger"; - -interface Props extends KubeObjectDetailsProps { -} - -@observer -export class PodDetails extends React.Component { - @observable metrics: IPodMetrics; - @observable containerMetrics: IPodMetrics; - - constructor(props: Props) { - super(props); - makeObservable(this); - } - - componentDidMount() { - disposeOnUnmount(this, [ - reaction(() => this.props.object, () => { - this.metrics = null; - this.containerMetrics = null; - }), - ]); - } - - @boundMethod - async loadMetrics() { - const { object: pod } = this.props; - - this.metrics = await getMetricsForPods([pod], pod.getNs()); - this.containerMetrics = await getMetricsForPods([pod], pod.getNs(), "container, namespace"); - } - - render() { - const { object: pod } = this.props; - - if (!pod) { - return null; - } - - if (!(pod instanceof Pod)) { - logger.error("[PodDetails]: passed object that is not an instanceof Pod", pod); - - return null; - } - - const { status, spec } = pod; - const { conditions, podIP } = status; - const podIPs = pod.getIPs(); - const { nodeName } = spec; - const nodeSelector = pod.getNodeSelectors(); - const volumes = pod.getVolumes(); - const isMetricHidden = getActiveClusterEntity()?.isMetricHidden(ClusterMetricsResourceType.Pod); - - return ( -
    - {!isMetricHidden && ( - - - - )} - - - {pod.getStatusMessage()} - - - {nodeName && ( - - {nodeName} - - )} - - - {podIP} - - - - {pod.getPriorityClassName()} - - - {pod.getQosClass()} - - {conditions && - - { - conditions.map(condition => { - const { type, status, lastTransitionTime } = condition; - - return ( - - ); - }) - } - - } - {nodeSelector.length > 0 && - - { - nodeSelector.map(label => ( - - )) - } - - } - - - - {pod.getSecrets().length > 0 && ( - - - - )} - - {pod.getInitContainers() && pod.getInitContainers().length > 0 && - - } - { - pod.getInitContainers() && pod.getInitContainers().map(container => { - return ; - }) - } - - { - pod.getContainers().map(container => { - const { name } = container; - const metrics = getItemMetrics(toJS(this.containerMetrics), name); - - return ( - - ); - }) - } - - {volumes.length > 0 && ( - <> - - {volumes.map(volume => { - const claimName = volume.persistentVolumeClaim ? volume.persistentVolumeClaim.claimName : null; - const configMap = volume.configMap ? volume.configMap.name : null; - const type = Object.keys(volume)[1]; - - return ( -
    -
    - - {volume.name} -
    - - {type} - - { type == "configMap" && ( -
    - {configMap && ( - - {configMap} - - - )} -
    - )} - { type === "emptyDir" && ( -
    - { volume.emptyDir.medium && ( - - {volume.emptyDir.medium} - - )} - { volume.emptyDir.sizeLimit && ( - - {volume.emptyDir.sizeLimit} - - )} -
    - )} - - {claimName && ( - - {claimName} - - - )} -
    - ); - })} - - )} -
    - ); - } -} diff --git a/src/renderer/components/+workloads-pods/pod-tolerations.tsx b/src/renderer/components/+workloads-pods/pod-tolerations.tsx deleted file mode 100644 index c113f495e6..0000000000 --- a/src/renderer/components/+workloads-pods/pod-tolerations.tsx +++ /dev/null @@ -1,69 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import "./pod-tolerations.scss"; -import React from "react"; -import uniqueId from "lodash/uniqueId"; - -import type { IToleration } from "../../../common/k8s-api/workload-kube-object"; -import { Table, TableCell, TableHead, TableRow } from "../table"; - -interface Props { - tolerations: IToleration[]; -} - -enum sortBy { - Key = "key", - Operator = "operator", - Effect = "effect", - Seconds = "seconds", - Value = "value", -} - -const getTableRow = (toleration: IToleration) => { - const { key, operator, effect, tolerationSeconds, value } = toleration; - - return ( - - {key} - {operator} - {value} - {effect} - {tolerationSeconds} - - ); -}; - -export function PodTolerations({ tolerations }: Props) { - return ( - toleration.key, - [sortBy.Operator]: toleration => toleration.operator, - [sortBy.Effect]: toleration => toleration.effect, - [sortBy.Seconds]: toleration => toleration.tolerationSeconds, - }} - sortSyncWithUrl={false} - className="PodTolerations" - renderRow={getTableRow} - > - - Key - Operator - Value - Effect - Seconds - -
    - ); -} diff --git a/src/renderer/components/+workloads-pods/pods.store.ts b/src/renderer/components/+workloads-pods/pods.store.ts deleted file mode 100644 index 04e5a1942c..0000000000 --- a/src/renderer/components/+workloads-pods/pods.store.ts +++ /dev/null @@ -1,91 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import countBy from "lodash/countBy"; -import { observable, makeObservable } from "mobx"; -import { KubeObjectStore } from "../../../common/k8s-api/kube-object.store"; -import { autoBind, cpuUnitsToNumber, unitsToBytes } from "../../utils"; -import { Pod, PodMetrics, podMetricsApi, podsApi } from "../../../common/k8s-api/endpoints"; -import { apiManager } from "../../../common/k8s-api/api-manager"; -import type { WorkloadKubeObject } from "../../../common/k8s-api/workload-kube-object"; - -export class PodsStore extends KubeObjectStore { - api = podsApi; - - @observable kubeMetrics = observable.array([]); - - constructor() { - super(); - - makeObservable(this); - autoBind(this); - } - - async loadKubeMetrics(namespace?: string) { - try { - this.kubeMetrics.replace(await podMetricsApi.list({ namespace })); - } catch (error) { - console.warn("loadKubeMetrics failed", error); - } - } - - getPodsByOwner(workload: WorkloadKubeObject): Pod[] { - if (!workload) return []; - - return this.items.filter(pod => { - const owners = pod.getOwnerRefs(); - - return owners.find(owner => owner.uid === workload.getId()); - }); - } - - getPodsByOwnerId(workloadId: string): Pod[] { - return this.items.filter(pod => { - return pod.getOwnerRefs().find(owner => owner.uid === workloadId); - }); - } - - getPodsByNode(node: string) { - if (!this.isLoaded) return []; - - return this.items.filter(pod => pod.spec.nodeName === node); - } - - getStatuses(pods: Pod[]) { - return countBy(pods.map(pod => pod.getStatus()).sort().reverse()); - } - - getPodKubeMetrics(pod: Pod) { - const containers = pod.getContainers(); - const empty = { cpu: 0, memory: 0 }; - const metrics = this.kubeMetrics.find(metric => { - return [ - metric.getName() === pod.getName(), - metric.getNs() === pod.getNs(), - ].every(v => v); - }); - - if (!metrics) return empty; - - return containers.reduce((total, container) => { - const metric = metrics.containers.find(item => item.name == container.name); - let cpu = "0"; - let memory = "0"; - - if (metric && metric.usage) { - cpu = metric.usage.cpu || "0"; - memory = metric.usage.memory || "0"; - } - - return { - cpu: total.cpu + cpuUnitsToNumber(cpu), - memory: total.memory + unitsToBytes(memory), - }; - }, empty); - } -} - -export const podsStore = new PodsStore(); -apiManager.registerStore(podsStore); diff --git a/src/renderer/components/+workloads-pods/pods.tsx b/src/renderer/components/+workloads-pods/pods.tsx deleted file mode 100644 index 9e8616e04b..0000000000 --- a/src/renderer/components/+workloads-pods/pods.tsx +++ /dev/null @@ -1,146 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import "./pods.scss"; - -import React, { Fragment } from "react"; -import { observer } from "mobx-react"; -import { Link } from "react-router-dom"; -import { podsStore } from "./pods.store"; -import type { RouteComponentProps } from "react-router"; -import { eventStore } from "../+events/event.store"; -import { KubeObjectListLayout } from "../kube-object-list-layout"; -import { nodesApi, Pod } from "../../../common/k8s-api/endpoints"; -import { StatusBrick } from "../status-brick"; -import { cssNames, getConvertedParts, stopPropagation } from "../../utils"; -import toPairs from "lodash/toPairs"; -import startCase from "lodash/startCase"; -import kebabCase from "lodash/kebabCase"; -import { apiManager } from "../../../common/k8s-api/api-manager"; -import { KubeObjectStatusIcon } from "../kube-object-status-icon"; -import { Badge } from "../badge"; -import type { PodsRouteParams } from "../../../common/routes"; -import { getDetailsUrl } from "../kube-detail-params"; - -enum columnId { - name = "name", - namespace = "namespace", - containers = "containers", - restarts = "restarts", - age = "age", - qos = "qos", - node = "node", - owners = "owners", - status = "status", -} - -interface Props extends RouteComponentProps { -} - -@observer -export class Pods extends React.Component { - renderContainersStatus(pod: Pod) { - return pod.getContainerStatuses().map(containerStatus => { - const { name, state, ready } = containerStatus; - - return ( - - ( - -
    - {name} ({status}{ready ? ", ready" : ""}) -
    - {toPairs(state[status]).map(([name, value]) => ( -
    -
    {startCase(name)}
    -
    {value}
    -
    - ))} -
    - )), - }} - /> -
    - ); - }); - } - - render() { - return ( - getConvertedParts(pod.getName()), - [columnId.namespace]: pod => pod.getNs(), - [columnId.containers]: pod => pod.getContainers().length, - [columnId.restarts]: pod => pod.getRestartsCount(), - [columnId.owners]: pod => pod.getOwnerRefs().map(ref => ref.kind), - [columnId.qos]: pod => pod.getQosClass(), - [columnId.node]: pod => pod.getNodeName(), - [columnId.age]: pod => pod.getTimeDiffFromNow(), - [columnId.status]: pod => pod.getStatusMessage(), - }} - searchFilters={[ - pod => pod.getSearchFields(), - pod => pod.getStatusMessage(), - pod => pod.status.podIP, - pod => pod.getNodeName(), - ]} - renderHeaderTitle="Pods" - renderTableHeader={[ - { title: "Name", className: "name", sortBy: columnId.name, id: columnId.name }, - { className: "warning", showWithColumn: columnId.name }, - { title: "Namespace", className: "namespace", sortBy: columnId.namespace, id: columnId.namespace }, - { title: "Containers", className: "containers", sortBy: columnId.containers, id: columnId.containers }, - { title: "Restarts", className: "restarts", sortBy: columnId.restarts, id: columnId.restarts }, - { title: "Controlled By", className: "owners", sortBy: columnId.owners, id: columnId.owners }, - { title: "Node", className: "node", sortBy: columnId.node, id: columnId.node }, - { title: "QoS", className: "qos", sortBy: columnId.qos, id: columnId.qos }, - { title: "Age", className: "age", sortBy: columnId.age, id: columnId.age }, - { title: "Status", className: "status", sortBy: columnId.status, id: columnId.status }, - ]} - renderTableContents={pod => [ - , - , - pod.getNs(), - this.renderContainersStatus(pod), - pod.getRestartsCount(), - pod.getOwnerRefs().map(ref => { - const { kind, name } = ref; - const detailsLink = getDetailsUrl(apiManager.lookupApiLink(ref, pod)); - - return ( - - - {kind} - - - ); - }), - pod.getNodeName() ? - - - {pod.getNodeName()} - - - : "", - pod.getQosClass(), - pod.getAge(), - { title: pod.getStatusMessage(), className: kebabCase(pod.getStatusMessage()) }, - ]} - /> - ); - } -} diff --git a/src/renderer/components/+workloads-replicasets/index.ts b/src/renderer/components/+workloads-replicasets/index.ts deleted file mode 100644 index f89a481f8e..0000000000 --- a/src/renderer/components/+workloads-replicasets/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -export * from "./replicasets"; -export * from "./replicaset-details"; diff --git a/src/renderer/components/+workloads-replicasets/replicaset-details.tsx b/src/renderer/components/+workloads-replicasets/replicaset-details.tsx deleted file mode 100644 index 6a3365522e..0000000000 --- a/src/renderer/components/+workloads-replicasets/replicaset-details.tsx +++ /dev/null @@ -1,146 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import "./replicaset-details.scss"; -import React from "react"; -import { makeObservable, observable, reaction } from "mobx"; -import { DrawerItem } from "../drawer"; -import { Badge } from "../badge"; -import { replicaSetStore } from "./replicasets.store"; -import { PodDetailsStatuses } from "../+workloads-pods/pod-details-statuses"; -import { PodDetailsTolerations } from "../+workloads-pods/pod-details-tolerations"; -import { PodDetailsAffinities } from "../+workloads-pods/pod-details-affinities"; -import { disposeOnUnmount, observer } from "mobx-react"; -import { podsStore } from "../+workloads-pods/pods.store"; -import type { KubeObjectDetailsProps } from "../kube-object-details"; -import { getMetricsForReplicaSets, IPodMetrics, ReplicaSet } from "../../../common/k8s-api/endpoints"; -import { ResourceMetrics, ResourceMetricsText } from "../resource-metrics"; -import { PodCharts, podMetricTabs } from "../+workloads-pods/pod-charts"; -import { PodDetailsList } from "../+workloads-pods/pod-details-list"; -import { KubeObjectMeta } from "../kube-object-meta"; -import { getActiveClusterEntity } from "../../api/catalog-entity-registry"; -import { ClusterMetricsResourceType } from "../../../common/cluster-types"; -import { boundMethod, Disposer } from "../../utils"; -import logger from "../../../common/logger"; -import type { KubeObjectStore } from "../../../common/k8s-api/kube-object.store"; -import type { KubeObject } from "../../../common/k8s-api/kube-object"; -import { withInjectables } from "@ogre-tools/injectable-react"; -import kubeWatchApiInjectable - from "../../kube-watch-api/kube-watch-api.injectable"; - -interface Props extends KubeObjectDetailsProps { -} - -interface Dependencies { - subscribeStores: (stores: KubeObjectStore[]) => Disposer -} - -@observer -class NonInjectedReplicaSetDetails extends React.Component { - @observable metrics: IPodMetrics = null; - - constructor(props: Props & Dependencies) { - super(props); - makeObservable(this); - } - - componentDidMount() { - disposeOnUnmount(this, [ - reaction(() => this.props.object, () => { - this.metrics = null; - }), - - this.props.subscribeStores([ - podsStore, - ]), - ]); - } - - @boundMethod - async loadMetrics() { - const { object: replicaSet } = this.props; - - this.metrics = await getMetricsForReplicaSets([replicaSet], replicaSet.getNs(), ""); - } - - render() { - const { object: replicaSet } = this.props; - - if (!replicaSet) { - return null; - } - - if (!(replicaSet instanceof ReplicaSet)) { - logger.error("[ReplicaSetDetails]: passed object that is not an instanceof ReplicaSet", replicaSet); - - return null; - } - - const { metrics } = this; - const { status } = replicaSet; - const { availableReplicas, replicas } = status; - const selectors = replicaSet.getSelectors(); - const nodeSelector = replicaSet.getNodeSelectors(); - const images = replicaSet.getImages(); - const childPods = replicaSetStore.getChildPods(replicaSet); - const isMetricHidden = getActiveClusterEntity()?.isMetricHidden(ClusterMetricsResourceType.ReplicaSet); - - return ( -
    - {!isMetricHidden && podsStore.isLoaded && ( - - - - )} - - {selectors.length > 0 && - - { - selectors.map(label => ) - } - - } - {nodeSelector.length > 0 && - - { - nodeSelector.map(label => ) - } - - } - {images.length > 0 && - - { - images.map(image =>

    {image}

    ) - } -
    - } - - {`${availableReplicas || 0} current / ${replicas || 0} desired`} - - - - - - - - -
    - ); - } -} - -export const ReplicaSetDetails = withInjectables( - NonInjectedReplicaSetDetails, - - { - getProps: (di, props) => ({ - subscribeStores: di.inject(kubeWatchApiInjectable).subscribeStores, - ...props, - }), - }, -); diff --git a/src/renderer/components/+workloads-replicasets/replicaset-scale-dialog.test.tsx b/src/renderer/components/+workloads-replicasets/replicaset-scale-dialog.test.tsx deleted file mode 100755 index ed987c4f8b..0000000000 --- a/src/renderer/components/+workloads-replicasets/replicaset-scale-dialog.test.tsx +++ /dev/null @@ -1,180 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import "@testing-library/jest-dom/extend-expect"; - -import { ReplicaSetScaleDialog } from "./replicaset-scale-dialog"; -import { render, waitFor, fireEvent } from "@testing-library/react"; -import React from "react"; -import { ReplicaSet, ReplicaSetApi } from "../../../common/k8s-api/endpoints/replica-set.api"; - -const dummyReplicaSet: ReplicaSet = { - apiVersion: "v1", - kind: "dummy", - metadata: { - uid: "dummy", - name: "dummy", - creationTimestamp: "dummy", - resourceVersion: "dummy", - selfLink: "link", - }, - selfLink: "link", - spec: { - replicas: 1, - selector: { - matchLabels: { "label": "label" }, - }, - template: { - metadata: { - labels: { - app: "label", - }, - }, - spec: { - containers: [{ - name: "dummy", - image: "dummy", - imagePullPolicy: "dummy", - }], - initContainers: [{ - name: "dummy", - image: "dummy", - imagePullPolicy: "dummy", - }], - priority: 1, - serviceAccountName: "dummy", - serviceAccount: "dummy", - securityContext: {}, - schedulerName: "dummy", - }, - }, - minReadySeconds: 1, - }, - status: { - replicas: 1, - fullyLabeledReplicas: 1, - readyReplicas: 1, - availableReplicas: 1, - observedGeneration: 1, - conditions: [{ - type: "dummy", - status: "dummy", - lastUpdateTime: "dummy", - lastTransitionTime: "dummy", - reason: "dummy", - message: "dummy", - }], - }, - getDesired: jest.fn(), - getCurrent: jest.fn(), - getReady: jest.fn(), - getImages: jest.fn(), - getSelectors: jest.fn(), - getTemplateLabels: jest.fn(), - getAffinity: jest.fn(), - getTolerations: jest.fn(), - getNodeSelectors: jest.fn(), - getAffinityNumber: jest.fn(), - getId: jest.fn(), - getResourceVersion: jest.fn(), - getName: jest.fn(), - getNs: jest.fn(), - getAge: jest.fn(), - getTimeDiffFromNow: jest.fn(), - getFinalizers: jest.fn(), - getLabels: jest.fn(), - getAnnotations: jest.fn(), - getOwnerRefs: jest.fn(), - getSearchFields: jest.fn(), - toPlainObject: jest.fn(), - update: jest.fn(), - delete: jest.fn(), - patch: jest.fn(), -}; - -describe("", () => { - let replicaSetApi: ReplicaSetApi; - - beforeEach(() => { - replicaSetApi = new ReplicaSetApi({ - objectConstructor: ReplicaSet, - }); - }); - - it("renders w/o errors", () => { - const { container } = render(); - - expect(container).toBeInstanceOf(HTMLElement); - }); - - it("init with a dummy replica set and mocked current/desired scale", async () => { - // mock replicaSetApi.getReplicas() which will be called - // when rendered. - const initReplicas = 1; - - replicaSetApi.getReplicas = jest.fn().mockImplementationOnce(async () => initReplicas); - const { getByTestId } = render(); - - ReplicaSetScaleDialog.open(dummyReplicaSet); - // we need to wait for the replicaSetScaleDialog to show up - // because there is an in which renders null at start. - await waitFor(async () => { - const [currentScale, desiredScale] = await Promise.all([ - getByTestId("current-scale"), - getByTestId("desired-scale"), - ]); - - expect(currentScale).toHaveTextContent(`${initReplicas}`); - expect(desiredScale).toHaveTextContent(`${initReplicas}`); - }); - }); - - it("changes the desired scale when clicking the icon buttons +/-", async () => { - const initReplicas = 1; - - replicaSetApi.getReplicas = jest.fn().mockImplementationOnce(async () => initReplicas); - const component = render(); - - ReplicaSetScaleDialog.open(dummyReplicaSet); - await waitFor(async () => { - expect(await component.findByTestId("desired-scale")).toHaveTextContent(`${initReplicas}`); - expect(await component.findByTestId("current-scale")).toHaveTextContent(`${initReplicas}`); - expect((await component.baseElement.querySelector("input").value)).toBe(`${initReplicas}`); - }); - - const up = await component.findByTestId("desired-replicas-up"); - const down = await component.findByTestId("desired-replicas-down"); - - fireEvent.click(up); - expect(await component.findByTestId("desired-scale")).toHaveTextContent(`${initReplicas + 1}`); - expect(await component.findByTestId("current-scale")).toHaveTextContent(`${initReplicas}`); - expect((await component.baseElement.querySelector("input").value)).toBe(`${initReplicas + 1}`); - - fireEvent.click(down); - expect(await component.findByTestId("desired-scale")).toHaveTextContent(`${initReplicas}`); - expect(await component.findByTestId("current-scale")).toHaveTextContent(`${initReplicas}`); - expect((await component.baseElement.querySelector("input").value)).toBe(`${initReplicas}`); - - // edge case, desiredScale must >= 0 - let times = 10; - - for (let i = 0; i < times; i++) { - fireEvent.click(down); - } - expect(await component.findByTestId("desired-scale")).toHaveTextContent("0"); - expect((await component.baseElement.querySelector("input").value)).toBe("0"); - - // edge case, desiredScale must <= scaleMax (100) - times = 120; - - for (let i = 0; i < times; i++) { - fireEvent.click(up); - } - expect(await component.findByTestId("desired-scale")).toHaveTextContent("100"); - expect((component.baseElement.querySelector("input").value)).toBe("100"); - expect(await component.findByTestId("warning")) - .toHaveTextContent("High number of replicas may cause cluster performance issues"); - }); -}); diff --git a/src/renderer/components/+workloads-replicasets/replicaset-scale-dialog.tsx b/src/renderer/components/+workloads-replicasets/replicaset-scale-dialog.tsx deleted file mode 100644 index 13dd4a4bc1..0000000000 --- a/src/renderer/components/+workloads-replicasets/replicaset-scale-dialog.tsx +++ /dev/null @@ -1,187 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import "./replicaset-scale-dialog.scss"; - -import React, { Component } from "react"; -import { computed, observable, makeObservable } from "mobx"; -import { observer } from "mobx-react"; -import { Dialog, DialogProps } from "../dialog"; -import { Wizard, WizardStep } from "../wizard"; -import { Icon } from "../icon"; -import { Slider } from "../slider"; -import { Notifications } from "../notifications"; -import { cssNames } from "../../utils"; -import { ReplicaSet, ReplicaSetApi, replicaSetApi } from "../../../common/k8s-api/endpoints/replica-set.api"; - -interface Props extends Partial { - replicaSetApi: ReplicaSetApi -} - -const dialogState = observable.object({ - isOpen: false, - data: null as ReplicaSet, -}); - -@observer -export class ReplicaSetScaleDialog extends Component { - static defaultProps = { - replicaSetApi, - }; - - @observable ready = false; - @observable currentReplicas = 0; - @observable desiredReplicas = 0; - - constructor(props: Props) { - super(props); - makeObservable(this); - } - - static open(replicaSet: ReplicaSet) { - dialogState.isOpen = true; - dialogState.data = replicaSet; - } - - static close() { - dialogState.isOpen = false; - } - - get replicaSet() { - return dialogState.data; - } - - close = () => { - ReplicaSetScaleDialog.close(); - }; - - onOpen = async () => { - const { replicaSet } = this; - - this.currentReplicas = await this.props.replicaSetApi.getReplicas({ - namespace: replicaSet.getNs(), - name: replicaSet.getName(), - }); - this.desiredReplicas = this.currentReplicas; - this.ready = true; - }; - - onClose = () => { - this.ready = false; - }; - - onChange = (evt: React.ChangeEvent, value: number) => { - this.desiredReplicas = value; - }; - - @computed get scaleMax() { - const { currentReplicas } = this; - const defaultMax = 50; - - return currentReplicas <= defaultMax - ? defaultMax * 2 - : currentReplicas * 2; - } - - scale = async () => { - const { replicaSet } = this; - const { currentReplicas, desiredReplicas, close } = this; - - try { - if (currentReplicas !== desiredReplicas) { - await this.props.replicaSetApi.scale({ - name: replicaSet.getName(), - namespace: replicaSet.getNs(), - }, desiredReplicas); - } - close(); - } catch (err) { - Notifications.error(err); - } - }; - - private readonly scaleMin = 0; - - desiredReplicasUp = () => { - this.desiredReplicas = Math.min(this.scaleMax, this.desiredReplicas + 1); - }; - - desiredReplicasDown = () => { - this.desiredReplicas = Math.max(this.scaleMin, this.desiredReplicas - 1); - }; - - renderContents() { - const { currentReplicas, desiredReplicas, onChange, scaleMax } = this; - const warning = currentReplicas < 10 && desiredReplicas > 90; - - return ( - <> -
    - Current replica scale: {currentReplicas} -
    -
    -
    - Desired number of replicas: {desiredReplicas} -
    -
    - -
    -
    - - -
    -
    - {warning && -
    - - High number of replicas may cause cluster performance issues -
    - } - - ); - } - - render() { - const { className, ...dialogProps } = this.props; - const replicaSetName = this.replicaSet ? this.replicaSet.getName() : ""; - const header = ( -
    - Scale Replica Set {replicaSetName} -
    - ); - - return ( - - - - {this.renderContents()} - - - - ); - } -} diff --git a/src/renderer/components/+workloads-replicasets/replicasets.tsx b/src/renderer/components/+workloads-replicasets/replicasets.tsx deleted file mode 100644 index c76f11131d..0000000000 --- a/src/renderer/components/+workloads-replicasets/replicasets.tsx +++ /dev/null @@ -1,92 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import "./replicasets.scss"; - -import React from "react"; -import { observer } from "mobx-react"; -import type { ReplicaSet } from "../../../common/k8s-api/endpoints"; -import type { KubeObjectMenuProps } from "../kube-object-menu"; -import { replicaSetStore } from "./replicasets.store"; -import { KubeObjectStatusIcon } from "../kube-object-status-icon"; -import type { RouteComponentProps } from "react-router"; -import { KubeObjectListLayout } from "../kube-object-list-layout"; -import { MenuItem } from "../menu/menu"; -import { Icon } from "../icon/icon"; -import { ReplicaSetScaleDialog } from "./replicaset-scale-dialog"; -import type { ReplicaSetsRouteParams } from "../../../common/routes"; -import { eventStore } from "../+events/event.store"; - -enum columnId { - name = "name", - namespace = "namespace", - desired = "desired", - current = "current", - ready = "ready", - age = "age", -} - -interface Props extends RouteComponentProps { -} - -@observer -export class ReplicaSets extends React.Component { - render() { - return ( - replicaSet.getName(), - [columnId.namespace]: replicaSet => replicaSet.getNs(), - [columnId.desired]: replicaSet => replicaSet.getDesired(), - [columnId.current]: replicaSet => replicaSet.getCurrent(), - [columnId.ready]: replicaSet => replicaSet.getReady(), - [columnId.age]: replicaSet => replicaSet.getTimeDiffFromNow(), - }} - searchFilters={[ - replicaSet => replicaSet.getSearchFields(), - ]} - renderHeaderTitle="Replica Sets" - renderTableHeader={[ - { title: "Name", className: "name", sortBy: columnId.name, id: columnId.name }, - { className: "warning", showWithColumn: columnId.name }, - { title: "Namespace", className: "namespace", sortBy: columnId.namespace, id: columnId.namespace }, - { title: "Desired", className: "desired", sortBy: columnId.desired, id: columnId.desired }, - { title: "Current", className: "current", sortBy: columnId.current, id: columnId.current }, - { title: "Ready", className: "ready", sortBy: columnId.ready, id: columnId.ready }, - { title: "Age", className: "age", sortBy: columnId.age, id: columnId.age }, - ]} - renderTableContents={replicaSet => [ - replicaSet.getName(), - , - replicaSet.getNs(), - replicaSet.getDesired(), - replicaSet.getCurrent(), - replicaSet.getReady(), - replicaSet.getAge(), - ]} - renderItemMenu={(item: ReplicaSet) => { - return ; - }} - /> - ); - } -} - -export function ReplicaSetMenu(props: KubeObjectMenuProps) { - const { object, toolbar } = props; - - return ( - <> - ReplicaSetScaleDialog.open(object)}> - - Scale - - - ); -} diff --git a/src/renderer/components/+workloads-statefulsets/statefulset-details.tsx b/src/renderer/components/+workloads-statefulsets/statefulset-details.tsx deleted file mode 100644 index 8c3e323826..0000000000 --- a/src/renderer/components/+workloads-statefulsets/statefulset-details.tsx +++ /dev/null @@ -1,144 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import "./statefulset-details.scss"; - -import React from "react"; -import { disposeOnUnmount, observer } from "mobx-react"; -import { makeObservable, observable, reaction } from "mobx"; -import { Badge } from "../badge"; -import { DrawerItem } from "../drawer"; -import { PodDetailsStatuses } from "../+workloads-pods/pod-details-statuses"; -import { PodDetailsTolerations } from "../+workloads-pods/pod-details-tolerations"; -import { PodDetailsAffinities } from "../+workloads-pods/pod-details-affinities"; -import { podsStore } from "../+workloads-pods/pods.store"; -import { statefulSetStore } from "./statefulset.store"; -import type { KubeObjectDetailsProps } from "../kube-object-details"; -import { getMetricsForStatefulSets, IPodMetrics, StatefulSet } from "../../../common/k8s-api/endpoints"; -import { ResourceMetrics, ResourceMetricsText } from "../resource-metrics"; -import { PodCharts, podMetricTabs } from "../+workloads-pods/pod-charts"; -import { PodDetailsList } from "../+workloads-pods/pod-details-list"; -import { KubeObjectMeta } from "../kube-object-meta"; -import { getActiveClusterEntity } from "../../api/catalog-entity-registry"; -import { ClusterMetricsResourceType } from "../../../common/cluster-types"; -import { boundMethod, Disposer } from "../../utils"; -import logger from "../../../common/logger"; -import type { KubeObjectStore } from "../../../common/k8s-api/kube-object.store"; -import type { KubeObject } from "../../../common/k8s-api/kube-object"; -import { withInjectables } from "@ogre-tools/injectable-react"; -import kubeWatchApiInjectable - from "../../kube-watch-api/kube-watch-api.injectable"; - -interface Props extends KubeObjectDetailsProps { -} - -interface Dependencies { - subscribeStores: (stores: KubeObjectStore[]) => Disposer -} - -@observer -class NonInjectedStatefulSetDetails extends React.Component { - @observable metrics: IPodMetrics = null; - - constructor(props: Props & Dependencies) { - super(props); - makeObservable(this); - } - - componentDidMount() { - disposeOnUnmount(this, [ - reaction(() => this.props.object, () => { - this.metrics = null; - }), - - this.props.subscribeStores([ - podsStore, - ]), - ]); - } - - @boundMethod - async loadMetrics() { - const { object: statefulSet } = this.props; - - this.metrics = await getMetricsForStatefulSets([statefulSet], statefulSet.getNs(), ""); - } - - render() { - const { object: statefulSet } = this.props; - - if (!statefulSet) { - return null; - } - - if (!(statefulSet instanceof StatefulSet)) { - logger.error("[StatefulSetDetails]: passed object that is not an instanceof StatefulSet", statefulSet); - - return null; - } - - const images = statefulSet.getImages(); - const selectors = statefulSet.getSelectors(); - const nodeSelector = statefulSet.getNodeSelectors(); - const childPods = statefulSetStore.getChildPods(statefulSet); - const isMetricHidden = getActiveClusterEntity()?.isMetricHidden(ClusterMetricsResourceType.StatefulSet); - - return ( -
    - {!isMetricHidden && podsStore.isLoaded && ( - - - - )} - - {selectors.length && - - { - selectors.map(label => ) - } - - } - {nodeSelector.length > 0 && - - { - nodeSelector.map(label => ( - - )) - } - - } - {images.length > 0 && - - { - images.map(image =>

    {image}

    ) - } -
    - } - - - - - - - -
    - ); - } -} - -export const StatefulSetDetails = withInjectables( - NonInjectedStatefulSetDetails, - - { - getProps: (di, props) => ({ - subscribeStores: di.inject(kubeWatchApiInjectable).subscribeStores, - ...props, - }), - }, -); - diff --git a/src/renderer/components/+workloads-statefulsets/statefulset-scale-dialog.test.tsx b/src/renderer/components/+workloads-statefulsets/statefulset-scale-dialog.test.tsx deleted file mode 100755 index c76aba55b7..0000000000 --- a/src/renderer/components/+workloads-statefulsets/statefulset-scale-dialog.test.tsx +++ /dev/null @@ -1,190 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import "@testing-library/jest-dom/extend-expect"; - -import { StatefulSet, StatefulSetApi } from "../../../common/k8s-api/endpoints"; -import { StatefulSetScaleDialog } from "./statefulset-scale-dialog"; -import { render, waitFor, fireEvent } from "@testing-library/react"; -import React from "react"; - -const dummyStatefulSet: StatefulSet = { - apiVersion: "v1", - kind: "dummy", - metadata: { - uid: "dummy", - name: "dummy", - creationTimestamp: "dummy", - resourceVersion: "dummy", - selfLink: "link", - }, - selfLink: "link", - - spec: { - serviceName: "dummy", - replicas: 1, - selector: { - matchLabels: { "label": "label" }, - }, - template: { - metadata: { - labels: { - app: "app", - }, - }, - spec: { - containers: [{ - name: "dummy", - image: "dummy", - ports: [{ - containerPort: 1234, - name: "dummy", - }], - volumeMounts: [{ - name: "dummy", - mountPath: "dummy", - }], - }], - tolerations: [{ - key: "dummy", - operator: "dummy", - effect: "dummy", - tolerationSeconds: 1, - }], - }, - }, - volumeClaimTemplates: [{ - metadata: { - name: "dummy", - }, - spec: { - accessModes: ["dummy"], - resources: { - requests: { - storage: "dummy", - }, - }, - }, - }], - }, - status: { - observedGeneration: 1, - replicas: 1, - currentReplicas: 1, - readyReplicas: 1, - currentRevision: "dummy", - updateRevision: "dummy", - collisionCount: 1, - }, - - getImages: jest.fn(), - getReplicas: jest.fn(), - getSelectors: jest.fn(), - getTemplateLabels: jest.fn(), - getAffinity: jest.fn(), - getTolerations: jest.fn(), - getNodeSelectors: jest.fn(), - getAffinityNumber: jest.fn(), - getId: jest.fn(), - getResourceVersion: jest.fn(), - getName: jest.fn(), - getNs: jest.fn(), - getAge: jest.fn(), - getTimeDiffFromNow: jest.fn(), - getFinalizers: jest.fn(), - getLabels: jest.fn(), - getAnnotations: jest.fn(), - getOwnerRefs: jest.fn(), - getSearchFields: jest.fn(), - toPlainObject: jest.fn(), - update: jest.fn(), - delete: jest.fn(), - patch: jest.fn(), -}; - -describe("", () => { - let statefulSetApi: StatefulSetApi; - - beforeEach(() => { - statefulSetApi = new StatefulSetApi({ - objectConstructor: StatefulSet, - }); - }); - - it("renders w/o errors", () => { - const { container } = render(); - - expect(container).toBeInstanceOf(HTMLElement); - }); - - it("init with a dummy stateful set and mocked current/desired scale", async () => { - // mock statefulSetApi.getReplicas() which will be called - // when rendered. - const initReplicas = 1; - - statefulSetApi.getReplicas = jest.fn().mockImplementationOnce(async () => initReplicas); - const { getByTestId } = render(); - - StatefulSetScaleDialog.open(dummyStatefulSet); - // we need to wait for the StatefulSetScaleDialog to show up - // because there is an in which renders null at start. - await waitFor(async () => { - const [currentScale, desiredScale] = await Promise.all([ - getByTestId("current-scale"), - getByTestId("desired-scale"), - ]); - - expect(currentScale).toHaveTextContent(`${initReplicas}`); - expect(desiredScale).toHaveTextContent(`${initReplicas}`); - }); - }); - - it("changes the desired scale when clicking the icon buttons +/-", async () => { - const initReplicas = 1; - - statefulSetApi.getReplicas = jest.fn().mockImplementationOnce(async () => initReplicas); - const component = render(); - - StatefulSetScaleDialog.open(dummyStatefulSet); - await waitFor(async () => { - expect(await component.findByTestId("desired-scale")).toHaveTextContent(`${initReplicas}`); - expect(await component.findByTestId("current-scale")).toHaveTextContent(`${initReplicas}`); - expect((await component.baseElement.querySelector("input").value)).toBe(`${initReplicas}`); - }); - - const up = await component.findByTestId("desired-replicas-up"); - const down = await component.findByTestId("desired-replicas-down"); - - fireEvent.click(up); - expect(await component.findByTestId("desired-scale")).toHaveTextContent(`${initReplicas + 1}`); - expect(await component.findByTestId("current-scale")).toHaveTextContent(`${initReplicas}`); - expect((await component.baseElement.querySelector("input").value)).toBe(`${initReplicas + 1}`); - - fireEvent.click(down); - expect(await component.findByTestId("desired-scale")).toHaveTextContent(`${initReplicas}`); - expect(await component.findByTestId("current-scale")).toHaveTextContent(`${initReplicas}`); - expect((await component.baseElement.querySelector("input").value)).toBe(`${initReplicas}`); - - // edge case, desiredScale must >= 0 - let times = 10; - - for (let i = 0; i < times; i++) { - fireEvent.click(down); - } - expect(await component.findByTestId("desired-scale")).toHaveTextContent("0"); - expect((await component.baseElement.querySelector("input").value)).toBe("0"); - - // edge case, desiredScale must <= scaleMax (100) - times = 120; - - for (let i = 0; i < times; i++) { - fireEvent.click(up); - } - expect(await component.findByTestId("desired-scale")).toHaveTextContent("100"); - expect((component.baseElement.querySelector("input").value)).toBe("100"); - expect(await component.findByTestId("warning")) - .toHaveTextContent("High number of replicas may cause cluster performance issues"); - }); -}); diff --git a/src/renderer/components/+workloads-statefulsets/statefulset-scale-dialog.tsx b/src/renderer/components/+workloads-statefulsets/statefulset-scale-dialog.tsx deleted file mode 100644 index a7de5d8feb..0000000000 --- a/src/renderer/components/+workloads-statefulsets/statefulset-scale-dialog.tsx +++ /dev/null @@ -1,186 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import "./statefulset-scale-dialog.scss"; - -import { StatefulSet, StatefulSetApi, statefulSetApi } from "../../../common/k8s-api/endpoints"; -import React, { Component } from "react"; -import { computed, makeObservable, observable } from "mobx"; -import { observer } from "mobx-react"; -import { Dialog, DialogProps } from "../dialog"; -import { Wizard, WizardStep } from "../wizard"; -import { Icon } from "../icon"; -import { Slider } from "../slider"; -import { Notifications } from "../notifications"; -import { cssNames } from "../../utils"; - -interface Props extends Partial { - statefulSetApi: StatefulSetApi -} - -const dialogState = observable.object({ - isOpen: false, - data: null as StatefulSet, -}); - -@observer -export class StatefulSetScaleDialog extends Component { - static defaultProps = { - statefulSetApi, - }; - @observable ready = false; - @observable currentReplicas = 0; - @observable desiredReplicas = 0; - - constructor(props: Props) { - super(props); - makeObservable(this); - } - - static open(statefulSet: StatefulSet) { - dialogState.isOpen = true; - dialogState.data = statefulSet; - } - - static close() { - dialogState.isOpen = false; - } - - get statefulSet() { - return dialogState.data; - } - - close = () => { - StatefulSetScaleDialog.close(); - }; - - onOpen = async () => { - const { statefulSet } = this; - - this.currentReplicas = await this.props.statefulSetApi.getReplicas({ - namespace: statefulSet.getNs(), - name: statefulSet.getName(), - }); - this.desiredReplicas = this.currentReplicas; - this.ready = true; - }; - - onClose = () => { - this.ready = false; - }; - - onChange = (evt: React.ChangeEvent, value: number) => { - this.desiredReplicas = value; - }; - - @computed get scaleMax() { - const { currentReplicas } = this; - const defaultMax = 50; - - return currentReplicas <= defaultMax - ? defaultMax * 2 - : currentReplicas * 2; - } - - scale = async () => { - const { statefulSet } = this; - const { currentReplicas, desiredReplicas, close } = this; - - try { - if (currentReplicas !== desiredReplicas) { - await this.props.statefulSetApi.scale({ - name: statefulSet.getName(), - namespace: statefulSet.getNs(), - }, desiredReplicas); - } - close(); - } catch (err) { - Notifications.error(err); - } - }; - - private readonly scaleMin = 0; - - desiredReplicasUp = () => { - this.desiredReplicas = Math.min(this.scaleMax, this.desiredReplicas + 1); - }; - - desiredReplicasDown = () => { - this.desiredReplicas = Math.max(this.scaleMin, this.desiredReplicas - 1); - }; - - renderContents() { - const { currentReplicas, desiredReplicas, onChange, scaleMax } = this; - const warning = currentReplicas < 10 && desiredReplicas > 90; - - return ( - <> -
    - Current replica scale: {currentReplicas} -
    -
    -
    - Desired number of replicas: {desiredReplicas} -
    -
    - -
    -
    - - -
    -
    - {warning && -
    - - High number of replicas may cause cluster performance issues -
    - } - - ); - } - - render() { - const { className, ...dialogProps } = this.props; - const statefulSetName = this.statefulSet ? this.statefulSet.getName() : ""; - const header = ( -
    - Scale Stateful Set {statefulSetName} -
    - ); - - return ( - - - - {this.renderContents()} - - - - ); - } -} diff --git a/src/renderer/components/+workloads-statefulsets/statefulsets.tsx b/src/renderer/components/+workloads-statefulsets/statefulsets.tsx deleted file mode 100644 index 1b4a967282..0000000000 --- a/src/renderer/components/+workloads-statefulsets/statefulsets.tsx +++ /dev/null @@ -1,92 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import "./statefulsets.scss"; - -import React from "react"; -import { observer } from "mobx-react"; -import type { RouteComponentProps } from "react-router"; -import type { StatefulSet } from "../../../common/k8s-api/endpoints"; -import { podsStore } from "../+workloads-pods/pods.store"; -import { statefulSetStore } from "./statefulset.store"; -import { eventStore } from "../+events/event.store"; -import type { KubeObjectMenuProps } from "../kube-object-menu"; -import { KubeObjectListLayout } from "../kube-object-list-layout"; -import { KubeObjectStatusIcon } from "../kube-object-status-icon"; -import { StatefulSetScaleDialog } from "./statefulset-scale-dialog"; -import { MenuItem } from "../menu/menu"; -import { Icon } from "../icon/icon"; -import type { StatefulSetsRouteParams } from "../../../common/routes"; - -enum columnId { - name = "name", - namespace = "namespace", - pods = "pods", - age = "age", - replicas = "replicas", -} - -interface Props extends RouteComponentProps { -} - -@observer -export class StatefulSets extends React.Component { - renderPods(statefulSet: StatefulSet) { - const { readyReplicas, currentReplicas } = statefulSet.status; - - return `${readyReplicas || 0}/${currentReplicas || 0}`; - } - - render() { - return ( - statefulSet.getName(), - [columnId.namespace]: statefulSet => statefulSet.getNs(), - [columnId.age]: statefulSet => statefulSet.getTimeDiffFromNow(), - [columnId.replicas]: statefulSet => statefulSet.getReplicas(), - }} - searchFilters={[ - statefulSet => statefulSet.getSearchFields(), - ]} - renderHeaderTitle="Stateful Sets" - renderTableHeader={[ - { title: "Name", className: "name", sortBy: columnId.name, id: columnId.name }, - { title: "Namespace", className: "namespace", sortBy: columnId.namespace, id: columnId.namespace }, - { title: "Pods", className: "pods", id: columnId.pods }, - { title: "Replicas", className: "replicas", sortBy: columnId.replicas, id: columnId.replicas }, - { className: "warning", showWithColumn: columnId.replicas }, - { title: "Age", className: "age", sortBy: columnId.age, id: columnId.age }, - ]} - renderTableContents={statefulSet => [ - statefulSet.getName(), - statefulSet.getNs(), - this.renderPods(statefulSet), - statefulSet.getReplicas(), - , - statefulSet.getAge(), - ]} - renderItemMenu={item => } - /> - ); - } -} - -export function StatefulSetMenu(props: KubeObjectMenuProps) { - const { object, toolbar } = props; - - return ( - <> - StatefulSetScaleDialog.open(object)}> - - Scale - - - ); -} diff --git a/src/renderer/components/+workloads/index.ts b/src/renderer/components/+workloads/index.ts deleted file mode 100644 index 502ff98107..0000000000 --- a/src/renderer/components/+workloads/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -export * from "./workloads"; -export * from "./workloads.stores"; diff --git a/src/renderer/components/+workloads/layout.tsx b/src/renderer/components/+workloads/layout.tsx new file mode 100644 index 0000000000..8cb86de09d --- /dev/null +++ b/src/renderer/components/+workloads/layout.tsx @@ -0,0 +1,31 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { withInjectables } from "@ogre-tools/injectable-react"; +import type { IComputedValue } from "mobx"; +import { observer } from "mobx-react"; +import React from "react"; +import { TabLayout, TabLayoutRoute } from "../layout/tab-layout"; +import workloadRoutesInjectable from "./routes.injectable"; + +export interface WorkloadsLayoutProps {} + +interface Dependencies { + routes: IComputedValue; +} + +const NonInjectedWorkloadsLayout = observer(({ routes }: Dependencies & WorkloadsLayoutProps) => ( + +)); + +export const WorkloadsLayout = withInjectables(NonInjectedWorkloadsLayout, { + getProps: (di, props) => ({ + routes: di.inject(workloadRoutesInjectable), + ...props, + }), +}); diff --git a/src/renderer/components/+workloads/workloads-mixins.scss b/src/renderer/components/+workloads/mixins.scss similarity index 100% rename from src/renderer/components/+workloads/workloads-mixins.scss rename to src/renderer/components/+workloads/mixins.scss diff --git a/src/renderer/components/+workloads/workloads.tsx b/src/renderer/components/+workloads/routes.injectable.ts similarity index 66% rename from src/renderer/components/+workloads/workloads.tsx rename to src/renderer/components/+workloads/routes.injectable.ts index aaa9ba6209..bad1d89b3d 100644 --- a/src/renderer/components/+workloads/workloads.tsx +++ b/src/renderer/components/+workloads/routes.injectable.ts @@ -2,24 +2,27 @@ * Copyright (c) OpenLens Authors. All rights reserved. * Licensed under MIT License. See LICENSE in root directory for more information. */ - -import "./workloads.scss"; - -import React from "react"; -import { TabLayout, TabLayoutRoute } from "../layout/tab-layout"; +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { computed, IComputedValue } from "mobx"; +import type { KubeResource } from "../../../common/rbac"; +import isAllowedResourceInjectable from "../../utils/allowed-resource.injectable"; +import type { TabLayoutRoute } from "../layout/tab-layout"; import { WorkloadsOverview } from "../+workloads-overview/overview"; -import { Pods } from "../+workloads-pods"; -import { Deployments } from "../+workloads-deployments"; -import { DaemonSets } from "../+workloads-daemonsets"; -import { StatefulSets } from "../+workloads-statefulsets"; -import { Jobs } from "../+workloads-jobs"; -import { CronJobs } from "../+workloads-cronjobs"; -import { isAllowedResource } from "../../../common/utils/allowed-resource"; -import { ReplicaSets } from "../+workloads-replicasets"; +import { Pods } from "../+pods"; +import { Deployments } from "../+deployments"; +import { DaemonSets } from "../+daemonsets"; +import { StatefulSets } from "../+stateful-sets"; +import { Jobs } from "../+jobs"; +import { CronJobs } from "../+cronjobs"; +import { ReplicaSets } from "../+replica-sets"; import * as routes from "../../../common/routes"; -export class Workloads extends React.Component { - static get tabRoutes(): TabLayoutRoute[] { +interface Dependencies { + isAllowedResource: (resource: KubeResource) => boolean; +} + +function getRoutes({ isAllowedResource }: Dependencies): IComputedValue { + return computed(() => { const tabs: TabLayoutRoute[] = [ { title: "Overview", @@ -93,11 +96,14 @@ export class Workloads extends React.Component { } return tabs; - } - - render() { - return ( - - ); - } + }); } + +const workloadRoutesInjectable = getInjectable({ + instantiate: (di) => getRoutes({ + isAllowedResource: di.inject(isAllowedResourceInjectable), + }), + lifecycle: lifecycleEnum.singleton, +}); + +export default workloadRoutesInjectable; diff --git a/src/renderer/components/+workloads/sidebar-item.tsx b/src/renderer/components/+workloads/sidebar-item.tsx new file mode 100644 index 0000000000..96d04740c8 --- /dev/null +++ b/src/renderer/components/+workloads/sidebar-item.tsx @@ -0,0 +1,46 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { withInjectables } from "@ogre-tools/injectable-react"; +import type { IComputedValue } from "mobx"; +import { observer } from "mobx-react"; +import React from "react"; +import { workloadsRoute, workloadsURL } from "../../../common/routes"; +import { isActiveRoute } from "../../navigation"; +import { Icon } from "../icon"; +import { SidebarItem } from "../layout/sidebar-item"; +import type { TabLayoutRoute } from "../layout/tab-layout"; +import { TabRouteTree } from "../layout/tab-route-tree"; +import workloadRoutesInjectable from "./routes.injectable"; + +export interface WorkloadsSidebarItemProps {} + +interface Dependencies { + routes: IComputedValue; +} + +const NonInjectedWorkloadsSidebarItem = observer(({ routes }: Dependencies & WorkloadsSidebarItemProps) => { + const tabRoutes = routes.get(); + + return ( + } + > + + + ); +}); + +export const WorkloadsSidebarItem = withInjectables(NonInjectedWorkloadsSidebarItem, { + getProps: (di, props) => ({ + routes: di.inject(workloadRoutesInjectable), + ...props, + }), +}); diff --git a/src/renderer/components/+workloads/workloads.stores.ts b/src/renderer/components/+workloads/workloads.stores.ts deleted file mode 100644 index 8fb00c5a17..0000000000 --- a/src/renderer/components/+workloads/workloads.stores.ts +++ /dev/null @@ -1,25 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import type { KubeObjectStore } from "../../../common/k8s-api/kube-object.store"; -import { podsStore } from "../+workloads-pods/pods.store"; -import { deploymentStore } from "../+workloads-deployments/deployments.store"; -import { daemonSetStore } from "../+workloads-daemonsets/daemonsets.store"; -import { statefulSetStore } from "../+workloads-statefulsets/statefulset.store"; -import { jobStore } from "../+workloads-jobs/job.store"; -import { cronJobStore } from "../+workloads-cronjobs/cronjob.store"; -import type { KubeResource } from "../../../common/rbac"; -import { replicaSetStore } from "../+workloads-replicasets/replicasets.store"; -import type { KubeObject } from "../../../common/k8s-api/kube-object"; - -export const workloadStores = new Map>([ - ["pods", podsStore], - ["deployments", deploymentStore], - ["daemonsets", daemonSetStore], - ["statefulsets", statefulSetStore], - ["replicasets", replicaSetStore], - ["jobs", jobStore], - ["cronjobs", cronJobStore], -]); diff --git a/src/renderer/components/__tests__/cronjob.store.test.ts b/src/renderer/components/__tests__/cron-job.store.test.ts similarity index 80% rename from src/renderer/components/__tests__/cronjob.store.test.ts rename to src/renderer/components/__tests__/cron-job.store.test.ts index cc6ead0189..5d789a6d5e 100644 --- a/src/renderer/components/__tests__/cronjob.store.test.ts +++ b/src/renderer/components/__tests__/cron-job.store.test.ts @@ -2,8 +2,10 @@ * Copyright (c) OpenLens Authors. All rights reserved. * Licensed under MIT License. See LICENSE in root directory for more information. */ -import { cronJobStore } from "../+workloads-cronjobs/cronjob.store"; -import { CronJob } from "../../../common/k8s-api/endpoints"; +import { CronJobStore } from "../+cronjobs/store"; +import { JobStore } from "../+jobs/store"; +import { PodStore } from "../+pods/store"; +import { CronJob, CronJobApi, JobApi, PodApi } from "../../../common/k8s-api/endpoints"; const spec = { schedule: "test", @@ -68,6 +70,20 @@ otherSuspendedCronJob.spec = { ...spec }; scheduledCronJob.spec.suspend = false; describe("CronJob Store tests", () => { + let podStore: PodStore; + let jobStore: JobStore; + let cronJobStore: CronJobStore; + + beforeEach(() => { + podStore = new PodStore(new PodApi()); + jobStore = new JobStore(new JobApi(), { + podStore, + }); + cronJobStore = new CronJobStore(new CronJobApi(), { + jobStore, + }); + }); + it("gets CronJob statuses in proper sorting order", () => { const statuses = Object.entries(cronJobStore.getStatuses([ suspendedCronJob, diff --git a/src/renderer/components/__tests__/daemonset.store.test.ts b/src/renderer/components/__tests__/daemon-set.store.test.ts similarity index 80% rename from src/renderer/components/__tests__/daemonset.store.test.ts rename to src/renderer/components/__tests__/daemon-set.store.test.ts index ede72a393b..fa2b7ce420 100644 --- a/src/renderer/components/__tests__/daemonset.store.test.ts +++ b/src/renderer/components/__tests__/daemon-set.store.test.ts @@ -3,10 +3,9 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import { observable } from "mobx"; -import { daemonSetStore } from "../+workloads-daemonsets/daemonsets.store"; -import { podsStore } from "../+workloads-pods/pods.store"; -import { DaemonSet, Pod } from "../../../common/k8s-api/endpoints"; +import { DaemonSetStore } from "../+daemonsets/store"; +import { PodStore } from "../+pods/store"; +import { DaemonSet, DaemonSetApi, Pod, PodApi } from "../../../common/k8s-api/endpoints"; const runningDaemonSet = new DaemonSet({ apiVersion: "foo", @@ -50,6 +49,11 @@ const runningPod = new Pod({ uid: "foobar", ownerReferences: [{ uid: "runningDaemonSet", + apiVersion: "", + blockOwnerDeletion: false, + controller: false, + kind: "", + name: "bar", }], namespace: "default", }, @@ -87,6 +91,11 @@ const pendingPod = new Pod({ uid: "foobar-pending", ownerReferences: [{ uid: "pendingDaemonSet", + apiVersion: "", + blockOwnerDeletion: false, + controller: false, + kind: "", + name: "bar", }], namespace: "default", }, @@ -101,6 +110,11 @@ const failedPod = new Pod({ uid: "foobar-failed", ownerReferences: [{ uid: "failedDaemonSet", + apiVersion: "", + blockOwnerDeletion: false, + controller: false, + kind: "", + name: "bar", }], namespace: "default", }, @@ -115,8 +129,16 @@ failedPod.status = { }; describe("DaemonSet Store tests", () => { - beforeAll(() => { - podsStore.items = observable.array([ + let podStore: PodStore; + let daemonSetStore: DaemonSetStore; + + beforeEach(() => { + podStore = new PodStore(new PodApi()); + daemonSetStore = new DaemonSetStore(new DaemonSetApi(), { + podStore, + }); + + podStore.items.replace([ runningPod, failedPod, pendingPod, diff --git a/src/renderer/components/__tests__/deployments.store.test.ts b/src/renderer/components/__tests__/deployment.store.test.ts similarity index 91% rename from src/renderer/components/__tests__/deployments.store.test.ts rename to src/renderer/components/__tests__/deployment.store.test.ts index e03f2bab9e..787fdbfdb0 100644 --- a/src/renderer/components/__tests__/deployments.store.test.ts +++ b/src/renderer/components/__tests__/deployment.store.test.ts @@ -3,10 +3,9 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import { observable } from "mobx"; -import { deploymentStore } from "../+workloads-deployments/deployments.store"; -import { podsStore } from "../+workloads-pods/pods.store"; -import { Deployment, Pod } from "../../../common/k8s-api/endpoints"; +import { DeploymentStore } from "../+deployments/store"; +import { PodStore } from "../+pods/store"; +import { Deployment, DeploymentApi, Pod, PodApi } from "../../../common/k8s-api/endpoints"; const spec = { containers: [{ @@ -198,13 +197,21 @@ failedPod.status = { }; describe("Deployment Store tests", () => { - beforeAll(() => { - // Add pods to pod store - podsStore.items = observable.array([ + let deploymentStore: DeploymentStore; + let podStore: PodStore; + + beforeEach(() => { + podStore = new PodStore(new PodApi()); + + podStore.items.replace([ runningPod, failedPod, pendingPod, ]); + + deploymentStore = new DeploymentStore(new DeploymentApi(), { + podStore, + }); }); it("gets Deployment statuses in proper sorting order", () => { diff --git a/src/renderer/components/__tests__/job.store.test.ts b/src/renderer/components/__tests__/job.store.test.ts index b5864620aa..c02128d78d 100644 --- a/src/renderer/components/__tests__/job.store.test.ts +++ b/src/renderer/components/__tests__/job.store.test.ts @@ -3,10 +3,9 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import { observable } from "mobx"; -import { jobStore } from "../+workloads-jobs/job.store"; -import { podsStore } from "../+workloads-pods/pods.store"; -import { Job, Pod } from "../../../common/k8s-api/endpoints"; +import { JobStore } from "../+jobs/store"; +import { PodStore } from "../+pods/store"; +import { Job, JobApi, Pod, PodApi } from "../../../common/k8s-api/endpoints"; const runningJob = new Job({ apiVersion: "foo", @@ -61,6 +60,11 @@ const runningPod = new Pod({ uid: "foobar", ownerReferences: [{ uid: "runningJob", + apiVersion: "", + blockOwnerDeletion: false, + controller: false, + kind: "", + name: "bar", }], namespace: "default", }, @@ -98,6 +102,11 @@ const pendingPod = new Pod({ uid: "foobar-pending", ownerReferences: [{ uid: "pendingJob", + apiVersion: "", + blockOwnerDeletion: false, + controller: false, + kind: "", + name: "bar", }], namespace: "default", }, @@ -112,6 +121,11 @@ const failedPod = new Pod({ uid: "foobar-failed", ownerReferences: [{ uid: "failedJob", + apiVersion: "", + blockOwnerDeletion: false, + controller: false, + kind: "", + name: "bar", }], namespace: "default", }, @@ -134,6 +148,11 @@ const succeededPod = new Pod({ uid: "foobar-succeeded", ownerReferences: [{ uid: "succeededJob", + apiVersion: "", + blockOwnerDeletion: false, + controller: false, + kind: "", + name: "bar", }], }, }); @@ -147,12 +166,19 @@ succeededPod.status = { }; describe("Job Store tests", () => { - beforeAll(() => { - podsStore.items = observable.array([ + let podStore: PodStore; + let jobStore: JobStore; + + beforeEach(() => { + podStore = new PodStore(new PodApi()); + jobStore = new JobStore(new JobApi(), { + podStore, + }); + + podStore.items.replace([ runningPod, failedPod, pendingPod, - succeededPod, ]); }); diff --git a/src/renderer/components/__tests__/nodes.api.test.ts b/src/renderer/components/__tests__/node.api.test.ts similarity index 100% rename from src/renderer/components/__tests__/nodes.api.test.ts rename to src/renderer/components/__tests__/node.api.test.ts diff --git a/src/renderer/components/__tests__/pods.store.test.ts b/src/renderer/components/__tests__/pod.store.test.ts similarity index 88% rename from src/renderer/components/__tests__/pods.store.test.ts rename to src/renderer/components/__tests__/pod.store.test.ts index 580e901495..e03fb79584 100644 --- a/src/renderer/components/__tests__/pods.store.test.ts +++ b/src/renderer/components/__tests__/pod.store.test.ts @@ -3,8 +3,8 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import { Pod } from "../../../common/k8s-api/endpoints"; -import { podsStore } from "../+workloads-pods/pods.store"; +import { Pod, PodApi } from "../../../common/k8s-api/endpoints"; +import { PodStore } from "../+pods/store"; const runningPod = new Pod({ apiVersion: "foo", @@ -105,8 +105,14 @@ succeededPod.status = { }; describe("Pod Store tests", () => { + let podStore: PodStore; + + beforeEach(() => { + podStore = new PodStore(new PodApi()); + }); + it("gets Pod statuses in proper sorting order", () => { - const statuses = Object.entries(podsStore.getStatuses([ + const statuses = Object.entries(podStore.getStatuses([ pendingPod, runningPod, succeededPod, @@ -125,7 +131,7 @@ describe("Pod Store tests", () => { }); it("counts statuses properly", () => { - const statuses = Object.entries(podsStore.getStatuses([ + const statuses = Object.entries(podStore.getStatuses([ pendingPod, pendingPod, pendingPod, diff --git a/src/renderer/components/__tests__/replicaset.store.test.ts b/src/renderer/components/__tests__/replica-set.store.test.ts similarity index 80% rename from src/renderer/components/__tests__/replicaset.store.test.ts rename to src/renderer/components/__tests__/replica-set.store.test.ts index 079e7bd00b..245599823c 100644 --- a/src/renderer/components/__tests__/replicaset.store.test.ts +++ b/src/renderer/components/__tests__/replica-set.store.test.ts @@ -3,10 +3,9 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import { observable } from "mobx"; -import { podsStore } from "../+workloads-pods/pods.store"; -import { replicaSetStore } from "../+workloads-replicasets/replicasets.store"; -import { ReplicaSet, Pod } from "../../../common/k8s-api/endpoints"; +import { PodStore } from "../+pods/store"; +import { ReplicaSetStore } from "../+replica-sets/store"; +import { ReplicaSet, Pod, PodApi, ReplicaSetApi } from "../../../common/k8s-api/endpoints"; const runningReplicaSet = new ReplicaSet({ apiVersion: "foo", @@ -50,6 +49,11 @@ const runningPod = new Pod({ uid: "foobar", ownerReferences: [{ uid: "runningReplicaSet", + apiVersion: "", + blockOwnerDeletion: false, + controller: false, + kind: "", + name: "bar", }], namespace: "default", }, @@ -87,6 +91,11 @@ const pendingPod = new Pod({ uid: "foobar-pending", ownerReferences: [{ uid: "pendingReplicaSet", + apiVersion: "", + blockOwnerDeletion: false, + controller: false, + kind: "", + name: "bar", }], namespace: "default", }, @@ -101,6 +110,11 @@ const failedPod = new Pod({ uid: "foobar-failed", ownerReferences: [{ uid: "failedReplicaSet", + apiVersion: "", + blockOwnerDeletion: false, + controller: false, + kind: "", + name: "bar", }], namespace: "default", }, @@ -115,12 +129,21 @@ failedPod.status = { }; describe("ReplicaSet Store tests", () => { - beforeAll(() => { - podsStore.items = observable.array([ + let replicaSetStore: ReplicaSetStore; + let podStore: PodStore; + + beforeEach(() => { + podStore = new PodStore(new PodApi()); + + podStore.items.replace([ runningPod, failedPod, pendingPod, ]); + + replicaSetStore = new ReplicaSetStore(new ReplicaSetApi(), { + podStore, + }); }); it("gets ReplicaSet statuses in proper sorting order", () => { diff --git a/src/renderer/components/__tests__/statefulset.store.test.ts b/src/renderer/components/__tests__/stateful-set.store.test.ts similarity index 80% rename from src/renderer/components/__tests__/statefulset.store.test.ts rename to src/renderer/components/__tests__/stateful-set.store.test.ts index 3ece4dcf64..b66963fa3b 100644 --- a/src/renderer/components/__tests__/statefulset.store.test.ts +++ b/src/renderer/components/__tests__/stateful-set.store.test.ts @@ -3,10 +3,9 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import { observable } from "mobx"; -import { podsStore } from "../+workloads-pods/pods.store"; -import { statefulSetStore } from "../+workloads-statefulsets/statefulset.store"; -import { StatefulSet, Pod } from "../../../common/k8s-api/endpoints"; +import { PodStore } from "../+pods/store"; +import { StatefulSetStore } from "../+stateful-sets/store"; +import { StatefulSet, Pod, PodApi, StatefulSetApi } from "../../../common/k8s-api/endpoints"; const runningStatefulSet = new StatefulSet({ apiVersion: "foo", @@ -50,6 +49,11 @@ const runningPod = new Pod({ uid: "foobar", ownerReferences: [{ uid: "runningStatefulSet", + apiVersion: "", + blockOwnerDeletion: false, + controller: false, + kind: "", + name: "bar", }], namespace: "default", }, @@ -87,6 +91,11 @@ const pendingPod = new Pod({ uid: "foobar-pending", ownerReferences: [{ uid: "pendingStatefulSet", + apiVersion: "", + blockOwnerDeletion: false, + controller: false, + kind: "", + name: "bar", }], namespace: "default", }, @@ -101,6 +110,11 @@ const failedPod = new Pod({ uid: "foobar-failed", ownerReferences: [{ uid: "failedStatefulSet", + apiVersion: "", + blockOwnerDeletion: false, + controller: false, + kind: "", + name: "bar", }], namespace: "default", }, @@ -115,13 +129,21 @@ failedPod.status = { }; describe("StatefulSet Store tests", () => { - beforeAll(() => { - // Add pods to pod store - podsStore.items = observable.array([ + let statefulSetStore: StatefulSetStore; + let podStore: PodStore; + + beforeEach(() => { + podStore = new PodStore(new PodApi()); + + podStore.items.replace([ runningPod, failedPod, pendingPod, ]); + + statefulSetStore = new StatefulSetStore(new StatefulSetApi(), { + podStore, + }); }); it("gets StatefulSet statuses in proper sorting order", () => { diff --git a/src/renderer/components/activate-entity-command/activate-entity-command.tsx b/src/renderer/components/activate-entity-command/activate-entity-command.tsx index 9a7964dee6..fd3d70dc45 100644 --- a/src/renderer/components/activate-entity-command/activate-entity-command.tsx +++ b/src/renderer/components/activate-entity-command/activate-entity-command.tsx @@ -4,13 +4,13 @@ */ import { withInjectables } from "@ogre-tools/injectable-react"; -import { computed, IComputedValue } from "mobx"; +import type { IComputedValue } from "mobx"; import { observer } from "mobx-react"; import React from "react"; +import type { CatalogEntity } from "../../../common/catalog"; import { broadcastMessage, catalogEntityRunListener } from "../../../common/ipc"; -import type { CatalogEntity } from "../../api/catalog-entity"; -import { catalogEntityRegistry } from "../../api/catalog-entity-registry"; -import commandOverlayInjectable from "../command-palette/command-overlay.injectable"; +import entitiesInjectable from "../../catalog/entities.injectable"; +import closeCommandDialogInjectable from "../command-palette/close-command-dialog.injectable"; import { Select } from "../select"; interface Dependencies { @@ -45,7 +45,7 @@ const NonInjectedActivateEntityCommand = observer(({ closeCommandOverlay, entiti export const ActivateEntityCommand = withInjectables(NonInjectedActivateEntityCommand, { getProps: di => ({ - closeCommandOverlay: di.inject(commandOverlayInjectable).close, - entities: computed(() => [...catalogEntityRegistry.items]), + closeCommandOverlay: di.inject(closeCommandDialogInjectable), + entities: di.inject(entitiesInjectable), }), }); diff --git a/src/renderer/components/app.scss b/src/renderer/components/app.scss index bedc1d1f59..f9383f065f 100755 --- a/src/renderer/components/app.scss +++ b/src/renderer/components/app.scss @@ -9,14 +9,14 @@ @import "~flex.box"; // todo: replace with tailwind's flexbox classes @import "./fonts"; -@import "../themes/theme-vars"; +@import "../internal-themes/theme-vars"; :root { --unit: 8px; --padding: var(--unit); --margin: var(--unit); --border-radius: 3px; - --font-main: 'Roboto', 'Helvetica', 'Arial', sans-serif; + --font-main: "Roboto", "Helvetica", "Arial", sans-serif; --font-monospace: Lucida Console, Monaco, Consolas, monospace; --font-size-small: calc(1.5 * var(--unit)); --font-size: calc(1.75 * var(--unit)); @@ -27,7 +27,9 @@ --main-layout-header: 40px; } -*, *:before, *:after { +*, +*:before, +*:after { box-sizing: border-box; padding: 0; margin: 0; @@ -70,7 +72,8 @@ html { --flex-gap: #{$padding}; } -html, body { +html, +body { height: 100%; overflow: hidden; } @@ -107,7 +110,8 @@ label { color: var(--textColorSecondary); } -ol, ul { +ol, +ul { margin: 0; list-style: none; } @@ -116,7 +120,7 @@ h1 { color: var(--textColorPrimary); font-size: 28px; font-weight: normal; - letter-spacing: -.010em; + letter-spacing: -0.01em; margin: 0; } @@ -156,7 +160,7 @@ code { vertical-align: middle; border-radius: $radius; font-family: $font-monospace; - font-size: calc(var(--font-size) * .9); + font-size: calc(var(--font-size) * 0.9); color: #b4b5b4; &.block { diff --git a/src/renderer/components/catalog-entities/weblink-add-command.tsx b/src/renderer/components/catalog-entities/weblink-add-command.tsx index 18ec7071ea..ac87c6d5f6 100644 --- a/src/renderer/components/catalog-entities/weblink-add-command.tsx +++ b/src/renderer/components/catalog-entities/weblink-add-command.tsx @@ -3,95 +3,91 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import React from "react"; +import React, { useState } from "react"; import { observer } from "mobx-react"; import { Input } from "../input"; import { isUrl } from "../input/input_validators"; -import { WeblinkStore } from "../../../common/weblink-store"; -import { computed, makeObservable, observable } from "mobx"; +import type { WeblinkCreateOptions, WeblinkData } from "../../../common/weblinks/store"; import { withInjectables } from "@ogre-tools/injectable-react"; -import commandOverlayInjectable from "../command-palette/command-overlay.injectable"; +import closeCommandDialogInjectable from "../command-palette/close-command-dialog.injectable"; +import addWeblinkInjectable from "../../../common/weblinks/add-weblink.injectable"; + +export interface WeblinkAddCommandProps {} interface Dependencies { closeCommandOverlay: () => void; + addWeblink: (data: WeblinkCreateOptions) => WeblinkData; } +const NonInjectedWeblinkAddCommand = observer(({ closeCommandOverlay, addWeblink }: Dependencies & WeblinkAddCommandProps) => { + const [url, setUrl] = useState(""); + const [nameHidden, setNameHidden] = useState(true); + const [dirty, setDirty] = useState(false); -@observer -class NonInjectedWeblinkAddCommand extends React.Component { - @observable url = ""; - @observable nameHidden = true; - @observable dirty = false; + const onChangeUrl = (url: string) => { + setDirty(true); + setUrl(url); + }; - constructor(props: Dependencies) { - super(props); - makeObservable(this); - } + const onSubmitUrl = (url: string) => { + setDirty(true); + setUrl(url); + setNameHidden(false); + }; - onChangeUrl(url: string) { - this.dirty = true; - this.url = url; - } - - onSubmitUrl(url: string) { - this.dirty = true; - this.url = url; - this.nameHidden = false; - } - - onSubmit(name: string) { - WeblinkStore.getInstance().add({ - name: name || this.url, - url: this.url, + const onSubmit = (name: string) => { + addWeblink({ + name: name || url, + url, }); - this.props.closeCommandOverlay(); - } + closeCommandOverlay(); + }; - @computed get showValidation() { - return this.url?.length > 0; - } - - render() { - return ( - <> - this.onChangeUrl(v)} - onSubmit={(v) => this.onSubmitUrl(v)} - showValidationLine={true} /> - { this.nameHidden && ( - - Please provide a web link URL (Press "Enter" to continue or "Escape" to cancel) - - )} - { !this.nameHidden && ( - <> - this.onSubmit(v)} - dirty={true}/> + return ( + <> + + { + nameHidden + ? ( - Please provide a name for the web link (Press "Enter" to confirm or "Escape" to cancel) + Please provide a web link URL (Press "Enter" to continue or "Escape" to cancel) - - )} - - ); - } -} + ) + : ( + <> + + + Please provide a name for the web link (Press "Enter" to confirm or "Escape" to cancel) + + + ) + } + + ); +}); -export const WeblinkAddCommand = withInjectables(NonInjectedWeblinkAddCommand, { +export const WeblinkAddCommand = withInjectables(NonInjectedWeblinkAddCommand, { getProps: (di, props) => ({ - closeCommandOverlay: di.inject(commandOverlayInjectable).close, + closeCommandOverlay: di.inject(closeCommandDialogInjectable), + addWeblink: di.inject(addWeblinkInjectable), ...props, }), }); diff --git a/src/renderer/components/chart/bar-chart.tsx b/src/renderer/components/chart/bar-chart.tsx index d906b7b1c9..3c621ae6a3 100644 --- a/src/renderer/components/chart/bar-chart.tsx +++ b/src/renderer/components/chart/bar-chart.tsx @@ -12,160 +12,172 @@ import type { ChartData, ChartOptions, ChartPoint, ChartTooltipItem, Scriptable import { Chart, ChartKind, ChartProps } from "./chart"; import { bytesToUnits, cssNames } from "../../utils"; import { ZebraStripes } from "./zebra-stripes.plugin"; -import { ThemeStore } from "../../theme.store"; import { NoMetrics } from "../resource-metrics/no-metrics"; +import type { Theme } from "../../themes/store"; +import type { IComputedValue } from "mobx"; +import { withInjectables } from "@ogre-tools/injectable-react"; +import activeThemeInjectable from "../../themes/active-theme.injectable"; -interface Props extends ChartProps { +export interface BarChartProps extends ChartProps { name?: string; timeLabelStep?: number; // Minute labels appearance step } -const defaultProps: Partial = { - timeLabelStep: 10, - plugins: [ZebraStripes], -}; - -@observer -export class BarChart extends React.Component { - static defaultProps = defaultProps as object; - - render() { - const { name, data, className, timeLabelStep, plugins, options: customOptions, ...settings } = this.props; - const { textColorPrimary, borderFaintColor, chartStripesColor } = ThemeStore.getInstance().activeTheme.colors; - - const getBarColor: Scriptable = ({ dataset }) => { - const color = dataset.borderColor; - - return Color(color).alpha(0.2).string(); - }; - - // Remove empty sets and insert default data - const chartData: ChartData = { - ...data, - datasets: data.datasets - .filter(set => set.data.length) - .map(item => { - return { - type: ChartKind.BAR, - borderWidth: { top: 3 }, - barPercentage: 1, - categoryPercentage: 1, - ...item, - }; - }), - }; - - if (chartData.datasets.length == 0) { - return ; - } - - const formatTimeLabels = (timestamp: string, index: number) => { - const label = moment(parseInt(timestamp)).format("HH:mm"); - const offset = " "; - - if (index == 0) return offset + label; - if (index == 60) return label + offset; - - return index % timeLabelStep == 0 ? label : ""; - }; - - const barOptions: ChartOptions = { - maintainAspectRatio: false, - responsive: true, - scales: { - xAxes: [{ - type: "time", - offset: true, - gridLines: { - display: false, - }, - stacked: true, - ticks: { - callback: formatTimeLabels, - autoSkip: false, - source: "data", - backdropColor: "white", - fontColor: textColorPrimary, - fontSize: 11, - maxRotation: 0, - minRotation: 0, - }, - bounds: "data", - time: { - unit: "minute", - displayFormats: { - minute: "x", - }, - parser: timestamp => moment.unix(parseInt(timestamp)), - }, - }], - yAxes: [{ - position: "right", - gridLines: { - color: borderFaintColor, - drawBorder: false, - tickMarkLength: 0, - zeroLineWidth: 0, - }, - ticks: { - maxTicksLimit: 6, - fontColor: textColorPrimary, - fontSize: 11, - padding: 8, - min: 0, - }, - }], - }, - tooltips: { - mode: "index", - position: "cursor", - callbacks: { - title([tooltip]: ChartTooltipItem[]) { - const xLabel = tooltip?.xLabel; - const skipLabel = xLabel == null || new Date(xLabel).getTime() > Date.now(); - - if (skipLabel) return ""; - - return String(xLabel); - }, - labelColor: ({ datasetIndex }) => { - return { - borderColor: "darkgray", - backgroundColor: chartData.datasets[datasetIndex].borderColor as string, - }; - }, - }, - }, - animation: { - duration: 0, - }, - elements: { - rectangle: { - backgroundColor: getBarColor.bind(null), - }, - }, - plugins: { - ZebraStripes: { - stripeColor: chartStripesColor, - interval: chartData.datasets[0].data.length, - }, - }, - }; - const options = merge(barOptions, customOptions); - - return ( - - ); - } +interface Dependencies { + activeTheme: IComputedValue; } +const NonInjectedBarChart = observer(({ + name, + timeLabelStep = 10, + plugins = [ZebraStripes], + activeTheme, + data, + className, + options: customOptions, + ...settings +}: Dependencies & BarChartProps) => { + const { textColorPrimary, borderFaintColor, chartStripesColor } = activeTheme.get().colors; + + const getBarColor: Scriptable = ({ dataset }) => { + const color = dataset.borderColor; + + return Color(color).alpha(0.2).string(); + }; + + // Remove empty sets and insert default data + const chartData: ChartData = { + ...data, + datasets: data.datasets + .filter(set => set.data.length) + .map(item => { + return { + type: ChartKind.BAR, + borderWidth: { top: 3 }, + barPercentage: 1, + categoryPercentage: 1, + ...item, + }; + }), + }; + + if (chartData.datasets.length == 0) { + return ; + } + + const formatTimeLabels = (timestamp: string, index: number) => { + const label = moment(parseInt(timestamp)).format("HH:mm"); + const offset = " "; + + if (index == 0) return offset + label; + if (index == 60) return label + offset; + + return index % timeLabelStep == 0 ? label : ""; + }; + + const barOptions: ChartOptions = { + maintainAspectRatio: false, + responsive: true, + scales: { + xAxes: [{ + type: "time", + offset: true, + gridLines: { + display: false, + }, + stacked: true, + ticks: { + callback: formatTimeLabels, + autoSkip: false, + source: "data", + backdropColor: "white", + fontColor: textColorPrimary, + fontSize: 11, + maxRotation: 0, + minRotation: 0, + }, + bounds: "data", + time: { + unit: "minute", + displayFormats: { + minute: "x", + }, + parser: timestamp => moment.unix(parseInt(timestamp)), + }, + }], + yAxes: [{ + position: "right", + gridLines: { + color: borderFaintColor, + drawBorder: false, + tickMarkLength: 0, + zeroLineWidth: 0, + }, + ticks: { + maxTicksLimit: 6, + fontColor: textColorPrimary, + fontSize: 11, + padding: 8, + min: 0, + }, + }], + }, + tooltips: { + mode: "index", + position: "cursor", + callbacks: { + title([tooltip]: ChartTooltipItem[]) { + const xLabel = tooltip?.xLabel; + const skipLabel = xLabel == null || new Date(xLabel).getTime() > Date.now(); + + if (skipLabel) return ""; + + return String(xLabel); + }, + labelColor: ({ datasetIndex }) => { + return { + borderColor: "darkgray", + backgroundColor: chartData.datasets[datasetIndex].borderColor as string, + }; + }, + }, + }, + animation: { + duration: 0, + }, + elements: { + rectangle: { + backgroundColor: getBarColor.bind(null), + }, + }, + plugins: { + ZebraStripes: { + stripeColor: chartStripesColor, + interval: chartData.datasets[0].data.length, + }, + }, + }; + const options = merge(barOptions, customOptions); + + return ( + + ); +}); + +export const BarChart = withInjectables(NonInjectedBarChart, { + getProps: (di, props) => ({ + activeTheme: di.inject(activeThemeInjectable), + ...props, + }), +}); + // Default options for all charts containing memory units (network, disk, memory, etc) export const memoryOptions: ChartOptions = { scales: { diff --git a/src/renderer/components/chart/pie-chart.tsx b/src/renderer/components/chart/pie-chart.tsx index 3c5f0a9e19..bbb420a5a6 100644 --- a/src/renderer/components/chart/pie-chart.tsx +++ b/src/renderer/components/chart/pie-chart.tsx @@ -9,65 +9,75 @@ import { observer } from "mobx-react"; import ChartJS, { ChartOptions } from "chart.js"; import { Chart, ChartProps } from "./chart"; import { cssNames } from "../../utils"; -import { ThemeStore } from "../../theme.store"; +import { withInjectables } from "@ogre-tools/injectable-react"; +import type { IComputedValue } from "mobx"; +import activeThemeInjectable from "../../themes/active-theme.injectable"; +import type { Theme } from "../../themes/store"; -interface Props extends ChartProps { +export interface PieChartProps extends ChartProps { } -@observer -export class PieChart extends React.Component { - render() { - const { data, className, options, ...chartProps } = this.props; - const { contentColor } = ThemeStore.getInstance().activeTheme.colors; - const cutouts = [88, 76, 63]; - const opts: ChartOptions = this.props.showChart === false ? {} : { - maintainAspectRatio: false, - tooltips: { - mode: "index", - callbacks: { - title: () => "", - label: (tooltipItem, data) => { - const dataset: any = data["datasets"][tooltipItem.datasetIndex]; - const metaData = Object.values<{ total: number }>(dataset["_meta"])[0]; - const percent = Math.round((dataset["data"][tooltipItem["index"]] / metaData.total) * 100); - const label = dataset["label"]; - - if (isNaN(percent)) return `${label}: N/A`; - - return `${label}: ${percent}%`; - }, - }, - filter: ({ datasetIndex, index }, { datasets }) => { - const { data } = datasets[datasetIndex]; - - if (datasets.length === 1) return true; - - return index !== data.length - 1; - }, - position: "cursor", - }, - elements: { - arc: { - borderWidth: 1, - borderColor: contentColor, - }, - }, - cutoutPercentage: cutouts[data.datasets.length - 1] || 50, - responsive: true, - ...options, - }; - - return ( - - ); - } +interface Dependencies { + activeTheme: IComputedValue; } +const NonInjectedPieChart = observer(({ activeTheme, data, className, options, showChart, ...chartProps }: Dependencies & PieChartProps) => { + const { contentColor } = activeTheme.get().colors; + const cutouts = [88, 76, 63]; + const opts: ChartOptions = showChart === false ? {} : { + maintainAspectRatio: false, + tooltips: { + mode: "index", + callbacks: { + title: () => "", + label: (tooltipItem, data) => { + const dataset: any = data["datasets"][tooltipItem.datasetIndex]; + const metaData = Object.values<{ total: number }>(dataset["_meta"])[0]; + const percent = Math.round((dataset["data"][tooltipItem["index"]] / metaData.total) * 100); + const label = dataset["label"]; + + if (isNaN(percent)) return `${label}: N/A`; + + return `${label}: ${percent}%`; + }, + }, + filter: ({ datasetIndex, index }, { datasets }) => { + const { data } = datasets[datasetIndex]; + + if (datasets.length === 1) return true; + + return index !== data.length - 1; + }, + position: "cursor", + }, + elements: { + arc: { + borderWidth: 1, + borderColor: contentColor, + }, + }, + cutoutPercentage: cutouts[data.datasets.length - 1] || 50, + responsive: true, + ...options, + }; + + return ( + + ); +}); + +export const PieChart = withInjectables(NonInjectedPieChart, { + getProps: (di, props) => ({ + activeTheme: di.inject(activeThemeInjectable), + ...props, + }), +}); + ChartJS.Tooltip.positioners.cursor = function (elements: any, position: { x: number; y: number }) { return position; }; diff --git a/src/renderer/components/cluster-manager/bottom-bar.test.tsx b/src/renderer/components/cluster-manager/bottom-bar.test.tsx index 97539f0ea3..a3149b150b 100644 --- a/src/renderer/components/cluster-manager/bottom-bar.test.tsx +++ b/src/renderer/components/cluster-manager/bottom-bar.test.tsx @@ -4,19 +4,21 @@ */ import React from "react"; -import { render } from "@testing-library/react"; import "@testing-library/jest-dom/extend-expect"; import { BottomBar } from "./bottom-bar"; import { StatusBarRegistry } from "../../../extensions/registries"; +import { getDiForUnitTesting } from "../../getDiForUnitTesting"; +import { DiRender, renderFor } from "../test-utils/renderFor"; +import type { ConfigurableDependencyInjectionContainer } from "@ogre-tools/injectable"; -jest.mock("electron", () => ({ - app: { - getPath: () => "/foo", - }, -})); describe("", () => { + let render: DiRender; + let di: ConfigurableDependencyInjectionContainer; + beforeEach(() => { + di = getDiForUnitTesting(); + render = renderFor(di); StatusBarRegistry.createInstance(); }); diff --git a/src/renderer/components/cluster-manager/cluster-frame-handler.injectable.ts b/src/renderer/components/cluster-manager/cluster-frame-handler.injectable.ts new file mode 100644 index 0000000000..fd66ef9d7d --- /dev/null +++ b/src/renderer/components/cluster-manager/cluster-frame-handler.injectable.ts @@ -0,0 +1,16 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import getClusterByIdInjectable from "../../../common/cluster-store/get-cluster-by-id.injectable"; +import { ClusterFrameHandler } from "./cluster-frame-handler"; + +const clusterFrameHandlerInjectable = getInjectable({ + instantiate: (di) => new ClusterFrameHandler({ + getClusterById: di.inject(getClusterByIdInjectable), + }), + lifecycle: lifecycleEnum.singleton, +}); + +export default clusterFrameHandlerInjectable; diff --git a/src/renderer/components/cluster-manager/lens-views.ts b/src/renderer/components/cluster-manager/cluster-frame-handler.ts similarity index 81% rename from src/renderer/components/cluster-manager/lens-views.ts rename to src/renderer/components/cluster-manager/cluster-frame-handler.ts index edd3b6d0c7..a883b4d3dc 100644 --- a/src/renderer/components/cluster-manager/lens-views.ts +++ b/src/renderer/components/cluster-manager/cluster-frame-handler.ts @@ -3,34 +3,34 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import { action, IReactionDisposer, makeObservable, observable, when } from "mobx"; +import { action, IReactionDisposer, observable, when } from "mobx"; import logger from "../../../main/logger"; import { clusterVisibilityHandler } from "../../../common/cluster-ipc"; -import { ClusterStore } from "../../../common/cluster-store/cluster-store"; import type { ClusterId } from "../../../common/cluster-types"; -import { getClusterFrameUrl, Singleton } from "../../utils"; +import { getClusterFrameUrl } from "../../utils"; import { ipcRenderer } from "electron"; +import type { Cluster } from "../../../common/cluster/cluster"; export interface LensView { isLoaded: boolean; frame: HTMLIFrameElement; } -export class ClusterFrameHandler extends Singleton { +export interface ClusterFrameHandlerDependencies { + getClusterById: (id: string) => Cluster | null; +} + +export class ClusterFrameHandler { private views = observable.map(); - constructor() { - super(); - makeObservable(this); - } + constructor(protected readonly dependencies: ClusterFrameHandlerDependencies) {} public hasLoadedView(clusterId: string): boolean { return Boolean(this.views.get(clusterId)?.isLoaded); } - @action - public initView(clusterId: ClusterId) { - const cluster = ClusterStore.getInstance().getById(clusterId); + public initView = action((clusterId: ClusterId) => { + const cluster = this.dependencies.getClusterById(clusterId); if (!cluster || this.views.has(clusterId)) { return; @@ -64,7 +64,7 @@ export class ClusterFrameHandler extends Singleton { () => { when( () => { - const cluster = ClusterStore.getInstance().getById(clusterId); + const cluster = this.dependencies.getClusterById(clusterId); return !cluster || (cluster.disconnected && this.views.get(clusterId)?.isLoaded); }, @@ -77,7 +77,7 @@ export class ClusterFrameHandler extends Singleton { ); }, ); - } + }); private prevVisibleClusterChange?: IReactionDisposer; @@ -92,7 +92,7 @@ export class ClusterFrameHandler extends Singleton { view.style.display = "none"; } - const cluster = ClusterStore.getInstance().getById(clusterId); + const cluster = this.dependencies.getClusterById(clusterId); if (cluster) { this.prevVisibleClusterChange = when( diff --git a/src/renderer/components/cluster-manager/cluster-manager.tsx b/src/renderer/components/cluster-manager/cluster-manager.tsx index f7fe8bb40a..045c340c8b 100644 --- a/src/renderer/components/cluster-manager/cluster-manager.tsx +++ b/src/renderer/components/cluster-manager/cluster-manager.tsx @@ -5,9 +5,9 @@ import "./cluster-manager.scss"; -import React from "react"; +import React, { useEffect } from "react"; import { Redirect, Route, Switch } from "react-router"; -import { disposeOnUnmount, observer } from "mobx-react"; +import { observer } from "mobx-react"; import { BottomBar } from "./bottom-bar"; import { Catalog } from "../+catalog"; import { Preferences } from "../+preferences"; @@ -22,64 +22,66 @@ import * as routes from "../../../common/routes"; import { DeleteClusterDialog } from "../delete-cluster-dialog"; import { reaction } from "mobx"; import { navigation } from "../../navigation"; -import { setEntityOnRouteMatch } from "../../api/helpers/general-active-sync"; +import setEntityOnRouteMatchInjectable from "../../catalog/set-entity-on-route-match.injectable"; import { catalogURL, getPreviousTabUrl } from "../../../common/routes"; import { withInjectables } from "@ogre-tools/injectable-react"; import { TopBar } from "../layout/top-bar/top-bar"; -import catalogPreviousActiveTabStorageInjectable from "../+catalog/catalog-previous-active-tab-storage/catalog-previous-active-tab-storage.injectable"; +import type { StorageLayer } from "../../utils"; +import catalogPreviousActiveTabInjectable from "../+catalog/catalog-previous-tab.injectable"; + +export interface ClusterManagerProps { + +} interface Dependencies { - catalogPreviousActiveTabStorage: { get: () => string } + setEntityOnRouteMatch: () => void; + previousActiveTab: StorageLayer; } -@observer -class NonInjectedClusterManager extends React.Component { - componentDidMount() { - disposeOnUnmount(this, [ - reaction(() => navigation.location, () => setEntityOnRouteMatch(), { fireImmediately: true }), - ]); - } +const NonInjectedClusterManager = observer(({ setEntityOnRouteMatch, previousActiveTab }: Dependencies & ClusterManagerProps) => { + useEffect(() => ( + reaction( + () => navigation.location, + () => setEntityOnRouteMatch(), + { fireImmediately: true }, + ) + ), []); - render() { - return ( -
    - -
    -
    - - - - - - - - - - - {GlobalPageRegistry.getInstance() - .getItems() + return ( +
    + +
    +
    + + + + + + + + + + { + GlobalPageRegistry.getInstance().getItems() .map(({ url, components: { Page }}) => ( - ))} - - -
    - - - -
    - ); - } -} + )) + } + +
    +
    + + + +
    + ); +}); -export const ClusterManager = withInjectables(NonInjectedClusterManager, { - getProps: di => ({ - catalogPreviousActiveTabStorage: di.inject(catalogPreviousActiveTabStorageInjectable), +export const ClusterManager = withInjectables(NonInjectedClusterManager, { + getProps: (di, props) => ({ + setEntityOnRouteMatch: di.inject(setEntityOnRouteMatchInjectable), + previousActiveTab: di.inject(catalogPreviousActiveTabInjectable), + ...props, }), }); + diff --git a/src/renderer/components/cluster-manager/cluster-status.tsx b/src/renderer/components/cluster-manager/cluster-status.tsx index 11a16f2689..7cf0deafc0 100644 --- a/src/renderer/components/cluster-manager/cluster-status.tsx +++ b/src/renderer/components/cluster-manager/cluster-status.tsx @@ -5,9 +5,9 @@ import styles from "./cluster-status.module.scss"; -import { computed, observable, makeObservable } from "mobx"; -import { disposeOnUnmount, observer } from "mobx-react"; -import React from "react"; +import { observable } from "mobx"; +import { observer } from "mobx-react"; +import React, { useEffect, useState } from "react"; import { clusterActivateHandler } from "../../../common/cluster-ipc"; import { ipcRendererOn, requestMain } from "../../../common/ipc"; import type { Cluster } from "../../../common/cluster/cluster"; @@ -18,84 +18,69 @@ import { Spinner } from "../spinner"; import { navigate } from "../../navigation"; import { entitySettingsURL } from "../../../common/routes"; import type { KubeAuthUpdate } from "../../../common/cluster-types"; -import { catalogEntityRegistry } from "../../api/catalog-entity-registry"; +import { withInjectables } from "@ogre-tools/injectable-react"; +import type { CatalogEntity } from "../../../common/catalog"; +import getEntityByIdInjectable from "../../catalog/get-entity-by-id.injectable"; -interface Props { +export interface ClusterStatusProps { className?: IClassName; cluster: Cluster; } -@observer -export class ClusterStatus extends React.Component { - @observable authOutput: KubeAuthUpdate[] = []; - @observable isReconnecting = false; +interface Dependencies { + getEntityById: (id: string) => CatalogEntity; +} - constructor(props: Props) { - super(props); - makeObservable(this); - } +const NonInjectedClusterStatus = observer(({ getEntityById, cluster, className }: Dependencies & ClusterStatusProps) => { + const [authOutput] = useState(observable.array()); + const [isReconnecting, setIsReconnecting] = useState(false); - get cluster(): Cluster { - return this.props.cluster; - } + useEffect(() => ( + ipcRendererOn(`cluster:${cluster.id}:connection-update`, (evt, res: KubeAuthUpdate) => { + authOutput.push(res); + }) + ), []); - @computed get entity() { - return catalogEntityRegistry.getById(this.cluster.id); - } + const entity = getEntityById(cluster.id); + const hasErrors = authOutput.some(({ isError }) => isError); - @computed get hasErrors(): boolean { - return this.authOutput.some(({ isError }) => isError); - } - - componentDidMount() { - disposeOnUnmount(this, [ - ipcRendererOn(`cluster:${this.cluster.id}:connection-update`, (evt, res: KubeAuthUpdate) => { - this.authOutput.push(res); - }), - ]); - } - - reconnect = async () => { - this.authOutput = []; - this.isReconnecting = true; + const reconnect = async () => { + authOutput.clear(); + setIsReconnecting(true); try { - await requestMain(clusterActivateHandler, this.cluster.id, true); + await requestMain(clusterActivateHandler, cluster.id, true); } catch (error) { - this.authOutput.push({ + authOutput.push({ message: error.toString(), isError: true, }); } finally { - this.isReconnecting = false; + setIsReconnecting(false); } }; - manageProxySettings = () => { + const manageProxySettings = () => { navigate(entitySettingsURL({ params: { - entityId: this.cluster.id, + entityId: cluster.id, }, fragment: "proxy", })); }; - renderAuthenticationOutput() { - return ( -
    -        {
    -          this.authOutput.map(({ message, isError }, index) => (
    -            

    - {message.trim()} -

    - )) - } -
    - ); - } + const renderAuthenticationOutput = () => ( +
    +      {authOutput.map(({ message, isError }, index) => (
    +        

    + {message.trim()} +

    + ))} +
    + ); - renderStatusIcon() { - if (this.hasErrors) { + const renderStatusIcon = () => { + if (hasErrors) { return ; } @@ -103,46 +88,45 @@ export class ClusterStatus extends React.Component { <>
    -          

    {this.isReconnecting ? "Reconnecting" : "Connecting"}…

    +

    {isReconnecting ? "Reconnecting" : "Connecting"}…

    ); - } + }; - renderReconnectionHelp() { - if (this.hasErrors && !this.isReconnecting) { - return ( - <> -
    + + ); +}); - return ( - -
    - {icon} {message} -
    -
    -
    -
    - ); - } -} +export const ConfirmDialog = withInjectables(NonInjectedConfirmDialog, { + getProps: (di, props) => ({ + closeConfirmDialog: di.inject(closeConfirmDialogInjectable), + state: di.inject(confirmDialogStateInjectable), + ...props, + }), +}); diff --git a/src/renderer/components/confirm-dialog/dialog-close.injectable.ts b/src/renderer/components/confirm-dialog/dialog-close.injectable.ts new file mode 100644 index 0000000000..315d6bdd8f --- /dev/null +++ b/src/renderer/components/confirm-dialog/dialog-close.injectable.ts @@ -0,0 +1,25 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { bind } from "../../utils"; +import type { ConfirmDialogState } from "./dialog.state.injectable"; +import confirmDialogStateInjectable from "./dialog.state.injectable"; + +interface Dependencies { + confirmDialogState: ConfirmDialogState; +} + +function closeConfirmDialog({ confirmDialogState }: Dependencies): void { + confirmDialogState.params = null; +} + +const closeConfirmDialogInjectable = getInjectable({ + instantiate: (di) => bind(closeConfirmDialog, null, { + confirmDialogState: di.inject(confirmDialogStateInjectable), + }), + lifecycle: lifecycleEnum.singleton, +}); + +export default closeConfirmDialogInjectable; diff --git a/src/renderer/components/confirm-dialog/dialog-confirm.injectable.ts b/src/renderer/components/confirm-dialog/dialog-confirm.injectable.ts new file mode 100644 index 0000000000..33bd459822 --- /dev/null +++ b/src/renderer/components/confirm-dialog/dialog-confirm.injectable.ts @@ -0,0 +1,32 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { bind } from "../../utils"; +import type { ConfirmDialogBooleanParams, ConfirmDialogParams } from "./confirm-dialog"; +import openConfirmDialogInjectable from "./dialog-open.injectable"; + +interface Dependencies { + openConfirmDialog: (params: ConfirmDialogParams) => void +} + +function confirmWithDialog({ openConfirmDialog }: Dependencies, params: ConfirmDialogBooleanParams): Promise { + return new Promise(resolve => { + openConfirmDialog({ + ...params, + ok: () => resolve(true), + cancel: () => resolve(false), + }); + }); +} + +const confirmWithDialogInjectable = getInjectable({ + instantiate: (di) => bind(confirmWithDialog, null, { + openConfirmDialog: di.inject(openConfirmDialogInjectable), + }), + lifecycle: lifecycleEnum.singleton, +}); + +export default confirmWithDialogInjectable; diff --git a/src/renderer/components/confirm-dialog/dialog-open.injectable.ts b/src/renderer/components/confirm-dialog/dialog-open.injectable.ts new file mode 100644 index 0000000000..fc257f6c23 --- /dev/null +++ b/src/renderer/components/confirm-dialog/dialog-open.injectable.ts @@ -0,0 +1,26 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { bind } from "../../utils"; +import type { ConfirmDialogParams } from "./confirm-dialog"; +import type { ConfirmDialogState } from "./dialog.state.injectable"; +import confirmDialogStateInjectable from "./dialog.state.injectable"; + +interface Dependencies { + confirmDialogState: ConfirmDialogState; +} + +function openConfirmDialog({ confirmDialogState }: Dependencies, params: ConfirmDialogParams): void { + confirmDialogState.params = params; +} + +const openConfirmDialogInjectable = getInjectable({ + instantiate: (di) => bind(openConfirmDialog, null, { + confirmDialogState: di.inject(confirmDialogStateInjectable), + }), + lifecycle: lifecycleEnum.singleton, +}); + +export default openConfirmDialogInjectable; diff --git a/src/renderer/components/confirm-dialog/dialog.state.injectable.ts b/src/renderer/components/confirm-dialog/dialog.state.injectable.ts new file mode 100644 index 0000000000..8784f0201b --- /dev/null +++ b/src/renderer/components/confirm-dialog/dialog.state.injectable.ts @@ -0,0 +1,20 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { observable } from "mobx"; +import type { ConfirmDialogParams } from "./confirm-dialog"; + +export interface ConfirmDialogState { + params: ConfirmDialogParams | null; +} + +const confirmDialogStateInjectable = getInjectable({ + instantiate: () => observable.object({ + params: null, + }), + lifecycle: lifecycleEnum.singleton, +}); + +export default confirmDialogStateInjectable; diff --git a/src/renderer/components/+workloads-pods/pod-container-port.scss b/src/renderer/components/container-port/view.scss similarity index 96% rename from src/renderer/components/+workloads-pods/pod-container-port.scss rename to src/renderer/components/container-port/view.scss index 1c08040ae5..fa7177c87e 100644 --- a/src/renderer/components/+workloads-pods/pod-container-port.scss +++ b/src/renderer/components/container-port/view.scss @@ -3,7 +3,7 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -.PodContainerPort { +.ContainerPort { &.waiting { opacity: 0.5; pointer-events: none; diff --git a/src/renderer/components/container-port/view.tsx b/src/renderer/components/container-port/view.tsx new file mode 100644 index 0000000000..135630e9cf --- /dev/null +++ b/src/renderer/components/container-port/view.tsx @@ -0,0 +1,189 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import "./view.scss"; + +import React, { useEffect, useState } from "react"; +import { observer } from "mobx-react"; +import { action, reaction, IComputedValue } from "mobx"; +import { cssNames } from "../../utils"; +import { Notifications } from "../notifications"; +import { Button } from "../button"; +import { aboutPortForwarding, notifyErrorPortForwarding, openPortForward, predictProtocol } from "../../port-forward"; +import type { ForwardedPort } from "../../port-forward"; +import { Spinner } from "../spinner"; +import logger from "../../../common/logger"; +import { withInjectables } from "@ogre-tools/injectable-react"; +import addPortForwardInjectable from "../../port-forward/add.injectable"; +import portForwardsInjectable from "../../port-forward/port-forwards.injectable"; +import getPortForwardInjectable from "../../port-forward/get.injectable"; +import removePortForwardInjectable from "../../port-forward/remove.injectable"; +import startPortForwardInjectable from "../../port-forward/start.injectable"; +import type { PortForwardDialogOpenOptions } from "../../port-forward/open-dialog.injectable"; +import openPortForwardDialogInjectable from "../../port-forward/open-dialog.injectable"; +import type { KubeObject } from "../../../common/k8s-api/kube-object"; + +export interface ContainerPortProps { + object: KubeObject; + port: { + name?: string; + port: number; + protocol: string; + } +} + +interface Dependencies { + addPortForward: (portForward: ForwardedPort) => Promise; + getPortForward: (portForward: ForwardedPort) => Promise; + portForwards: IComputedValue; + removePortForward: (portForward: ForwardedPort) => Promise; + startPortForward: (portForward: ForwardedPort) => Promise; + openPortForwardDialog: (portForward: ForwardedPort, options?: PortForwardDialogOpenOptions) => void; +} + +const NonInjectedContainerPort = observer(({ object, port, openPortForwardDialog, addPortForward, getPortForward, portForwards, removePortForward, startPortForward }: Dependencies & ContainerPortProps) => { + const [waiting, setWaiting] = useState(false); + const [forwardPort, setForwardPort] = useState(0); + const [isPortForwarded, setIsPortForwarded] = useState(false); + const [isActive, setIsActive] = useState(false); + + const checkExistingPortForwarding = action(async () => { + let portForward: ForwardedPort = { + kind: object.kind, + name: object.getName(), + namespace: object.getNs(), + port: port.port, + forwardPort, + }; + + try { + portForward = await getPortForward(portForward); + } catch (error) { + setIsPortForwarded(false); + setIsActive(false); + + return; + } + + setForwardPort(portForward.forwardPort); + setIsPortForwarded(true); + setIsActive(portForward.status === "Active"); + }); + + const portForward = action(async () => { + let portForward: ForwardedPort = { + kind: object.kind, + name: object.getName(), + namespace: object.getNs(), + port: port.port, + forwardPort, + protocol: predictProtocol(port.name), + status: "Active", + }; + + setWaiting(true); + + try { + // determine how many port-forwards already exist + const { length } = portForwards.get(); + + if (!isPortForwarded) { + portForward = await addPortForward(portForward); + } else if (!isActive) { + portForward = await startPortForward(portForward); + } + + if (portForward.status === "Active") { + openPortForward(portForward); + + // if this is the first port-forward show the about notification + if (!length) { + aboutPortForwarding(); + } + } else { + notifyErrorPortForwarding(`Error occurred starting port-forward, the local port may not be available or the ${portForward.kind} ${portForward.name} may not be reachable`); + } + } catch (error) { + logger.error("[POD-CONTAINER-PORT]:", error, portForward); + } finally { + checkExistingPortForwarding(); + setWaiting(false); + } + }); + + const stopPortForward = action(async () => { + const portForward: ForwardedPort = { + kind: object.kind, + name: object.getName(), + namespace: object.getNs(), + port: port.port, + forwardPort, + }; + + setWaiting(true); + + try { + await removePortForward(portForward); + } catch (error) { + Notifications.error(`Error occurred stopping the port-forward from port ${portForward.forwardPort}.`); + } finally { + checkExistingPortForwarding(); + setForwardPort(0); + setWaiting(false); + } + }); + + const portForwardAction = action(async () => { + if (isPortForwarded) { + await stopPortForward(); + } else { + const portForward: ForwardedPort = { + kind: object.kind, + name: object.getName(), + namespace: object.getNs(), + port: port.port, + forwardPort, + protocol: predictProtocol(port.name), + }; + + openPortForwardDialog(portForward, { openInBrowser: true, onClose: () => checkExistingPortForwarding() }); + } + }); + + useEffect(() => reaction( + () => object, + checkExistingPortForwarding, + { + fireImmediately: true, + }, + ), []); + + const { name, port: containerPort, protocol } = port; + const text = `${name ? `${name}: ` : ""}${containerPort}/${protocol}`; + + return ( +
    + portForward()}> + {text} + + + {waiting && ( + + )} +
    + ); +}); + +export const ContainerPort = withInjectables(NonInjectedContainerPort, { + getProps: (di, props) => ({ + addPortForward: di.inject(addPortForwardInjectable), + getPortForward: di.inject(getPortForwardInjectable), + portForwards: di.inject(portForwardsInjectable), + removePortForward: di.inject(removePortForwardInjectable), + startPortForward: di.inject(startPortForwardInjectable), + openPortForwardDialog: di.inject(openPortForwardDialogInjectable), + ...props, + }), +}); diff --git a/src/renderer/components/current-cluster.injectable.ts b/src/renderer/components/current-cluster.injectable.ts new file mode 100644 index 0000000000..3ac7e06656 --- /dev/null +++ b/src/renderer/components/current-cluster.injectable.ts @@ -0,0 +1,19 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { computed } from "mobx"; +import getClusterByIdInjectable from "../../common/cluster-store/get-cluster-by-id.injectable"; +import { getHostedClusterId } from "../utils"; + +const currentClusterInjectable = getInjectable({ + instantiate: (di) => { + const getClusterById = di.inject(getClusterByIdInjectable); + + return computed(() => getClusterById(getHostedClusterId())); + }, + lifecycle: lifecycleEnum.singleton, +}); + +export default currentClusterInjectable; diff --git a/src/renderer/components/delete-cluster-dialog/__tests__/delete-cluster-dialog.test.tsx b/src/renderer/components/delete-cluster-dialog/__tests__/delete-cluster-dialog.test.tsx index fad58d03c5..33f9438f63 100644 --- a/src/renderer/components/delete-cluster-dialog/__tests__/delete-cluster-dialog.test.tsx +++ b/src/renderer/components/delete-cluster-dialog/__tests__/delete-cluster-dialog.test.tsx @@ -4,7 +4,7 @@ */ import "@testing-library/jest-dom/extend-expect"; import { KubeConfig } from "@kubernetes/client-node"; -import { fireEvent, render } from "@testing-library/react"; +import { fireEvent } from "@testing-library/react"; import mockFs from "mock-fs"; import React from "react"; import * as selectEvent from "react-select-event"; @@ -15,6 +15,9 @@ import { DeleteClusterDialog } from "../delete-cluster-dialog"; import type { ClusterModel } from "../../../../common/cluster-types"; import { getDisForUnitTesting } from "../../../../test-utils/get-dis-for-unit-testing"; import { createClusterInjectionToken } from "../../../../common/cluster/create-cluster-injection-token"; +import type { DependencyInjectionContainer } from "@ogre-tools/injectable"; +import { type DiRender, renderFor } from "../../test-utils/renderFor"; +import deleteClusterDialogStateInjectable from "../state.injectable"; jest.mock("electron", () => ({ app: { @@ -87,6 +90,8 @@ let config: KubeConfig; describe("", () => { let createCluster: (model: ClusterModel) => Cluster; + let di: DependencyInjectionContainer; + let render: DiRender; beforeEach(async () => { const { mainDi, runSetups } = getDisForUnitTesting({ doGeneralOverrides: true }); @@ -96,6 +101,7 @@ describe("", () => { await runSetups(); createCluster = mainDi.inject(createClusterInjectionToken); + render = renderFor(di); }); afterEach(() => { @@ -103,7 +109,7 @@ describe("", () => { }); describe("Kubeconfig with different clusters", () => { - beforeEach(async () => { + beforeEach(() => { const mockOpts = { "temp-kube-config": kubeconfig, }; @@ -134,7 +140,8 @@ describe("", () => { kubeConfigPath: "./temp-kube-config", }); - DeleteClusterDialog.open({ cluster, config }); + di.override(deleteClusterDialogStateInjectable, () => ({ cluster, config })); + const { getByText } = render(); const message = "The contents of kubeconfig file will be changed!"; @@ -152,7 +159,7 @@ describe("", () => { kubeConfigPath: "./temp-kube-config", }); - DeleteClusterDialog.open({ cluster, config }); + di.override(deleteClusterDialogStateInjectable, () => ({ cluster, config })); const { getByTestId } = render(); @@ -169,15 +176,15 @@ describe("", () => { kubeConfigPath: "./temp-kube-config", }); - DeleteClusterDialog.open({ cluster, config }); + di.override(deleteClusterDialogStateInjectable, () => ({ cluster, config })); - const { getByText } = render(); + const { findByText } = render(); - expect(getByText("Select...")).toBeInTheDocument(); - selectEvent.openMenu(getByText("Select...")); + expect(await findByText("Select...")).toBeInTheDocument(); + selectEvent.openMenu(await findByText("Select...")); - expect(getByText("test")).toBeInTheDocument(); - expect(getByText("test2")).toBeInTheDocument(); + expect(await findByText("test")).toBeInTheDocument(); + expect(await findByText("test2")).toBeInTheDocument(); }); it("shows context switcher after checkbox click", async () => { @@ -190,19 +197,20 @@ describe("", () => { kubeConfigPath: "./temp-kube-config", }); - DeleteClusterDialog.open({ cluster, config }); + di.override(deleteClusterDialogStateInjectable, () => ({ cluster, config })); - const { getByText, getByTestId } = render(); - const link = getByTestId("context-switch"); + + const { findByText, findByTestId } = render(); + const link = await findByTestId("context-switch"); expect(link).toBeInstanceOf(HTMLElement); fireEvent.click(link); - expect(getByText("Select...")).toBeInTheDocument(); - selectEvent.openMenu(getByText("Select...")); + expect(await findByText("Select...")).toBeInTheDocument(); + selectEvent.openMenu(await findByText("Select...")); - expect(getByText("test")).toBeInTheDocument(); - expect(getByText("test2")).toBeInTheDocument(); + expect(await findByText("test")).toBeInTheDocument(); + expect(await findByText("test2")).toBeInTheDocument(); }); it("shows warning for internal kubeconfig cluster", () => { @@ -217,7 +225,7 @@ describe("", () => { const spy = jest.spyOn(cluster, "isInLocalKubeconfig").mockImplementation(() => true); - DeleteClusterDialog.open({ cluster, config }); + di.override(deleteClusterDialogStateInjectable, () => ({ cluster, config })); const { getByTestId } = render(); @@ -228,7 +236,7 @@ describe("", () => { }); describe("Kubeconfig with single cluster", () => { - beforeEach(async () => { + beforeEach(() => { const mockOpts = { "temp-kube-config": singleClusterConfig, }; @@ -253,7 +261,7 @@ describe("", () => { kubeConfigPath: "./temp-kube-config", }); - DeleteClusterDialog.open({ cluster, config }); + di.override(deleteClusterDialogStateInjectable, () => ({ cluster, config })); const { getByTestId } = render(); diff --git a/src/renderer/components/delete-cluster-dialog/close-delete-cluster-dialog.injectable.ts b/src/renderer/components/delete-cluster-dialog/close-delete-cluster-dialog.injectable.ts new file mode 100644 index 0000000000..3248bafb19 --- /dev/null +++ b/src/renderer/components/delete-cluster-dialog/close-delete-cluster-dialog.injectable.ts @@ -0,0 +1,26 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { action } from "mobx"; +import { bind } from "../../utils"; +import deleteClusterDialogStateInjectable, { DeleteClusterDialogState } from "./state.injectable"; + +interface Dependencies { + state: DeleteClusterDialogState; +} + +const closeDeleteClusterDialog = action(({ state }: Dependencies): void => { + state.cluster = undefined; + state.config = undefined; +}); + +const closeDeleteClusterDialogInjectable = getInjectable({ + instantiate: (di) => bind(closeDeleteClusterDialog, null, { + state: di.inject(deleteClusterDialogStateInjectable), + }), + lifecycle: lifecycleEnum.singleton, +}); + +export default closeDeleteClusterDialogInjectable; diff --git a/src/renderer/components/delete-cluster-dialog/delete-cluster-dialog.tsx b/src/renderer/components/delete-cluster-dialog/delete-cluster-dialog.tsx index 9b30e686fe..ed5b2eacf6 100644 --- a/src/renderer/components/delete-cluster-dialog/delete-cluster-dialog.tsx +++ b/src/renderer/components/delete-cluster-dialog/delete-cluster-dialog.tsx @@ -9,53 +9,37 @@ import { observer } from "mobx-react"; import React from "react"; import { Button } from "../button"; -import type { KubeConfig } from "@kubernetes/client-node"; -import type { Cluster } from "../../../common/cluster/cluster"; import { saveKubeconfig } from "./save-config"; import { requestMain } from "../../../common/ipc"; import { clusterClearDeletingHandler, clusterDeleteHandler, clusterSetDeletingHandler } from "../../../common/cluster-ipc"; import { Notifications } from "../notifications"; -import { HotbarStore } from "../../../common/hotbar-store"; import { boundMethod } from "autobind-decorator"; import { Dialog } from "../dialog"; import { Icon } from "../icon"; import { Select } from "../select"; import { Checkbox } from "../checkbox"; +import { withInjectables } from "@ogre-tools/injectable-react"; +import removeAllHotbarItemsInjectable from "../../../common/hotbar-store/remove-all-hotbar-items.injectable"; +import type { DeleteClusterDialogState } from "./state.injectable"; +import deleteClusterDialogStateInjectable from "./state.injectable"; +import closeDeleteClusterDialogInjectable from "./close-delete-cluster-dialog.injectable"; -type DialogState = { - isOpen: boolean, - config?: KubeConfig, - cluster?: Cluster -}; - -const dialogState: DialogState = observable({ - isOpen: false, -}); - -type Props = {}; +interface Dependencies { + removeAllHotbarItems: (id: string) => void; + readonly state: DeleteClusterDialogState; + closeDeleteClusterDialog: () => void; +} @observer -export class DeleteClusterDialog extends React.Component { +class NonInjectedDeleteClusterDialog extends React.Component { @observable showContextSwitch = false; @observable newCurrentContext = ""; - constructor(props: Props) { + constructor(props: Dependencies) { super(props); makeObservable(this); } - static open({ config, cluster }: Partial) { - dialogState.isOpen = true; - dialogState.config = config; - dialogState.cluster = cluster; - } - - static close() { - dialogState.isOpen = false; - dialogState.cluster = null; - dialogState.config = null; - } - @boundMethod onOpen() { this.newCurrentContext = ""; @@ -67,25 +51,25 @@ export class DeleteClusterDialog extends React.Component { @boundMethod onClose() { - DeleteClusterDialog.close(); + this.props.closeDeleteClusterDialog(); this.showContextSwitch = false; } removeContext() { - dialogState.config.contexts = dialogState.config.contexts.filter(item => - item.name !== dialogState.cluster.contextName, + this.props.state.config.contexts = this.props.state.config.contexts.filter(item => + item.name !== this.props.state.cluster.contextName, ); } changeCurrentContext() { if (this.newCurrentContext && this.showContextSwitch) { - dialogState.config.currentContext = this.newCurrentContext; + this.props.state.config.currentContext = this.newCurrentContext; } } @boundMethod async onDelete() { - const { cluster, config } = dialogState; + const { cluster, config } = this.props.state; await requestMain(clusterSetDeletingHandler, cluster.id); this.removeContext(); @@ -93,7 +77,7 @@ export class DeleteClusterDialog extends React.Component { try { await saveKubeconfig(config, cluster.kubeConfigPath); - HotbarStore.getInstance().removeAllHotbarItems(cluster.id); + this.props.removeAllHotbarItems(cluster.id); await requestMain(clusterDeleteHandler, cluster.id); } catch(error) { Notifications.error(`Cannot remove cluster, failed to process config file. ${error}`); @@ -105,7 +89,7 @@ export class DeleteClusterDialog extends React.Component { } @computed get disableDelete() { - const { cluster, config } = dialogState; + const { cluster, config } = this.props.state; const noContextsAvailable = config.contexts.filter(context => context.name !== cluster.contextName).length == 0; const newContextNotSelected = this.newCurrentContext === ""; @@ -117,12 +101,12 @@ export class DeleteClusterDialog extends React.Component { } isCurrentContext() { - return dialogState.config.currentContext == dialogState.cluster.contextName; + return this.props.state.config.currentContext == this.props.state.cluster.contextName; } renderCurrentContextSwitch() { if (!this.showContextSwitch) return null; - const { cluster, config } = dialogState; + const { cluster, config } = this.props.state; const contexts = config.contexts.filter(context => context.name !== cluster.contextName); const options = [ @@ -146,7 +130,7 @@ export class DeleteClusterDialog extends React.Component { } renderDeleteMessage() { - const { cluster } = dialogState; + const { cluster } = this.props.state; if (cluster.isInLocalKubeconfig()) { return ( @@ -164,7 +148,7 @@ export class DeleteClusterDialog extends React.Component { } getWarningMessage() { - const { cluster, config } = dialogState; + const { cluster, config } = this.props.state; const contexts = config.contexts.filter(context => context.name !== cluster.contextName); if (!contexts.length) { @@ -206,9 +190,8 @@ export class DeleteClusterDialog extends React.Component { } render() { - const { cluster, config, isOpen } = dialogState; - - if (!cluster || !config) return null; + const { cluster, config } = this.props.state; + const isOpen = Boolean(cluster && config); const contexts = config.contexts.filter(context => context.name !== cluster.contextName); @@ -258,3 +241,12 @@ export class DeleteClusterDialog extends React.Component { ); } } + +export const DeleteClusterDialog = withInjectables(NonInjectedDeleteClusterDialog, { + getProps: (di, props) => ({ + removeAllHotbarItems: di.inject(removeAllHotbarItemsInjectable), + state: di.inject(deleteClusterDialogStateInjectable), + closeDeleteClusterDialog: di.inject(closeDeleteClusterDialogInjectable), + ...props, + }), +}); diff --git a/src/renderer/components/delete-cluster-dialog/open-delete-cluster-dialog.injectable.ts b/src/renderer/components/delete-cluster-dialog/open-delete-cluster-dialog.injectable.ts new file mode 100644 index 0000000000..3d9c1d07a0 --- /dev/null +++ b/src/renderer/components/delete-cluster-dialog/open-delete-cluster-dialog.injectable.ts @@ -0,0 +1,28 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import type { KubeConfig } from "@kubernetes/client-node"; +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { action } from "mobx"; +import type { Cluster } from "../../../common/cluster/cluster"; +import { bind } from "../../utils"; +import deleteClusterDialogStateInjectable, { DeleteClusterDialogState } from "./state.injectable"; + +interface Dependencies { + state: DeleteClusterDialogState; +} + +const openDeleteClusterDialog = action(({ state }: Dependencies, cluster: Cluster, config: KubeConfig): void => { + state.cluster = cluster; + state.config = config; +}); + +const openDeleteClusterDialogInjectable = getInjectable({ + instantiate: (di) => bind(openDeleteClusterDialog, null, { + state: di.inject(deleteClusterDialogStateInjectable), + }), + lifecycle: lifecycleEnum.singleton, +}); + +export default openDeleteClusterDialogInjectable; diff --git a/src/renderer/components/delete-cluster-dialog/state.injectable.ts b/src/renderer/components/delete-cluster-dialog/state.injectable.ts new file mode 100644 index 0000000000..858500882d --- /dev/null +++ b/src/renderer/components/delete-cluster-dialog/state.injectable.ts @@ -0,0 +1,20 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import type { KubeConfig } from "@kubernetes/client-node"; +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { observable } from "mobx"; +import type { Cluster } from "../../../common/cluster/cluster"; + +export interface DeleteClusterDialogState { + config?: KubeConfig; + cluster?: Cluster; +} + +const deleteClusterDialogStateInjectable = getInjectable({ + instantiate: () => observable.object({}), + lifecycle: lifecycleEnum.singleton, +}); + +export default deleteClusterDialogStateInjectable; diff --git a/src/renderer/components/dialog/dialog.tsx b/src/renderer/components/dialog/dialog.tsx index f339ec973e..15360f491a 100644 --- a/src/renderer/components/dialog/dialog.tsx +++ b/src/renderer/components/dialog/dialog.tsx @@ -17,7 +17,7 @@ import { navigation } from "../../navigation"; export interface DialogProps { className?: string; - isOpen?: boolean; + isOpen: boolean; open?: () => void; close?: () => void; onOpen?: () => void; @@ -37,7 +37,7 @@ export class Dialog extends React.PureComponent { private contentElem: HTMLElement; ref = React.createRef(); - static defaultProps: DialogProps = { + static defaultProps = { isOpen: false, open: noop, close: noop, @@ -46,7 +46,7 @@ export class Dialog extends React.PureComponent { modal: true, animated: true, pinned: false, - }; + } as object; @disposeOnUnmount closeOnNavigate = reaction(() => navigation.toString(), () => this.close()); diff --git a/src/renderer/components/dialog/logs-dialog.tsx b/src/renderer/components/dialog/logs-dialog.tsx index 57abcf6618..b527c8d3f8 100644 --- a/src/renderer/components/dialog/logs-dialog.tsx +++ b/src/renderer/components/dialog/logs-dialog.tsx @@ -43,7 +43,10 @@ export class LogsDialog extends React.Component { ); return ( - + this.logsElem = e}> diff --git a/src/renderer/components/dock/__test__/dock-tabs.test.tsx b/src/renderer/components/dock/__test__/dock-tabs.test.tsx index 705680317f..8051a47f95 100644 --- a/src/renderer/components/dock/__test__/dock-tabs.test.tsx +++ b/src/renderer/components/dock/__test__/dock-tabs.test.tsx @@ -6,199 +6,213 @@ import React from "react"; import { fireEvent } from "@testing-library/react"; import "@testing-library/jest-dom/extend-expect"; -import fse from "fs-extra"; -import { DockTabs } from "../dock-tabs"; -import { DockStore, DockTab, TabKind } from "../dock-store/dock.store"; +import { DockTabs } from "../dock-tab/dock-tabs"; +import { TabKind } from "../dock/store"; import { noop } from "../../../utils"; -import { ThemeStore } from "../../../theme.store"; -import { UserStore } from "../../../../common/user-store"; +import type { ConfigurableDependencyInjectionContainer } from "@ogre-tools/injectable"; import { getDiForUnitTesting } from "../../../getDiForUnitTesting"; -import dockStoreInjectable from "../dock-store/dock-store.injectable"; -import type { DiRender } from "../../test-utils/renderFor"; -import { renderFor } from "../../test-utils/renderFor"; -import directoryForUserDataInjectable - from "../../../../common/app-paths/directory-for-user-data/directory-for-user-data.injectable"; +import { type DiRender, renderFor } from "../../test-utils/renderFor"; +import { observable } from "mobx"; +import closeDockTabInjectable from "../dock/close-tab.injectable"; +import isTerminalDisconnectedInjectable from "../terminal/is-disconnected.injectable"; +import reconnectTerminalInjectable from "../terminal/reconnect.injectable"; +import closeAllDockTabsInjectable from "../dock/close-all-tabs.injectable"; +import closeOtherDockTabsInjectable from "../dock/close-other-tabs.injectable"; +import closeDockTabsToTheRightInjectable from "../dock/close-tabs-right.injectable"; -jest.mock("electron", () => ({ - app: { - getVersion: () => "99.99.99", - getName: () => "lens", - setName: jest.fn(), - setPath: jest.fn(), - getPath: () => "tmp", - getLocale: () => "en", - setLoginItemSettings: jest.fn(), - }, - ipcMain: { - on: jest.fn(), - handle: jest.fn(), - }, -})); - -const initialTabs: DockTab[] = [ - { id: "terminal", kind: TabKind.TERMINAL, title: "Terminal", pinned: false }, - { id: "create", kind: TabKind.CREATE_RESOURCE, title: "Create resource", pinned: false }, - { id: "edit", kind: TabKind.EDIT_RESOURCE, title: "Edit resource", pinned: false }, - { id: "install", kind: TabKind.INSTALL_CHART, title: "Install chart", pinned: false }, - { id: "logs", kind: TabKind.POD_LOGS, title: "Logs", pinned: false }, -]; - -const getComponent = (dockStore: DockStore) => ( - -); - -const getTabKinds = (dockStore: DockStore) => dockStore.tabs.map((tab) => tab.kind); describe("", () => { - let dockStore: DockStore; let render: DiRender; + let di: ConfigurableDependencyInjectionContainer; + let closeDockTab: jest.Mock; + let closeAllDockTabs: jest.Mock; + let closeOtherDockTabs: jest.Mock; + let closeDockTabsToTheRight: jest.Mock; - beforeEach(async () => { - const di = getDiForUnitTesting({ doGeneralOverrides: true }); - - + beforeEach(() => { + di = getDiForUnitTesting(); render = renderFor(di); + closeDockTab = jest.fn(); + closeOtherDockTabs = jest.fn(); + closeAllDockTabs = jest.fn(); + closeDockTabsToTheRight = jest.fn(); - di.override( - directoryForUserDataInjectable, - () => "some-test-suite-specific-directory-for-user-data", + di.override(closeDockTabInjectable, () => closeDockTab); + di.override(closeAllDockTabsInjectable, () => closeAllDockTabs); + di.override(closeOtherDockTabsInjectable, () => closeOtherDockTabs); + di.override(closeDockTabsToTheRightInjectable, () => closeDockTabsToTheRight); + di.override(isTerminalDisconnectedInjectable, () => () => false); + di.override(reconnectTerminalInjectable, () => noop); + }); + + it("renders all 5 tabs", () => { + const tabs = observable.array([ + { id: "terminal", kind: TabKind.TERMINAL, title: "Terminal", pinned: false }, + { id: "create", kind: TabKind.CREATE_RESOURCE, title: "Create resource", pinned: false }, + { id: "edit", kind: TabKind.EDIT_RESOURCE, title: "Edit resource", pinned: false }, + { id: "install", kind: TabKind.INSTALL_CHART, title: "Install chart", pinned: false }, + { id: "logs", kind: TabKind.POD_LOGS, title: "Logs", pinned: false }, + ]); + const { container } = render( + , ); - await di.runSetups(); - - dockStore = di.inject(dockStoreInjectable); - - UserStore.createInstance(); - ThemeStore.createInstance(); - await dockStore.whenReady; - dockStore.tabs = initialTabs; - }); - - afterEach(() => { - ThemeStore.resetInstance(); - UserStore.resetInstance(); - - // TODO: A unit test may not cause side effects. Here accessing file system is a side effect. - fse.remove("some-test-suite-specific-directory-for-user-data"); - }); - - it("renders w/o errors", () => { - const { container } = render(getComponent(dockStore)); - - expect(container).toBeInstanceOf(HTMLElement); - }); - - it("has 6 tabs (1 tab is initial terminal)", () => { - const { container } = render(getComponent(dockStore)); - const tabs = container.querySelectorAll(".Tab"); - - expect(tabs.length).toBe(initialTabs.length); + expect(container.querySelectorAll(".Tab").length).toBe(5); }); it("opens a context menu", () => { - const { container, getByText } = render(getComponent(dockStore)); - const tab = container.querySelector(".Tab"); + const tabs = observable.array([ + { id: "terminal", kind: TabKind.TERMINAL, title: "Terminal", pinned: false }, + { id: "create", kind: TabKind.CREATE_RESOURCE, title: "Create resource", pinned: false }, + { id: "edit", kind: TabKind.EDIT_RESOURCE, title: "Edit resource", pinned: false }, + { id: "install", kind: TabKind.INSTALL_CHART, title: "Install chart", pinned: false }, + { id: "logs", kind: TabKind.POD_LOGS, title: "Logs", pinned: false }, + ]); + const { container, getByText } = render( + , + ); + + fireEvent.contextMenu(container.querySelector(".Tab")); - fireEvent.contextMenu(tab); expect(getByText("Close all tabs")).toBeInTheDocument(); }); - it("closes selected tab", () => { - const { container, getByText, rerender } = render( - getComponent(dockStore), + it("calls closeDockTab()", () => { + const tabs = observable.array([ + { id: "terminal", kind: TabKind.TERMINAL, title: "Terminal", pinned: false }, + { id: "create", kind: TabKind.CREATE_RESOURCE, title: "Create resource", pinned: false }, + { id: "edit", kind: TabKind.EDIT_RESOURCE, title: "Edit resource", pinned: false }, + { id: "install", kind: TabKind.INSTALL_CHART, title: "Install chart", pinned: false }, + { id: "logs", kind: TabKind.POD_LOGS, title: "Logs", pinned: false }, + ]); + const { container, getByText } = render( + , ); - const tab = container.querySelector(".Tab"); - - fireEvent.contextMenu(tab); + fireEvent.contextMenu(container.querySelector(".Tab")); fireEvent.click(getByText("Close")); - rerender(getComponent(dockStore)); + expect(closeDockTab).toBeCalledTimes(1); + }); - const tabs = container.querySelectorAll(".Tab"); - - expect(tabs.length).toBe(initialTabs.length - 1); - - expect(getTabKinds(dockStore)).toEqual([ - TabKind.CREATE_RESOURCE, - TabKind.EDIT_RESOURCE, - TabKind.INSTALL_CHART, - TabKind.POD_LOGS, + it("calls closeOtherDockTabs()", () => { + const tabs = observable.array([ + { id: "terminal", kind: TabKind.TERMINAL, title: "Terminal", pinned: false }, + { id: "create", kind: TabKind.CREATE_RESOURCE, title: "Create resource", pinned: false }, + { id: "edit", kind: TabKind.EDIT_RESOURCE, title: "Edit resource", pinned: false }, + { id: "install", kind: TabKind.INSTALL_CHART, title: "Install chart", pinned: false }, + { id: "logs", kind: TabKind.POD_LOGS, title: "Logs", pinned: false }, ]); - }); - - it("closes other tabs", () => { - const { container, getByText, rerender } = render(getComponent(dockStore)); - const tab = container.querySelectorAll(".Tab")[3]; - - fireEvent.contextMenu(tab); - fireEvent.click(getByText("Close other tabs")); - rerender(getComponent(dockStore)); - - const tabs = container.querySelectorAll(".Tab"); - - expect(tabs.length).toBe(1); - expect(getTabKinds(dockStore)).toEqual([initialTabs[3].kind]); - }); - - it("closes all tabs", () => { - const { container, getByText, rerender } = render(getComponent(dockStore)); - const tab = container.querySelector(".Tab"); - - fireEvent.contextMenu(tab); - const command = getByText("Close all tabs"); - - fireEvent.click(command); - rerender(getComponent(dockStore)); - const tabs = container.querySelectorAll(".Tab"); - - expect(tabs.length).toBe(0); - }); - - it("closes tabs to the right", () => { - const { container, getByText, rerender } = render(getComponent(dockStore)); - const tab = container.querySelectorAll(".Tab")[3]; // 4th of 5 - - fireEvent.contextMenu(tab); - fireEvent.click(getByText("Close tabs to the right")); - rerender(getComponent(dockStore)); - - expect(getTabKinds(dockStore)).toEqual( - initialTabs.slice(0, 4).map(tab => tab.kind), + const { container, getByText } = render( + , ); + + fireEvent.contextMenu(container.querySelectorAll(".Tab")[3]); + fireEvent.click(getByText("Close other tabs")); + + expect(closeOtherDockTabs).toBeCalledTimes(1); }); - it("disables 'Close All' & 'Close Other' items if only 1 tab available", () => { - dockStore.tabs = [{ - id: "terminal", kind: TabKind.TERMINAL, title: "Terminal", pinned: false, - }]; - const { container, getByText } = render(getComponent(dockStore)); - const tab = container.querySelector(".Tab"); + it("calls closeAllDockTabs()", () => { + const tabs = observable.array([ + { id: "terminal", kind: TabKind.TERMINAL, title: "Terminal", pinned: false }, + { id: "create", kind: TabKind.CREATE_RESOURCE, title: "Create resource", pinned: false }, + { id: "edit", kind: TabKind.EDIT_RESOURCE, title: "Edit resource", pinned: false }, + { id: "install", kind: TabKind.INSTALL_CHART, title: "Install chart", pinned: false }, + { id: "logs", kind: TabKind.POD_LOGS, title: "Logs", pinned: false }, + ]); + const { container, getByText } = render( + , + ); - fireEvent.contextMenu(tab); - const closeAll = getByText("Close all tabs"); - const closeOthers = getByText("Close other tabs"); + fireEvent.contextMenu(container.querySelector(".Tab")); + fireEvent.click(getByText("Close all tabs")); - expect(closeAll).toHaveClass("disabled"); - expect(closeOthers).toHaveClass("disabled"); + expect(closeAllDockTabs).toBeCalledTimes(1); + }); + + it("calls closeDockTabsToTheRight()", () => { + const tabs = observable.array([ + { id: "terminal", kind: TabKind.TERMINAL, title: "Terminal", pinned: false }, + { id: "create", kind: TabKind.CREATE_RESOURCE, title: "Create resource", pinned: false }, + { id: "edit", kind: TabKind.EDIT_RESOURCE, title: "Edit resource", pinned: false }, + { id: "install", kind: TabKind.INSTALL_CHART, title: "Install chart", pinned: false }, + { id: "logs", kind: TabKind.POD_LOGS, title: "Logs", pinned: false }, + ]); + const { container, getByText } = render( + , + ); + + fireEvent.contextMenu(container.querySelectorAll(".Tab")[3]); + fireEvent.click(getByText("Close tabs to the right")); + + expect(closeDockTabsToTheRight).toBeCalledTimes(1); + }); + + it("disables 'Close Other' items if only 1 tab available", () => { + const tabs = observable.array([ + { id: "terminal", kind: TabKind.TERMINAL, title: "Terminal", pinned: false }, + ]); + const { container, getByText } = render( + , + ); + + fireEvent.contextMenu(container.querySelector(".Tab")); + + expect(getByText("Close other tabs")).toHaveClass("disabled"); }); it("disables 'Close To The Right' item if last tab clicked", () => { - dockStore.tabs = [ + const tabs = observable.array([ { id: "terminal", kind: TabKind.TERMINAL, title: "Terminal", pinned: false }, { id: "logs", kind: TabKind.POD_LOGS, title: "Pod Logs", pinned: false }, - ]; - const { container, getByText } = render(getComponent(dockStore)); - const tab = container.querySelectorAll(".Tab")[1]; + ]); + const { container, getByText } = render( + , + ); - fireEvent.contextMenu(tab); - const command = getByText("Close tabs to the right"); + fireEvent.contextMenu(container.querySelectorAll(".Tab")[1]); - expect(command).toHaveClass("disabled"); + expect(getByText("Close tabs to the right")).toHaveClass("disabled"); }); }); 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 3521ef4ad5..edcaee4e39 100644 --- a/src/renderer/components/dock/__test__/log-resource-selector.test.tsx +++ b/src/renderer/components/dock/__test__/log-resource-selector.test.tsx @@ -6,18 +6,19 @@ import React from "react"; import "@testing-library/jest-dom/extend-expect"; import * as selectEvent from "react-select-event"; -import { Pod } from "../../../../common/k8s-api/endpoints"; -import { LogResourceSelector } from "../log-resource-selector"; -import type { LogTabData } from "../log-tab-store/log-tab.store"; + +import { Pod, PodApi } from "../../../../common/k8s-api/endpoints"; +import { LogResourceSelector } from "../logs/log-resource-selector"; +import type { LogTabData } from "../log-tab/store"; import { dockerPod, deploymentPod1 } from "./pod.mock"; -import { ThemeStore } from "../../../theme.store"; -import { UserStore } from "../../../../common/user-store"; import mockFs from "mock-fs"; +import type { ConfigurableDependencyInjectionContainer } from "@ogre-tools/injectable"; import { getDiForUnitTesting } from "../../../getDiForUnitTesting"; -import type { DiRender } from "../../test-utils/renderFor"; -import { renderFor } from "../../test-utils/renderFor"; -import directoryForUserDataInjectable from "../../../../common/app-paths/directory-for-user-data/directory-for-user-data.injectable"; -import callForLogsInjectable from "../log-store/call-for-logs/call-for-logs.injectable"; +import { type DiRender, renderFor } from "../../test-utils/renderFor"; +import podStoreInjectable from "../../+pods/store.injectable"; +import { PodStore } from "../../+pods/store"; +import type { TabId } from "../dock/store"; +import logTabManagerInjectable from "../logs/log-tab-manager.injectable"; jest.mock("electron", () => ({ app: { @@ -67,29 +68,29 @@ const getFewPodsTabData = (): LogTabData => { }; describe("", () => { + let di: ConfigurableDependencyInjectionContainer; let render: DiRender; + let renameTab: jest.Mock<(tabId: TabId) => void>; - beforeEach(async () => { - const di = getDiForUnitTesting({ doGeneralOverrides: true }); - - di.override(directoryForUserDataInjectable, () => "some-directory-for-user-data"); - di.override(callForLogsInjectable, () => () => Promise.resolve("some-logs")); - + beforeEach(() => { + di = getDiForUnitTesting(); render = renderFor(di); - await di.runSetups(); + renameTab = jest.fn(); + di.override(podStoreInjectable, () => new PodStore(new PodApi())); + di.override(logTabManagerInjectable, () => ({ + renameTab, + })); + }); + + beforeEach(() => { mockFs({ "tmp": {}, }); - - UserStore.createInstance(); - ThemeStore.createInstance(); }); afterEach(() => { - UserStore.resetInstance(); - ThemeStore.resetInstance(); mockFs.restore(); }); 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 7d9aab1e01..581fdcdc80 100644 --- a/src/renderer/components/dock/__test__/log-tab.store.test.ts +++ b/src/renderer/components/dock/__test__/log-tab.store.test.ts @@ -3,63 +3,62 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import { podsStore } from "../../+workloads-pods/pods.store"; -import { UserStore } from "../../../../common/user-store"; -import { Pod } from "../../../../common/k8s-api/endpoints"; -import { ThemeStore } from "../../../theme.store"; +import type { PodStore } from "../../+pods/store"; import { deploymentPod1, deploymentPod2, deploymentPod3, dockerPod } from "./pod.mock"; -import { mockWindow } from "../../../../../__mocks__/windowMock"; +import type { LogTabStore } from "../logs/tab-store"; +import type { ConfigurableDependencyInjectionContainer } from "@ogre-tools/injectable"; import { getDiForUnitTesting } from "../../../getDiForUnitTesting"; -import logTabStoreInjectable from "../log-tab-store/log-tab-store.injectable"; -import type { LogTabStore } from "../log-tab-store/log-tab.store"; -import dockStoreInjectable from "../dock-store/dock-store.injectable"; -import type { DockStore } from "../dock-store/dock.store"; -import directoryForUserDataInjectable - from "../../../../common/app-paths/directory-for-user-data/directory-for-user-data.injectable"; -import mockFs from "mock-fs"; - -mockWindow(); - -podsStore.items.push(new Pod(dockerPod)); -podsStore.items.push(new Pod(deploymentPod1)); -podsStore.items.push(new Pod(deploymentPod2)); +import logTabStoreInjectable from "../logs/tab-store.injectable"; +import podStoreInjectable from "../../+pods/store.injectable"; +import type { DockTabCreate, DockTabData, TabId } from "../dock/store"; +import createDockTabInjectable from "../dock/create-tab.injectable"; +import closeDockTabInjectable from "../dock/close-tab.injectable"; +import renameDockTabInjectable from "../rename-tab.injectable"; +import logTabStorageInjectable from "../logs/tab-storage.injectable"; +import createPodLogsTabInjectable, { PodLogsTabData } from "../logs/create-pod-tab.injectable"; describe("log tab store", () => { + let di: ConfigurableDependencyInjectionContainer; let logTabStore: LogTabStore; - let dockStore: DockStore; + let podStore: PodStore; + let renameDockTab: jest.Mock; + let createDockTab: jest.Mock; + let closeDockTab: jest.Mock; + let createPodLogsTab: (data: PodLogsTabData) => TabId; - beforeEach(async () => { - const di = getDiForUnitTesting({ doGeneralOverrides: true }); + beforeEach(() => { + di = getDiForUnitTesting(); - mockFs(); + renameDockTab = jest.fn(); + createDockTab = jest.fn(); + closeDockTab = jest.fn(); - di.override(directoryForUserDataInjectable, () => "some-directory-for-user-data"); + di.override(renameDockTabInjectable, () => renameDockTab); + di.override(createDockTabInjectable, () => createDockTab); + di.override(closeDockTabInjectable, () => closeDockTab); + di.override(logTabStorageInjectable, () => undefined); - await di.runSetups(); - - dockStore = di.inject(dockStoreInjectable); logTabStore = di.inject(logTabStoreInjectable); + podStore = di.inject(podStoreInjectable); + createPodLogsTab = di.inject(createPodLogsTabInjectable); - UserStore.createInstance(); - ThemeStore.createInstance(); - }); - - afterEach(() => { - UserStore.resetInstance(); - ThemeStore.resetInstance(); - mockFs.restore(); + podStore.items.replace([ + dockerPod, + deploymentPod1, + deploymentPod2, + ]); }); it("creates log tab without sibling pods", () => { - const selectedPod = new Pod(dockerPod); + const selectedPod = dockerPod; const selectedContainer = selectedPod.getAllContainers()[0]; - logTabStore.createPodTab({ + const id = createPodLogsTab({ selectedPod, selectedContainer, }); - expect(logTabStore.getData(dockStore.selectedTabId)).toEqual({ + expect(logTabStore.getData(id)).toEqual({ pods: [selectedPod], selectedPod, selectedContainer, @@ -69,16 +68,16 @@ describe("log tab store", () => { }); it("creates log tab with sibling pods", () => { - const selectedPod = new Pod(deploymentPod1); - const siblingPod = new Pod(deploymentPod2); + const selectedPod = deploymentPod1; + const siblingPod = deploymentPod2; const selectedContainer = selectedPod.getInitContainers()[0]; - logTabStore.createPodTab({ + const id = createPodLogsTab({ selectedPod, selectedContainer, }); - expect(logTabStore.getData(dockStore.selectedTabId)).toEqual({ + expect(logTabStore.getData(id)).toEqual({ pods: [selectedPod, siblingPod], selectedPod, selectedContainer, @@ -88,17 +87,17 @@ describe("log tab store", () => { }); it("removes item from pods list if pod deleted from store", () => { - const selectedPod = new Pod(deploymentPod1); + const selectedPod = deploymentPod1; const selectedContainer = selectedPod.getInitContainers()[0]; - logTabStore.createPodTab({ + const id = createPodLogsTab({ selectedPod, selectedContainer, }); - podsStore.items.pop(); + podStore.items.pop(); - expect(logTabStore.getData(dockStore.selectedTabId)).toEqual({ + expect(logTabStore.getData(id)).toEqual({ pods: [selectedPod], selectedPod, selectedContainer, @@ -108,39 +107,22 @@ describe("log tab store", () => { }); it("adds item into pods list if new sibling pod added to store", () => { - const selectedPod = new Pod(deploymentPod1); - const selectedContainer = selectedPod.getInitContainers()[0]; + const selectedPod = deploymentPod1; + const selectedContainer = deploymentPod1.getInitContainers()[0]; - logTabStore.createPodTab({ + const id = createPodLogsTab({ selectedPod, selectedContainer, }); - podsStore.items.push(new Pod(deploymentPod3)); + podStore.items.push(deploymentPod3); - expect(logTabStore.getData(dockStore.selectedTabId)).toEqual({ - pods: [selectedPod, deploymentPod3], + expect(logTabStore.getData(id)).toEqual({ + pods: [selectedPod, deploymentPod2, deploymentPod3], selectedPod, selectedContainer, showTimestamps: false, previous: false, }); }); - - // FIXME: this is failed when it's not .only == depends on something above - it.only("closes tab if no pods left in store", async () => { - const selectedPod = new Pod(deploymentPod1); - const selectedContainer = selectedPod.getInitContainers()[0]; - - const id = logTabStore.createPodTab({ - selectedPod, - selectedContainer, - }); - - podsStore.items.clear(); - - expect(logTabStore.getData(dockStore.selectedTabId)).toBeUndefined(); - expect(logTabStore.getData(id)).toBeUndefined(); - expect(dockStore.getTabById(id)).toBeUndefined(); - }); }); diff --git a/src/renderer/components/dock/__test__/pod.mock.ts b/src/renderer/components/dock/__test__/pod.mock.ts index a74c7d5572..26c1ba0e38 100644 --- a/src/renderer/components/dock/__test__/pod.mock.ts +++ b/src/renderer/components/dock/__test__/pod.mock.ts @@ -3,7 +3,9 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -export const dockerPod = { +import { Pod } from "../../../../common/k8s-api/endpoints"; + +export const dockerPod = new Pod({ apiVersion: "v1", kind: "dummy", metadata: { @@ -12,9 +14,10 @@ export const dockerPod = { creationTimestamp: "dummy", resourceVersion: "dummy", namespace: "default", + selfLink: "", }, spec: { - initContainers: [] as any, + initContainers: [], containers: [ { name: "docker-exporter", @@ -37,9 +40,9 @@ export const dockerPod = { podIP: "dummy", startTime: "dummy", }, -}; +}); -export const deploymentPod1 = { +export const deploymentPod1 = new Pod({ apiVersion: "v1", kind: "dummy", metadata: { @@ -56,6 +59,7 @@ export const deploymentPod1 = { controller: true, blockOwnerDeletion: true, }], + selfLink: "", }, spec: { initContainers: [ @@ -97,9 +101,9 @@ export const deploymentPod1 = { podIP: "dummy", startTime: "dummy", }, -}; +}); -export const deploymentPod2 = { +export const deploymentPod2 = new Pod({ apiVersion: "v1", kind: "dummy", metadata: { @@ -116,6 +120,7 @@ export const deploymentPod2 = { controller: true, blockOwnerDeletion: true, }], + selfLink: "", }, spec: { initContainers: [ @@ -157,9 +162,9 @@ export const deploymentPod2 = { podIP: "dummy", startTime: "dummy", }, -}; +}); -export const deploymentPod3 = { +export const deploymentPod3 = new Pod({ apiVersion: "v1", kind: "dummy", metadata: { @@ -176,8 +181,10 @@ export const deploymentPod3 = { controller: true, blockOwnerDeletion: true, }], + selfLink: "", }, spec: { + initContainers: [], containers: [ { name: "node-exporter", @@ -205,4 +212,4 @@ export const deploymentPod3 = { podIP: "dummy", startTime: "dummy", }, -}; +}); diff --git a/src/renderer/components/dock/__test__/to-bottom.test.tsx b/src/renderer/components/dock/__test__/to-bottom.test.tsx index fd5858a3c3..8ba43bb2ab 100644 --- a/src/renderer/components/dock/__test__/to-bottom.test.tsx +++ b/src/renderer/components/dock/__test__/to-bottom.test.tsx @@ -5,7 +5,7 @@ import React from "react"; import "@testing-library/jest-dom/extend-expect"; import { fireEvent, render } from "@testing-library/react"; -import { ToBottom } from "../to-bottom"; +import { ToBottom } from "../logs/to-bottom"; import { noop } from "../../../utils"; describe("", () => { diff --git a/src/renderer/components/dock/create-install-chart-tab/create-install-chart-tab.injectable.ts b/src/renderer/components/dock/create-install-chart-tab/create-install-chart-tab.injectable.ts deleted file mode 100644 index 8544cff719..0000000000 --- a/src/renderer/components/dock/create-install-chart-tab/create-install-chart-tab.injectable.ts +++ /dev/null @@ -1,19 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ -import { createInstallChartTab } from "./create-install-chart-tab"; -import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; -import installChartStoreInjectable from "../install-chart-store/install-chart-store.injectable"; -import dockStoreInjectable from "../dock-store/dock-store.injectable"; - -const createInstallChartTabInjectable = getInjectable({ - instantiate: (di) => createInstallChartTab({ - installChartStore: di.inject(installChartStoreInjectable), - createDockTab: di.inject(dockStoreInjectable).createTab, - }), - - lifecycle: lifecycleEnum.singleton, -}); - -export default createInstallChartTabInjectable; diff --git a/src/renderer/components/dock/create-install-chart-tab/create-install-chart-tab.ts b/src/renderer/components/dock/create-install-chart-tab/create-install-chart-tab.ts deleted file mode 100644 index d4f6aba833..0000000000 --- a/src/renderer/components/dock/create-install-chart-tab/create-install-chart-tab.ts +++ /dev/null @@ -1,44 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ -import type { HelmChart } from "../../../../common/k8s-api/endpoints/helm-charts.api"; -import { - DockTab, - DockTabCreate, - DockTabCreateSpecific, - TabKind, -} from "../dock-store/dock.store"; - -import type { InstallChartStore } from "../install-chart-store/install-chart.store"; - -interface Dependencies { - createDockTab: (rawTab: DockTabCreate, addNumber: boolean) => DockTab; - installChartStore: InstallChartStore; -} - -export const createInstallChartTab = - ({ createDockTab, installChartStore }: Dependencies) => - (chart: HelmChart, tabParams: DockTabCreateSpecific = {}) => { - const { name, repo, version } = chart; - - const tab = createDockTab( - { - title: `Helm Install: ${repo}/${name}`, - ...tabParams, - kind: TabKind.INSTALL_CHART, - }, - false, - ); - - installChartStore.setData(tab.id, { - name, - repo, - version, - namespace: "default", - releaseName: "", - description: "", - }); - - return tab; - }; diff --git a/src/renderer/components/dock/create-resource-store/create-resource-store.injectable.ts b/src/renderer/components/dock/create-resource-store/create-resource-store.injectable.ts deleted file mode 100644 index 258b1469e7..0000000000 --- a/src/renderer/components/dock/create-resource-store/create-resource-store.injectable.ts +++ /dev/null @@ -1,19 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ -import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; -import dockStoreInjectable from "../dock-store/dock-store.injectable"; -import { CreateResourceStore } from "./create-resource.store"; -import createStorageInjectable from "../../../utils/create-storage/create-storage.injectable"; - -const createResourceStoreInjectable = getInjectable({ - instantiate: (di) => new CreateResourceStore({ - dockStore: di.inject(dockStoreInjectable), - createStorage: di.inject(createStorageInjectable), - }), - - lifecycle: lifecycleEnum.singleton, -}); - -export default createResourceStoreInjectable; diff --git a/src/renderer/components/dock/create-resource-store/create-resource.store.ts b/src/renderer/components/dock/create-resource-store/create-resource.store.ts deleted file mode 100644 index 7c0c62f30b..0000000000 --- a/src/renderer/components/dock/create-resource-store/create-resource.store.ts +++ /dev/null @@ -1,70 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import fs from "fs-extra"; -import path from "path"; -import os from "os"; -import groupBy from "lodash/groupBy"; -import filehound from "filehound"; -import { watch } from "chokidar"; -import { autoBind, StorageHelper } from "../../../utils"; -import { DockTabStorageState, DockTabStore } from "../dock-tab-store/dock-tab.store"; -import type { DockStore } from "../dock-store/dock.store"; - -interface Dependencies { - dockStore: DockStore, - createStorage: (storageKey: string, options: DockTabStorageState) => StorageHelper> -} - -export class CreateResourceStore extends DockTabStore { - constructor(protected dependencies: Dependencies) { - super(dependencies, { - storageKey: "create_resource", - }); - - autoBind(this); - fs.ensureDirSync(this.userTemplatesFolder); - } - - get lensTemplatesFolder():string { - return path.resolve(__static, "../templates/create-resource"); - } - - get userTemplatesFolder():string { - return path.join(os.homedir(), ".k8slens", "templates"); - } - - async getTemplates(templatesPath: string, defaultGroup: string) { - const templates = await filehound.create().path(templatesPath).ext(["yaml", "json"]).depth(1).find(); - - return templates ? this.groupTemplates(templates, templatesPath, defaultGroup) : {}; - } - - groupTemplates(templates: string[], templatesPath: string, defaultGroup: string) { - return groupBy(templates, (v:string) => - path.relative(templatesPath, v).split(path.sep).length>1 - ? path.parse(path.relative(templatesPath, v)).dir - : defaultGroup); - } - - async getMergedTemplates() { - const userTemplates = await this.getTemplates(this.userTemplatesFolder, "ungrouped"); - const lensTemplates = await this.getTemplates(this.lensTemplatesFolder, "lens"); - - return { ...userTemplates, ...lensTemplates }; - } - - async watchUserTemplates(callback: ()=> void){ - watch(this.userTemplatesFolder, { - depth: 1, - ignoreInitial: true, - awaitWriteFinish: { - stabilityThreshold: 500, - }, - }).on("all", () => { - callback(); - }); - } -} diff --git a/src/renderer/components/dock/create-resource-tab/create-resource-tab.injectable.ts b/src/renderer/components/dock/create-resource-tab/create-resource-tab.injectable.ts deleted file mode 100644 index daacd34c33..0000000000 --- a/src/renderer/components/dock/create-resource-tab/create-resource-tab.injectable.ts +++ /dev/null @@ -1,17 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ -import { createResourceTab } from "./create-resource-tab"; -import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; -import dockStoreInjectable from "../dock-store/dock-store.injectable"; - -const createResourceTabInjectable = getInjectable({ - instantiate: (di) => createResourceTab({ - dockStore: di.inject(dockStoreInjectable), - }), - - lifecycle: lifecycleEnum.singleton, -}); - -export default createResourceTabInjectable; diff --git a/src/renderer/components/dock/create-resource-tab/create-resource-tab.ts b/src/renderer/components/dock/create-resource-tab/create-resource-tab.ts deleted file mode 100644 index bea9dc05aa..0000000000 --- a/src/renderer/components/dock/create-resource-tab/create-resource-tab.ts +++ /dev/null @@ -1,18 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ -import { DockStore, DockTabCreateSpecific, TabKind } from "../dock-store/dock.store"; - -interface Dependencies { - dockStore: DockStore -} - -export const createResourceTab = - ({ dockStore }: Dependencies) => - (tabParams: DockTabCreateSpecific = {}) => - dockStore.createTab({ - title: "Create resource", - ...tabParams, - kind: TabKind.CREATE_RESOURCE, - }); diff --git a/src/renderer/components/dock/create-resource.tsx b/src/renderer/components/dock/create-resource.tsx deleted file mode 100644 index 2b6728ede6..0000000000 --- a/src/renderer/components/dock/create-resource.tsx +++ /dev/null @@ -1,184 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import "./create-resource.scss"; - -import React from "react"; -import path from "path"; -import fs from "fs-extra"; -import { GroupSelectOption, Select, SelectOption } from "../select"; -import yaml from "js-yaml"; -import { makeObservable, observable } from "mobx"; -import { observer } from "mobx-react"; -import type { CreateResourceStore } from "./create-resource-store/create-resource.store"; -import type { DockTab } from "./dock-store/dock.store"; -import { EditorPanel } from "./editor-panel"; -import { InfoPanel } from "./info-panel"; -import * as resourceApplierApi from "../../../common/k8s-api/endpoints/resource-applier.api"; -import { Notifications } from "../notifications"; -import logger from "../../../common/logger"; -import type { KubeJsonApiData } from "../../../common/k8s-api/kube-json-api"; -import { getDetailsUrl } from "../kube-detail-params"; -import { apiManager } from "../../../common/k8s-api/api-manager"; -import { prevDefault } from "../../utils"; -import { navigate } from "../../navigation"; -import { withInjectables } from "@ogre-tools/injectable-react"; -import createResourceStoreInjectable - from "./create-resource-store/create-resource-store.injectable"; - -interface Props { - tab: DockTab; -} - -interface Dependencies { - createResourceStore: CreateResourceStore -} - -@observer -class NonInjectedCreateResource extends React.Component { - @observable currentTemplates: Map = new Map(); - @observable error = ""; - @observable templates: GroupSelectOption[] = []; - - constructor(props: Props & Dependencies) { - super(props); - makeObservable(this); - } - - componentDidMount() { - this.props.createResourceStore.getMergedTemplates().then(v => this.updateGroupSelectOptions(v)); - this.props.createResourceStore.watchUserTemplates(() => this.props.createResourceStore.getMergedTemplates().then(v => this.updateGroupSelectOptions(v))); - } - - updateGroupSelectOptions(templates: Record) { - this.templates = Object.entries(templates) - .map(([name, grouping]) => this.convertToGroup(name, grouping)); - } - - convertToGroup(group: string, items: string[]): GroupSelectOption { - const options = items.map(v => ({ label: path.parse(v).name, value: v })); - - return { label: group, options }; - } - - get tabId() { - return this.props.tab.id; - } - - get data() { - return this.props.createResourceStore.getData(this.tabId); - } - - get currentTemplate() { - return this.currentTemplates.get(this.tabId) ?? null; - } - - onChange = (value: string) => { - this.error = ""; // reset first, validation goes later - this.props.createResourceStore.setData(this.tabId, value); - }; - - onError = (error: Error | string) => { - this.error = error.toString(); - }; - - onSelectTemplate = (item: SelectOption) => { - this.currentTemplates.set(this.tabId, item); - fs.readFile(item.value, "utf8").then(v => { - this.props.createResourceStore.setData(this.tabId, v); - }); - }; - - create = async (): Promise => { - if (this.error || !this.data.trim()) { - // do not save when field is empty or there is an error - return null; - } - - // skip empty documents - const resources = yaml.loadAll(this.data).filter(Boolean); - - if (resources.length === 0) { - return void logger.info("Nothing to create"); - } - - const creatingResources = resources.map(async (resource: string) => { - try { - const data: KubeJsonApiData = await resourceApplierApi.update(resource); - const { kind, apiVersion, metadata: { name, namespace }} = data; - const resourceLink = apiManager.lookupApiLink({ kind, apiVersion, name, namespace }); - - const showDetails = () => { - navigate(getDetailsUrl(resourceLink)); - close(); - }; - - const close = Notifications.ok( -

    - {kind} {name} successfully created. -

    , - ); - } catch (error) { - Notifications.error(error?.toString() ?? "Unknown error occured"); - } - }); - - await Promise.allSettled(creatingResources); - - return undefined; - }; - - renderControls() { - return ( -
    - onSelectTemplate(v)} + value={currentTemplate} + /> +
    + } + submit={create} + submitLabel="Create" + showNotifications={false} + /> + + + ); +}); + +export const CreateResource = withInjectables(NonInjectedCreateResource, { + getProps: (di, props) => ({ + apiManager: di.inject(apiManagerInjectable), + createResourceStore: di.inject(createResourceStoreInjectable), + ...props, + }), +}); diff --git a/src/renderer/components/dock/create-resource/has-correct-extension.ts b/src/renderer/components/dock/create-resource/has-correct-extension.ts new file mode 100644 index 0000000000..47768161d6 --- /dev/null +++ b/src/renderer/components/dock/create-resource/has-correct-extension.ts @@ -0,0 +1,18 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +const extensionMatchers = [ + /\.yaml$/, + /\.yml$/, + /\.json$/, +]; + +/** + * Check if a fileName matches a yaml or json file name structure + * @param fileName The fileName to check + */ +export function hasCorrectExtension(fileName: string): boolean { + return extensionMatchers.some(matcher => matcher.test(fileName)); +} diff --git a/src/renderer/components/dock/create-resource/lens-templates.injectable.ts b/src/renderer/components/dock/create-resource/lens-templates.injectable.ts new file mode 100644 index 0000000000..ed984c52e4 --- /dev/null +++ b/src/renderer/components/dock/create-resource/lens-templates.injectable.ts @@ -0,0 +1,48 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import path from "path"; +import { hasCorrectExtension } from "./has-correct-extension"; +import type { RawTemplates } from "./create-resource-templates.injectable"; +import "../../../../common/vars"; +import readFileInjectable from "../../../../common/fs/read-file.injectable"; +import readDirInjectable from "../../../../common/fs/read-dir.injectable"; + +interface Dependencies { + readDir: (dirPath: string) => Promise; + readFile: (filePath: string, encoding: "utf-8") => Promise; +} + +async function getTemplates({ readDir, readFile }: Dependencies) { + const templatesFolder = path.resolve(__static, "../templates/create-resource"); + + /** + * Mapping between file names and their contents + */ + const templates: [file: string, contents: string][] = []; + + for (const dirEntry of await readDir(templatesFolder)) { + if (hasCorrectExtension(dirEntry)) { + templates.push([path.parse(dirEntry).name, await readFile(path.join(templatesFolder, dirEntry), "utf-8")]); + } + } + + return templates; +} + +let lensTemplatePaths: RawTemplates; + +const lensCreateResourceTemplatesInjectable = getInjectable({ + setup: async (di) => { + lensTemplatePaths = ["lens", await getTemplates({ + readFile: di.inject(readFileInjectable), + readDir: di.inject(readDirInjectable), + })]; + }, + instantiate: () => lensTemplatePaths, + lifecycle: lifecycleEnum.singleton, +}); + +export default lensCreateResourceTemplatesInjectable; diff --git a/src/renderer/components/dock/create-resource/storage.injectable.ts b/src/renderer/components/dock/create-resource/storage.injectable.ts new file mode 100644 index 0000000000..05f50442cb --- /dev/null +++ b/src/renderer/components/dock/create-resource/storage.injectable.ts @@ -0,0 +1,20 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import type { StorageLayer } from "../../../utils"; +import createStorageInjectable from "../../../utils/create-storage/create-storage.injectable"; +import type { DockTabStorageState } from "../dock-tab/store"; + +let storage: StorageLayer>; + +const createResourceTabStorageInjectable = getInjectable({ + setup: async (di) => { + storage = await di.inject(createStorageInjectable)("create_resource", {}); + }, + instantiate: () => storage, + lifecycle: lifecycleEnum.singleton, +}); + +export default createResourceTabStorageInjectable; diff --git a/src/renderer/components/dock/create-resource/store.injectable.ts b/src/renderer/components/dock/create-resource/store.injectable.ts new file mode 100644 index 0000000000..714515f586 --- /dev/null +++ b/src/renderer/components/dock/create-resource/store.injectable.ts @@ -0,0 +1,17 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { CreateResourceTabStore } from "./store"; +import createResourceTabStorageInjectable from "./storage.injectable"; + +const createResourceTabStoreInjectable = getInjectable({ + instantiate: (di) => new CreateResourceTabStore({ + storage: di.inject(createResourceTabStorageInjectable), + }), + + lifecycle: lifecycleEnum.singleton, +}); + +export default createResourceTabStoreInjectable; diff --git a/src/renderer/components/+workloads/workloads.scss b/src/renderer/components/dock/create-resource/store.ts similarity index 55% rename from src/renderer/components/+workloads/workloads.scss rename to src/renderer/components/dock/create-resource/store.ts index d05328095c..cb9bef81fa 100644 --- a/src/renderer/components/+workloads/workloads.scss +++ b/src/renderer/components/dock/create-resource/store.ts @@ -3,5 +3,7 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -.Workloads { +import { DockTabStore } from "../dock-tab/store"; + +export class CreateResourceTabStore extends DockTabStore { } diff --git a/src/renderer/components/dock/create-resource/user-templates.injectable.ts b/src/renderer/components/dock/create-resource/user-templates.injectable.ts new file mode 100644 index 0000000000..4060191e52 --- /dev/null +++ b/src/renderer/components/dock/create-resource/user-templates.injectable.ts @@ -0,0 +1,102 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { computed, IComputedValue, observable } from "mobx"; +import path from "path"; +import os from "os"; +import { delay, getOrInsert, waitForPath } from "../../../utils"; +import { watch } from "chokidar"; +import { readFile } from "fs/promises"; +import logger from "../../../../common/logger"; +import { hasCorrectExtension } from "./has-correct-extension"; +import type { RawTemplates } from "./create-resource-templates.injectable"; + +const userTemplatesFolder = path.join(os.homedir(), ".k8slens", "templates"); + +function groupTemplates(templates: Map): RawTemplates[] { + const res = new Map(); + + for (const [filePath, contents] of templates) { + const rawRelative = path.dirname(path.relative(userTemplatesFolder, filePath)); + const title = rawRelative === "." + ? "ungrouped" + : rawRelative; + + getOrInsert(res, title, []).push([path.parse(filePath).name, contents]); + } + + return [...res.entries()]; +} + +function watchUserCreateResourceTemplates(): IComputedValue { + /** + * Map between filePaths and template contents + */ + const templates = observable.map(); + + const onAddOrChange = async (filePath: string) => { + if (!hasCorrectExtension(filePath)) { + // ignore non yaml or json files + return; + } + + try { + const contents = await readFile(filePath, "utf-8"); + + templates.set(filePath, contents); + } catch (error) { + if (error?.code === "ENOENT") { + // ignore, file disappeared + } else { + logger.warn(`[USER-CREATE-RESOURCE-TEMPLATES]: encountered error while reading ${filePath}`, error); + } + } + }; + const onUnlink = (filePath: string) => { + templates.delete(filePath); + }; + + (async () => { + for (let i = 1;; i *= 2) { + try { + await waitForPath(userTemplatesFolder); + break; + } catch (error) { + logger.warn(`[USER-CREATE-RESOURCE-TEMPLATES]: encountered error while waiting for ${userTemplatesFolder} to exist, waiting and trying again`, error); + await delay(i * 1000); // exponential backoff in seconds + } + } + + /** + * NOTE: There is technically a race condition here of the form "time-of-check to time-of-use" + */ + watch(userTemplatesFolder, { + disableGlobbing: true, + ignorePermissionErrors: true, + usePolling: false, + awaitWriteFinish: { + pollInterval: 100, + stabilityThreshold: 1000, + }, + ignoreInitial: false, + atomic: 150, // for "atomic writes" + }) + .on("add", onAddOrChange) + .on("change", onAddOrChange) + .on("unlink", onUnlink) + .on("error", error => { + logger.warn(`[USER-CREATE-RESOURCE-TEMPLATES]: encountered error while watching files under ${userTemplatesFolder}`, error); + }); + })(); + + return computed(() => groupTemplates(templates)); +} + +const userCreateResourceTemplatesInjectable = getInjectable({ + instantiate: () => watchUserCreateResourceTemplates(), + lifecycle: lifecycleEnum.singleton, +}); + +export default userCreateResourceTemplatesInjectable; diff --git a/src/renderer/components/dock/create-resource/view.tsx b/src/renderer/components/dock/create-resource/view.tsx new file mode 100644 index 0000000000..b164f68639 --- /dev/null +++ b/src/renderer/components/dock/create-resource/view.tsx @@ -0,0 +1,154 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import React from "react"; +import { GroupSelectOption, Select, SelectOption } from "../../select"; +import yaml from "js-yaml"; +import { IComputedValue, makeObservable, observable } from "mobx"; +import { observer } from "mobx-react"; +import type { CreateResourceTabStore } from "./store"; +import type { DockTabData } from "../dock/store"; +import { EditorPanel } from "../editor/editor-panel"; +import { InfoPanel } from "../info-panel/info-panel"; +import * as resourceApplierApi from "../../../../common/k8s-api/endpoints/resource-applier.api"; +import { Notifications } from "../../notifications"; +import logger from "../../../../common/logger"; +import type { KubeJsonApiData } from "../../../../common/k8s-api/kube-json-api"; +import { getDetailsUrl } from "../../kube-detail-params"; +import type { ApiManager } from "../../../../common/k8s-api/api-manager"; +import { prevDefault } from "../../../utils"; +import { navigate } from "../../../navigation"; +import { withInjectables } from "@ogre-tools/injectable-react"; +import createResourceTabStoreInjectable from "./store.injectable"; +import createResourceTemplatesInjectable from "./create-resource-templates.injectable"; +import apiManagerInjectable from "../../../../common/k8s-api/api-manager.injectable"; + +interface Props { + tab: DockTabData; +} + +interface Dependencies { + createResourceTemplates: IComputedValue[]>; + createResourceTabStore: CreateResourceTabStore; + apiManager: ApiManager; +} + +@observer +class NonInjectedCreateResource extends React.Component { + @observable error = ""; + + constructor(props: Props & Dependencies) { + super(props); + makeObservable(this); + } + + get tabId() { + return this.props.tab.id; + } + + get data() { + return this.props.createResourceTabStore.getData(this.tabId); + } + + onChange = (value: string) => { + this.error = ""; // reset first, validation goes later + this.props.createResourceTabStore.setData(this.tabId, value); + }; + + onError = (error: Error | string) => { + this.error = error.toString(); + }; + + onSelectTemplate = (item: SelectOption) => { + this.props.createResourceTabStore.setData(this.tabId, item.value); + }; + + create = async (): Promise => { + if (this.error || !this.data.trim()) { + // do not save when field is empty or there is an error + return; + } + + // skip empty documents + const resources = yaml.loadAll(this.data).filter(Boolean); + + if (resources.length === 0) { + return void logger.info("Nothing to create"); + } + + const creatingResources = resources.map(async (resource: string) => { + try { + const data = await resourceApplierApi.update(resource) as KubeJsonApiData; + const { kind, apiVersion, metadata: { name, namespace }} = data; + + const showDetails = () => { + const resourceLink = this.props.apiManager.lookupApiLink({ kind, apiVersion, name, namespace }); + + navigate(getDetailsUrl(resourceLink)); + close(); + }; + + const close = Notifications.ok( +

    + {kind} {name} successfully created. +

    , + ); + } catch (error) { + Notifications.error(error?.toString() ?? "Unknown error occured"); + } + }); + + await Promise.allSettled(creatingResources); + }; + + renderControls() { + return ( +
    + - Namespace - - -
    - ); - - return ( -
    - - -
    - ); - } -} - -export const InstallChart = withInjectables( - NonInjectedInstallChart, - - { - getProps: (di, props) => ({ - createRelease: di.inject(releaseStoreInjectable).create, - installChartStore: di.inject(installChartStoreInjectable), - dockStore: di.inject(dockStoreInjectable), - ...props, - }), - }, -); diff --git a/src/renderer/components/dock/install-chart/chart-version-manager.injectable.ts b/src/renderer/components/dock/install-chart/chart-version-manager.injectable.ts new file mode 100644 index 0000000000..f29c50733b --- /dev/null +++ b/src/renderer/components/dock/install-chart/chart-version-manager.injectable.ts @@ -0,0 +1,14 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { DockTabStore } from "../dock-tab/store"; + +const chartVersionManagerInjectable = getInjectable({ + instantiate: () => new DockTabStore({}), + lifecycle: lifecycleEnum.singleton, +}); + +export default chartVersionManagerInjectable; diff --git a/src/renderer/components/dock/install-chart/clear-install-chart-tab-data.injectable.ts b/src/renderer/components/dock/install-chart/clear-install-chart-tab-data.injectable.ts new file mode 100644 index 0000000000..7ac46182d6 --- /dev/null +++ b/src/renderer/components/dock/install-chart/clear-install-chart-tab-data.injectable.ts @@ -0,0 +1,26 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { bind } from "../../../utils"; +import type { TabId } from "../dock/store"; +import type { InstallChartTabStore } from "./store"; +import installChartTabStoreInjectable from "./store.injectable"; + +interface Dependencies { + installChartTabStore: InstallChartTabStore; +} + +function clearInstallChartTabData({ installChartTabStore }: Dependencies, tabId: TabId) { + installChartTabStore.clearData(tabId); +} + +const clearInstallChartTabDataInjectable = getInjectable({ + instantiate: (di) => bind(clearInstallChartTabData, null, { + installChartTabStore: di.inject(installChartTabStoreInjectable), + }), + lifecycle: lifecycleEnum.singleton, +}); + +export default clearInstallChartTabDataInjectable; diff --git a/src/renderer/components/dock/install-chart/create-tab.injectable.ts b/src/renderer/components/dock/install-chart/create-tab.injectable.ts new file mode 100644 index 0000000000..14945f6b51 --- /dev/null +++ b/src/renderer/components/dock/install-chart/create-tab.injectable.ts @@ -0,0 +1,47 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import type { HelmChart } from "../../../../common/k8s-api/endpoints/helm-chart.api"; +import { bind } from "../../../utils"; +import createDockTabInjectable from "../dock/create-tab.injectable"; +import { type DockTabCreateSpecific, TabKind, DockTabCreate, DockTabData, TabId, DockTabCreateOptions } from "../dock/store"; +import type { IChartInstallData } from "./store"; +import installChartManagerInjectable from "./store.injectable"; + +interface Dependencies { + createDockTab: (rawTabDesc: DockTabCreate, opts?: DockTabCreateOptions) => DockTabData; + setInstallChartTabData: (tabId: TabId, data: IChartInstallData) => void; +} + +function createInstallChartTab({ createDockTab, setInstallChartTabData }: Dependencies, chart: HelmChart, tabParams: DockTabCreateSpecific = {}) { + const { name, repo, version } = chart; + const tab = createDockTab({ + title: `Helm Install: ${repo}/${name}`, + ...tabParams, + kind: TabKind.INSTALL_CHART, + }); + + setInstallChartTabData(tab.id, { + name, + repo, + version, + namespace: "default", + releaseName: "", + description: "", + }); + + return tab; +} + +const newInstallChartTabInjectable = getInjectable({ + instantiate: (di) => bind(createInstallChartTab, null, { + createDockTab: di.inject(createDockTabInjectable), + setInstallChartTabData: (di.inject(installChartManagerInjectable)).setData, + }), + lifecycle: lifecycleEnum.singleton, +}); + +export default newInstallChartTabInjectable; diff --git a/src/renderer/components/dock/install-chart.scss b/src/renderer/components/dock/install-chart/install-chart.scss similarity index 100% rename from src/renderer/components/dock/install-chart.scss rename to src/renderer/components/dock/install-chart/install-chart.scss diff --git a/src/renderer/components/dock/install-chart/release-details-manager.injectable.ts b/src/renderer/components/dock/install-chart/release-details-manager.injectable.ts new file mode 100644 index 0000000000..d73b9a527b --- /dev/null +++ b/src/renderer/components/dock/install-chart/release-details-manager.injectable.ts @@ -0,0 +1,15 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import type { IReleaseUpdateDetails } from "../../../../common/k8s-api/endpoints/helm-release.api"; +import { DockTabStore } from "../dock-tab/store"; + +const releaseDetailsManagerInjectable = getInjectable({ + instantiate: () => new DockTabStore({}), + lifecycle: lifecycleEnum.singleton, +}); + +export default releaseDetailsManagerInjectable; diff --git a/src/renderer/components/dock/install-chart/storage.injectable.ts b/src/renderer/components/dock/install-chart/storage.injectable.ts new file mode 100644 index 0000000000..a1891b9e92 --- /dev/null +++ b/src/renderer/components/dock/install-chart/storage.injectable.ts @@ -0,0 +1,21 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import type { StorageLayer } from "../../../utils"; +import createStorageInjectable from "../../../utils/create-storage/create-storage.injectable"; +import type { DockTabStorageState } from "../dock-tab/store"; +import type { IChartInstallData } from "./store"; + +let storage: StorageLayer>; + +const installChartTabStorageInjectable = getInjectable({ + setup: async (di) => { + storage = await di.inject(createStorageInjectable)("install_charts", {}); + }, + instantiate: () => storage, + lifecycle: lifecycleEnum.singleton, +}); + +export default installChartTabStorageInjectable; diff --git a/src/renderer/components/dock/install-chart/store.injectable.ts b/src/renderer/components/dock/install-chart/store.injectable.ts new file mode 100644 index 0000000000..7078f8bc57 --- /dev/null +++ b/src/renderer/components/dock/install-chart/store.injectable.ts @@ -0,0 +1,18 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { InstallChartTabStore } from "./store"; +import chartVersionManagerInjectable from "./chart-version-manager.injectable"; +import installChartTabStorageInjectable from "./storage.injectable"; + +const installChartTabStoreInjectable = getInjectable({ + instantiate: (di) => new InstallChartTabStore({ + versionsStore: di.inject(chartVersionManagerInjectable), + storage: di.inject(installChartTabStorageInjectable), + }), + lifecycle: lifecycleEnum.singleton, +}); + +export default installChartTabStoreInjectable; diff --git a/src/renderer/components/dock/install-chart-store/install-chart.store.ts b/src/renderer/components/dock/install-chart/store.ts similarity index 50% rename from src/renderer/components/dock/install-chart-store/install-chart.store.ts rename to src/renderer/components/dock/install-chart/store.ts index 9e8a9d4c32..8acd43eb81 100644 --- a/src/renderer/components/dock/install-chart-store/install-chart.store.ts +++ b/src/renderer/components/dock/install-chart/store.ts @@ -3,13 +3,10 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import { action, autorun, makeObservable } from "mobx"; -import { DockStore, TabId, TabKind } from "../dock-store/dock.store"; -import { DockTabStorageState, DockTabStore } from "../dock-tab-store/dock-tab.store"; -import { getChartDetails, getChartValues } from "../../../../common/k8s-api/endpoints/helm-charts.api"; -import type { IReleaseUpdateDetails } from "../../../../common/k8s-api/endpoints/helm-releases.api"; -import { Notifications } from "../../notifications"; -import type { StorageHelper } from "../../../utils"; +import { action, makeObservable } from "mobx"; +import type { TabId } from "../dock/store"; +import { DockTabStorageLayer, DockTabStore, DockTabStoreDependencies } from "../dock-tab/store"; +import { getChartDetails, getChartValues } from "../../../../common/k8s-api/endpoints/helm-chart.api"; export interface IChartInstallData { name: string; @@ -22,42 +19,27 @@ export interface IChartInstallData { lastVersion?: boolean; } -interface Dependencies { - dockStore: DockStore, - createStorage: (storageKey: string, options: DockTabStorageState) => StorageHelper> - - versionsStore: DockTabStore, - detailsStore: DockTabStore +export interface InstallChartManager extends DockTabStorageLayer { + loadValues: (tabId: TabId) => Promise; + initialLoad: (tabId: TabId) => Promise; } -export class InstallChartStore extends DockTabStore { - constructor(protected dependencies: Dependencies) { - super( - dependencies, - { storageKey: "install_charts" }, - ); +export interface InstallChartTabStoreDependencies extends DockTabStoreDependencies { + versionsStore: DockTabStore; +} +export class InstallChartTabStore extends DockTabStore implements InstallChartManager { + constructor(protected dependencies: InstallChartTabStoreDependencies) { + super(dependencies); makeObservable(this); - autorun(() => { - const { selectedTab, isOpen } = dependencies.dockStore; - - if (selectedTab?.kind === TabKind.INSTALL_CHART && isOpen) { - this.loadData(selectedTab.id) - .catch(err => Notifications.error(String(err))); - } - }, { delay: 250 }); } get versions() { return this.dependencies.versionsStore; } - get details() { - return this.dependencies.detailsStore; - } - @action - async loadData(tabId: string) { + async initialLoad(tabId: string) { const promises = []; if (!this.getData(tabId).values) { @@ -72,7 +54,7 @@ export class InstallChartStore extends DockTabStore { } @action - async loadVersions(tabId: TabId) { + private async loadVersions(tabId: TabId) { const { repo, name, version } = this.getData(tabId); this.versions.clearData(tabId); // reset @@ -94,8 +76,4 @@ export class InstallChartStore extends DockTabStore { return this.loadValues(tabId, attempt + 1); } } - - setData(tabId: TabId, data: IChartInstallData){ - super.setData(tabId, data); - } } diff --git a/src/renderer/components/dock/install-chart/view.tsx b/src/renderer/components/dock/install-chart/view.tsx new file mode 100644 index 0000000000..0bd5258fe1 --- /dev/null +++ b/src/renderer/components/dock/install-chart/view.tsx @@ -0,0 +1,211 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import "./install-chart.scss"; + +import React, { useEffect, useState } from "react"; +import { observer } from "mobx-react"; +import type { DockTabData, TabId } from "../dock/store"; +import { InfoPanel } from "../info-panel/info-panel"; +import { Badge } from "../../badge"; +import { NamespaceSelect } from "../../+namespaces/namespace-select"; +import { prevDefault } from "../../../utils"; +import type { IChartInstallData, InstallChartManager } from "./store"; +import { Spinner } from "../../spinner"; +import { Icon } from "../../icon"; +import { Button } from "../../button"; +import { LogsDialog } from "../../dialog/logs-dialog"; +import { Select, SelectOption } from "../../select"; +import { Input } from "../../input"; +import { EditorPanel } from "../editor/editor-panel"; +import { navigate } from "../../../navigation"; +import { releaseURL } from "../../../../common/routes"; +import type { DockTabStorageLayer } from "../dock-tab/store"; +import type { IReleaseCreatePayload, IReleaseUpdateDetails } from "../../../../common/k8s-api/endpoints/helm-release.api"; +import { withInjectables } from "@ogre-tools/injectable-react"; +import dockStoreInjectable from "../dock/store.injectable"; +import installChartManagerInjectable from "./store.injectable"; +import chartVersionManagerInjectable from "./chart-version-manager.injectable"; +import releaseDetailsManagerInjectable from "./release-details-manager.injectable"; +import { Notifications } from "../../notifications"; +import createReleaseInjectable from "../../+helm-releases/create-release.injectable"; + +export interface InstallChartProps { + tab: DockTabData; +} + +interface DockManager { + closeTab: (tabId: TabId) => void; +} + +interface Dependencies { + dockManager: DockManager; + installChartManager: InstallChartManager; + chartVersionManager: DockTabStorageLayer; + releaseDetailsManager: DockTabStorageLayer; + createRelease: (payload: IReleaseCreatePayload) => Promise +} + +const NonInjectedInstallChart = observer(({ createRelease, tab, dockManager, installChartManager, chartVersionManager, releaseDetailsManager }: Dependencies & InstallChartProps) => { + const [error, setError] = useState(""); + const [showNotes, setShowNotes] = useState(false); + const chartData = installChartManager.getData(tab.id); + const versions = chartVersionManager.getData(tab.id); + const releaseDetails = releaseDetailsManager.getData(tab.id); + + useEffect(() => { + installChartManager.initialLoad(tab.id) + .catch(err => Notifications.error(String(err))); + }, []); + + const viewRelease = () => { + const { release } = releaseDetails; + + navigate(releaseURL({ + params: { + name: release.name, + namespace: release.namespace, + }, + })); + dockManager.closeTab(tab.id); + }; + const save = (data: Partial) => { + installChartManager.setData(tab.id, { ...chartData, ...data }); + }; + const onVersionChange = (option: SelectOption) => { + const version = option.value; + + save({ version, values: "" }); + installChartManager.loadValues(tab.id); + }; + const onChange = (values: string) => { + setError(""); + save({ values }); + }; + const onError = (error: Error | string) => { + setError(error.toString()); + }; + + const onNamespaceChange = (opt: SelectOption) => { + save({ namespace: opt.value }); + }; + + const onReleaseNameChange = (name: string) => { + save({ releaseName: name }); + }; + + const install = async () => { + const { repo, name, version, namespace, values, releaseName } = chartData; + const details = await createRelease({ + name: releaseName || undefined, + chart: name, + repo, namespace, version, values, + }); + + releaseDetailsManager.setData(tab.id, details); + + return ( +

    Chart Release {details.release.name} successfully created.

    + ); + }; + + if (chartData?.values === undefined || !versions) { + return ; + } + + if (releaseDetails) { + return ( +
    +

    + +

    +

    Installation complete!

    +
    +
    + {showNotes && ( + setShowNotes(false)} + logs={releaseDetails.log} + isOpen={showNotes} + /> + )} +
    + ); + } + + const { repo, name, version, namespace, releaseName } = chartData; + const panelControls = ( +
    + Chart + + Version + +
    + ); + + return ( +
    + + +
    + ); +}); + +export const InstallChart = withInjectables(NonInjectedInstallChart, { + getProps: (di, props) => ({ + dockManager: di.inject(dockStoreInjectable), + installChartManager: di.inject(installChartManagerInjectable), + chartVersionManager: di.inject(chartVersionManagerInjectable), + releaseDetailsManager: di.inject(releaseDetailsManagerInjectable), + createRelease: di.inject(createReleaseInjectable), + ...props, + }), +}); diff --git a/src/renderer/components/dock/log-controls.tsx b/src/renderer/components/dock/log-controls.tsx deleted file mode 100644 index a197944bb4..0000000000 --- a/src/renderer/components/dock/log-controls.tsx +++ /dev/null @@ -1,101 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import "./log-controls.scss"; - -import React from "react"; -import { observer } from "mobx-react"; - -import { Pod } from "../../../common/k8s-api/endpoints"; -import { cssNames, saveFileDialog } from "../../utils"; -import { Checkbox } from "../checkbox"; -import { Icon } from "../icon"; -import type { LogTabData } from "./log-tab-store/log-tab.store"; -import type { LogStore } from "./log-store/log.store"; -import { withInjectables } from "@ogre-tools/injectable-react"; -import logStoreInjectable from "./log-store/log-store.injectable"; - -interface Props { - tabData?: LogTabData - logs: string[] - save: (data: Partial) => void -} - -interface Dependencies { - logStore: LogStore -} - -const NonInjectedLogControls = observer((props: Props & Dependencies) => { - const { tabData, save, logs, logStore } = props; - - if (!tabData) { - return null; - } - - const { showTimestamps, previous } = tabData; - const since = logs.length ? logStore.getTimestamps(logs[0]) : null; - const pod = new Pod(tabData.selectedPod); - - const toggleTimestamps = () => { - save({ showTimestamps: !showTimestamps }); - }; - - const togglePrevious = () => { - save({ previous: !previous }); - logStore.reload(); - }; - - const downloadLogs = () => { - const fileName = pod.getName(); - const logsToDownload = showTimestamps ? logs : logStore.logsWithoutTimestamps; - - saveFileDialog(`${fileName}.log`, logsToDownload.join("\n"), "text/plain"); - }; - - return ( -
    -
    - {since && ( - - Logs from{" "} - {new Date(since[0]).toLocaleString()} - - )} -
    -
    - - - -
    -
    - ); -}); - -export const LogControls = withInjectables( - NonInjectedLogControls, - - { - getProps: (di, props) => ({ - logStore: di.inject(logStoreInjectable), - ...props, - }), - }, -); - diff --git a/src/renderer/components/dock/log-list.tsx b/src/renderer/components/dock/log-list.tsx deleted file mode 100644 index fc31311e5a..0000000000 --- a/src/renderer/components/dock/log-list.tsx +++ /dev/null @@ -1,266 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import "./log-list.scss"; - -import React from "react"; -import AnsiUp from "ansi_up"; -import DOMPurify from "dompurify"; -import debounce from "lodash/debounce"; -import { action, computed, observable, makeObservable, reaction } from "mobx"; -import { disposeOnUnmount, observer } from "mobx-react"; -import moment from "moment-timezone"; -import type { Align, ListOnScrollProps } from "react-window"; -import { SearchStore } from "../../search-store/search-store"; -import { UserStore } from "../../../common/user-store"; -import { array, boundMethod, cssNames } from "../../utils"; -import { VirtualList } from "../virtual-list"; -import type { LogStore } from "./log-store/log.store"; -import type { LogTabStore } from "./log-tab-store/log-tab.store"; -import { ToBottom } from "./to-bottom"; -import { withInjectables } from "@ogre-tools/injectable-react"; -import logTabStoreInjectable from "./log-tab-store/log-tab-store.injectable"; -import logStoreInjectable from "./log-store/log-store.injectable"; -import searchStoreInjectable from "../../search-store/search-store.injectable"; - -interface Props { - logs: string[] - id: string -} - -const colorConverter = new AnsiUp(); - -interface Dependencies { - logTabStore: LogTabStore - logStore: LogStore - searchStore: SearchStore -} - -@observer -export class NonInjectedLogList extends React.Component { - @observable isJumpButtonVisible = false; - @observable isLastLineVisible = true; - - private virtualListDiv = React.createRef(); // A reference for outer container in VirtualList - private virtualListRef = React.createRef(); // A reference for VirtualList component - private lineHeight = 18; // Height of a log line. Should correlate with styles in pod-log-list.scss - - constructor(props: Props & Dependencies) { - super(props); - makeObservable(this); - } - - componentDidMount() { - disposeOnUnmount(this, [ - reaction(() => this.props.logs, this.onLogsInitialLoad), - reaction(() => this.props.logs, this.onLogsUpdate), - reaction(() => this.props.logs, this.onUserScrolledUp), - ]); - } - - @boundMethod - onLogsInitialLoad(logs: string[], prevLogs: string[]) { - if (!prevLogs.length && logs.length) { - this.isLastLineVisible = true; - } - } - - @boundMethod - onLogsUpdate() { - if (this.isLastLineVisible) { - setTimeout(() => { - this.scrollToBottom(); - }, 500); // Giving some time to VirtualList to prepare its outerRef (this.virtualListDiv) element - } - } - - @boundMethod - onUserScrolledUp(logs: string[], prevLogs: string[]) { - if (!this.virtualListDiv.current) return; - - const newLogsAdded = prevLogs.length < logs.length; - const scrolledToBeginning = this.virtualListDiv.current.scrollTop === 0; - - if (newLogsAdded && scrolledToBeginning) { - const firstLineContents = prevLogs[0]; - const lineToScroll = this.props.logs.findIndex((value) => value == firstLineContents); - - if (lineToScroll !== -1) { - this.scrollToItem(lineToScroll, "start"); - } - } - } - - /** - * Returns logs with or without timestamps regarding to showTimestamps prop - */ - @computed - get logs() { - const showTimestamps = this.props.logTabStore.getData(this.props.id)?.showTimestamps; - - if (!showTimestamps) { - return this.props.logStore.logsWithoutTimestamps; - } - - return this.props.logs - .map(log => this.props.logStore.splitOutTimestamp(log)) - .map(([logTimestamp, log]) => (`${logTimestamp && moment.tz(logTimestamp, UserStore.getInstance().localeTimezone).format()}${log}`)); - } - - /** - * Checks if JumpToBottom button should be visible and sets its observable - * @param props Scrolling props from virtual list core - */ - @action - setButtonVisibility = (props: ListOnScrollProps) => { - const offset = 100 * this.lineHeight; - const { scrollHeight } = this.virtualListDiv.current; - const { scrollOffset } = props; - - if (scrollHeight - scrollOffset < offset) { - this.isJumpButtonVisible = false; - } else { - this.isJumpButtonVisible = true; - } - }; - - /** - * Checks if last log line considered visible to user, setting its observable - * @param props Scrolling props from virtual list core - */ - @action - setLastLineVisibility = (props: ListOnScrollProps) => { - const { scrollHeight, clientHeight } = this.virtualListDiv.current; - const { scrollOffset } = props; - - this.isLastLineVisible = (clientHeight + scrollOffset) === scrollHeight; - }; - - /** - * Check if user scrolled to top and new logs should be loaded - * @param props Scrolling props from virtual list core - */ - checkLoadIntent = (props: ListOnScrollProps) => { - const { scrollOffset } = props; - - if (scrollOffset === 0) { - this.props.logStore.load(); - } - }; - - scrollToBottom = () => { - if (!this.virtualListDiv.current) return; - this.virtualListDiv.current.scrollTop = this.virtualListDiv.current.scrollHeight; - }; - - scrollToItem = (index: number, align: Align) => { - this.virtualListRef.current.scrollToItem(index, align); - }; - - onScroll = (props: ListOnScrollProps) => { - this.isLastLineVisible = false; - this.onScrollDebounced(props); - }; - - onScrollDebounced = debounce((props: ListOnScrollProps) => { - if (!this.virtualListDiv.current) return; - this.setButtonVisibility(props); - this.setLastLineVisibility(props); - this.checkLoadIntent(props); - }, 700); // Increasing performance and giving some time for virtual list to settle down - - /** - * A function is called by VirtualList for rendering each of the row - * @param rowIndex index of the log element in logs array - * @returns A react element with a row itself - */ - getLogRow = (rowIndex: number) => { - const { searchQuery, isActiveOverlay } = this.props.searchStore; - const item = this.logs[rowIndex]; - const contents: React.ReactElement[] = []; - const ansiToHtml = (ansi: string) => DOMPurify.sanitize(colorConverter.ansi_to_html(ansi)); - - if (searchQuery) { // If search is enabled, replace keyword with backgrounded - // Case-insensitive search (lowercasing query and keywords in line) - const regex = new RegExp(SearchStore.escapeRegex(searchQuery), "gi"); - const matches = item.matchAll(regex); - const modified = item.replace(regex, match => match.toLowerCase()); - // Splitting text line by keyword - const pieces = modified.split(searchQuery.toLowerCase()); - - pieces.forEach((piece, index) => { - const active = isActiveOverlay(rowIndex, index); - const lastItem = index === pieces.length - 1; - const overlayValue = matches.next().value; - const overlay = !lastItem - ? - : null; - - contents.push( - - - {overlay} - , - ); - }); - } - - return ( -
    - {contents.length > 1 ? contents : ( - - )} - {/* For preserving copy-paste experience and keeping line breaks */} -
    -
    - ); - }; - - render() { - const rowHeights = array.filled(this.logs.length, this.lineHeight); - - if (!this.logs.length) { - return ( -
    - There are no logs available for container -
    - ); - } - - return ( -
    - - {this.isJumpButtonVisible && ( - - )} -
    - ); - } -} - -export const LogList = withInjectables( - NonInjectedLogList, - - { - getProps: (di, props) => ({ - logTabStore: di.inject(logTabStoreInjectable), - logStore: di.inject(logStoreInjectable), - searchStore: di.inject(searchStoreInjectable), - ...props, - }), - }, -); - diff --git a/src/renderer/components/dock/log-resource-selector.tsx b/src/renderer/components/dock/log-resource-selector.tsx deleted file mode 100644 index c6d646038a..0000000000 --- a/src/renderer/components/dock/log-resource-selector.tsx +++ /dev/null @@ -1,122 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import "./log-resource-selector.scss"; - -import React, { useEffect } from "react"; -import { observer } from "mobx-react"; - -import { Pod } from "../../../common/k8s-api/endpoints"; -import { Badge } from "../badge"; -import { Select, SelectOption } from "../select"; -import type { LogTabData, LogTabStore } from "./log-tab-store/log-tab.store"; -import { podsStore } from "../+workloads-pods/pods.store"; -import type { TabId } from "./dock-store/dock.store"; -import logTabStoreInjectable from "./log-tab-store/log-tab-store.injectable"; -import { withInjectables } from "@ogre-tools/injectable-react"; -import logStoreInjectable from "./log-store/log-store.injectable"; - -interface Props { - tabId: TabId - tabData: LogTabData - save: (data: Partial) => void -} - -interface Dependencies { - logTabStore: LogTabStore - reloadLogs: () => Promise -} - -const NonInjectedLogResourceSelector = observer((props: Props & Dependencies) => { - const { tabData, save, tabId, logTabStore, reloadLogs } = props; - const { selectedPod, selectedContainer, pods } = tabData; - const pod = new Pod(selectedPod); - const containers = pod.getContainers(); - const initContainers = pod.getInitContainers(); - - const onContainerChange = (option: SelectOption) => { - save({ - selectedContainer: containers - .concat(initContainers) - .find(container => container.name === option.value), - }); - - reloadLogs(); - }; - - const onPodChange = (option: SelectOption) => { - const selectedPod = podsStore.getByName(option.value, pod.getNs()); - - save({ selectedPod }); - - logTabStore.renameTab(tabId); - }; - - const getSelectOptions = (items: string[]) => { - return items.map(item => { - return { - value: item, - label: item, - }; - }); - }; - - const containerSelectOptions = [ - { - label: `Containers`, - options: getSelectOptions(containers.map(container => container.name)), - }, - { - label: `Init Containers`, - options: getSelectOptions(initContainers.map(container => container.name)), - }, - ]; - - const podSelectOptions = [ - { - label: pod.getOwnerRefs()[0]?.name, - options: getSelectOptions(pods.map(pod => pod.metadata.name)), - }, - ]; - - useEffect(() => { - reloadLogs(); - }, [selectedPod]); - - return ( -
    - Namespace - Pod - -
    - ); -}); - -export const LogResourceSelector = withInjectables( - NonInjectedLogResourceSelector, - - { - getProps: (di, props) => ({ - logTabStore: di.inject(logTabStoreInjectable), - reloadLogs: di.inject(logStoreInjectable).reload, - ...props, - }), - }, -); - diff --git a/src/renderer/components/dock/log-store/reloaded-log-store.injectable.ts b/src/renderer/components/dock/log-store/reloaded-log-store.injectable.ts deleted file mode 100644 index 277579dc15..0000000000 --- a/src/renderer/components/dock/log-store/reloaded-log-store.injectable.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ -import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; -import logStoreInjectable from "./log-store.injectable"; - -const reloadedLogStoreInjectable = getInjectable({ - instantiate: async (di) => { - const nonReloadedStore = di.inject(logStoreInjectable); - - await nonReloadedStore.reload(); - - return nonReloadedStore; - }, - - lifecycle: lifecycleEnum.transient, -}); - -export default reloadedLogStoreInjectable; diff --git a/src/renderer/components/dock/log-tab-store/log-tab.store.ts b/src/renderer/components/dock/log-tab-store/log-tab.store.ts deleted file mode 100644 index fb15a9d06c..0000000000 --- a/src/renderer/components/dock/log-tab-store/log-tab.store.ts +++ /dev/null @@ -1,147 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import uniqueId from "lodash/uniqueId"; -import { computed, makeObservable, reaction } from "mobx"; -import { podsStore } from "../../+workloads-pods/pods.store"; - -import { IPodContainer, Pod } from "../../../../common/k8s-api/endpoints"; -import type { WorkloadKubeObject } from "../../../../common/k8s-api/workload-kube-object"; -import logger from "../../../../common/logger"; -import { DockTabStorageState, DockTabStore } from "../dock-tab-store/dock-tab.store"; -import { DockStore, DockTabCreateSpecific, TabKind } from "../dock-store/dock.store"; -import type { StorageHelper } from "../../../utils"; - -export interface LogTabData { - pods: Pod[]; - selectedPod: Pod; - selectedContainer: IPodContainer - showTimestamps?: boolean - previous?: boolean -} - -interface PodLogsTabData { - selectedPod: Pod - selectedContainer: IPodContainer -} - -interface WorkloadLogsTabData { - workload: WorkloadKubeObject -} - -interface Dependencies { - dockStore: DockStore - createStorage: (storageKey: string, options: DockTabStorageState) => StorageHelper> -} - -export class LogTabStore extends DockTabStore { - constructor(protected dependencies: Dependencies) { - super(dependencies, { - storageKey: "pod_logs", - }); - - reaction(() => podsStore.items.length, () => this.updateTabsData()); - - makeObservable(this, { - tabs: computed, - }); - } - - get tabs() { - return this.data.get(this.dependencies.dockStore.selectedTabId); - } - - createPodTab({ selectedPod, selectedContainer }: PodLogsTabData): string { - const podOwner = selectedPod.getOwnerRefs()[0]; - const pods = podsStore.getPodsByOwnerId(podOwner?.uid); - const title = `Pod ${selectedPod.getName()}`; - - return this.createLogsTab(title, { - pods: pods.length ? pods : [selectedPod], - selectedPod, - selectedContainer, - }); - } - - createWorkloadTab({ workload }: WorkloadLogsTabData): void { - const pods = podsStore.getPodsByOwnerId(workload.getId()); - - if (!pods.length) return; - - const selectedPod = pods[0]; - const selectedContainer = selectedPod.getAllContainers()[0]; - const title = `${workload.kind} ${selectedPod.getName()}`; - - this.createLogsTab(title, { - pods, - selectedPod, - selectedContainer, - }); - } - - renameTab(tabId: string) { - const { selectedPod } = this.getData(tabId); - - this.dependencies.dockStore.renameTab(tabId, `Pod ${selectedPod.metadata.name}`); - } - - private createDockTab(tabParams: DockTabCreateSpecific) { - this.dependencies.dockStore.createTab({ - ...tabParams, - kind: TabKind.POD_LOGS, - }, false); - } - - private createLogsTab(title: string, data: LogTabData): string { - const id = uniqueId("log-tab-"); - - this.createDockTab({ id, title }); - this.setData(id, { - ...data, - showTimestamps: false, - previous: false, - }); - - return id; - } - - private updateTabsData() { - for (const [tabId, tabData] of this.data) { - try { - if (!tabData.selectedPod) { - tabData.selectedPod = tabData.pods[0]; - } - - const pod = new Pod(tabData.selectedPod); - const pods = podsStore.getPodsByOwnerId(pod.getOwnerRefs()[0]?.uid); - const isSelectedPodInList = pods.find(item => item.getId() == pod.getId()); - const selectedPod = isSelectedPodInList ? pod : pods[0]; - const selectedContainer = isSelectedPodInList ? tabData.selectedContainer : pod.getAllContainers()[0]; - - if (pods.length > 0) { - this.setData(tabId, { - ...tabData, - selectedPod, - selectedContainer, - pods, - }); - - this.renameTab(tabId); - } else { - this.closeTab(tabId); - } - } catch (error) { - logger.error(`[LOG-TAB-STORE]: failed to set data for tabId=${tabId} deleting`, error); - this.data.delete(tabId); - } - } - } - - private closeTab(tabId: string) { - this.clearData(tabId); - this.dependencies.dockStore.closeTab(tabId); - } -} - diff --git a/src/renderer/components/dock/logs.tsx b/src/renderer/components/dock/logs.tsx deleted file mode 100644 index 00425cbfd4..0000000000 --- a/src/renderer/components/dock/logs.tsx +++ /dev/null @@ -1,136 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import React from "react"; -import { observer } from "mobx-react"; -import { boundMethod } from "../../utils"; -import { InfoPanel } from "./info-panel"; -import { LogResourceSelector } from "./log-resource-selector"; -import { LogList, NonInjectedLogList } from "./log-list"; -import { LogSearch } from "./log-search"; -import { LogControls } from "./log-controls"; -import { withInjectables } from "@ogre-tools/injectable-react"; -import type { SearchStore } from "../../search-store/search-store"; -import searchStoreInjectable from "../../search-store/search-store.injectable"; -import { Spinner } from "../spinner"; -import logsViewModelInjectable from "./logs/logs-view-model/logs-view-model.injectable"; -import type { LogsViewModel } from "./logs/logs-view-model/logs-view-model"; - -interface Props { - className?: string; -} - -interface Dependencies { - searchStore: SearchStore - model: LogsViewModel -} - -@observer -class NonInjectedLogs extends React.Component { - private logListElement = React.createRef(); // A reference for VirtualList component - - get model() { - return this.props.model; - } - - /** - * Scrolling to active overlay (search word highlight) - */ - @boundMethod - scrollToOverlay() { - const { activeOverlayLine } = this.props.searchStore; - - if (!this.logListElement.current || activeOverlayLine === undefined) return; - // Scroll vertically - this.logListElement.current.scrollToItem(activeOverlayLine, "center"); - // Scroll horizontally in timeout since virtual list need some time to prepare its contents - setTimeout(() => { - const overlay = document.querySelector(".PodLogs .list span.active"); - - if (!overlay) return; - overlay.scrollIntoViewIfNeeded(); - }, 100); - } - - renderResourceSelector() { - const { tabs, logs, logsWithoutTimestamps, saveTab, tabId } = this.model; - - if (!tabs) { - return null; - } - - const searchLogs = tabs.showTimestamps ? logs : logsWithoutTimestamps; - - const controls = ( -
    - - - -
    - ); - - return ( - - ); - } - - render() { - const { logs, tabs, tabId, saveTab } = this.model; - - return ( -
    - {this.renderResourceSelector()} - - - - -
    - ); - } -} - - - -export const Logs = withInjectables( - NonInjectedLogs, - - { - - getPlaceholder: () => ( -
    - -
    - ), - - getProps: async (di, props) => ({ - searchStore: di.inject(searchStoreInjectable), - model: await di.inject(logsViewModelInjectable), - ...props, - }), - }, -); diff --git a/src/renderer/components/dock/logs/__test__/log-resource-selector.test.tsx b/src/renderer/components/dock/logs/__test__/log-resource-selector.test.tsx new file mode 100644 index 0000000000..2f7ff9e01d --- /dev/null +++ b/src/renderer/components/dock/logs/__test__/log-resource-selector.test.tsx @@ -0,0 +1,208 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import React from "react"; +import "@testing-library/jest-dom/extend-expect"; +import * as selectEvent from "react-select-event"; +import { Pod } from "../../../../../common/k8s-api/endpoints"; +import { LogResourceSelector } from "../resource-selector"; +import { dockerPod, deploymentPod1, deploymentPod2 } from "./pod.mock"; +import { ThemeStore } from "../../../../theme.store"; +import { UserStore } from "../../../../../common/user-store"; +import mockFs from "mock-fs"; +import { getDiForUnitTesting } from "../../../../getDiForUnitTesting"; +import type { DiRender } from "../../../test-utils/renderFor"; +import { renderFor } from "../../../test-utils/renderFor"; +import directoryForUserDataInjectable from "../../../../../common/app-paths/directory-for-user-data/directory-for-user-data.injectable"; +import callForLogsInjectable from "../call-for-logs.injectable"; +import { LogTabViewModel, LogTabViewModelDependencies } from "../logs-view-model"; +import type { TabId } from "../../dock/store"; +import userEvent from "@testing-library/user-event"; + +jest.mock("electron", () => ({ + app: { + getVersion: () => "99.99.99", + getName: () => "lens", + setName: jest.fn(), + setPath: jest.fn(), + getPath: () => "tmp", + getLocale: () => "en", + setLoginItemSettings: jest.fn(), + }, + ipcMain: { + on: jest.fn(), + handle: jest.fn(), + }, +})); + +function mockLogTabViewModel(tabId: TabId, deps: Partial): LogTabViewModel { + return new LogTabViewModel(tabId, { + getLogs: jest.fn(), + getLogsWithoutTimestamps: jest.fn(), + getTimestampSplitLogs: jest.fn(), + getLogTabData: jest.fn(), + setLogTabData: jest.fn(), + loadLogs: jest.fn(), + reloadLogs: jest.fn(), + renameTab: jest.fn(), + stopLoadingLogs: jest.fn(), + getPodById: jest.fn(), + getPodsByOwnerId: jest.fn(), + createSearchStore: jest.fn(), + areLogsPresent: jest.fn(), + ...deps, + }); +} + +const getOnePodViewModel = (tabId: TabId, deps: Partial = {}): LogTabViewModel => { + const selectedPod = new Pod(dockerPod); + + return mockLogTabViewModel(tabId, { + getLogTabData: () => ({ + selectedPodId: selectedPod.getId(), + selectedContainer: selectedPod.getContainers()[0].name, + namespace: selectedPod.getNs(), + showPrevious: false, + showTimestamps: false, + }), + getPodById: (id) => { + if (id === selectedPod.getId()) { + return selectedPod; + } + + return undefined; + }, + ...deps, + }); +}; + +const getFewPodsTabData = (tabId: TabId, deps: Partial = {}): LogTabViewModel => { + const selectedPod = new Pod(deploymentPod1); + const anotherPod = new Pod(deploymentPod2); + + return mockLogTabViewModel(tabId, { + getLogTabData: () => ({ + owner: { + uid: "uuid", + kind: "Deployment", + name: "super-deployment", + }, + selectedPodId: selectedPod.getId(), + selectedContainer: selectedPod.getContainers()[0].name, + namespace: selectedPod.getNs(), + showPrevious: false, + showTimestamps: false, + }), + getPodById: (id) => { + if (id === selectedPod.getId()) { + return selectedPod; + } + + if (id === anotherPod.getId()) { + return anotherPod; + } + + return undefined; + }, + getPodsByOwnerId: (id) => { + if (id === "uuid") { + return [selectedPod, anotherPod]; + } + + return []; + }, + ...deps, + }); +}; + +describe("", () => { + let render: DiRender; + + beforeEach(async () => { + const di = getDiForUnitTesting({ doGeneralOverrides: true }); + + di.override(directoryForUserDataInjectable, () => "some-directory-for-user-data"); + di.override(callForLogsInjectable, () => () => Promise.resolve("some-logs")); + + render = renderFor(di); + + await di.runSetups(); + + mockFs({ + "tmp": {}, + }); + + UserStore.createInstance(); + ThemeStore.createInstance(); + }); + + afterEach(() => { + UserStore.resetInstance(); + ThemeStore.resetInstance(); + mockFs.restore(); + }); + + it("renders w/o errors", () => { + const model = getOnePodViewModel("foobar"); + const { container } = render(); + + expect(container).toBeInstanceOf(HTMLElement); + }); + + it("renders proper namespace", async () => { + const model = getOnePodViewModel("foobar"); + const { findByTestId } = render(); + const ns = await findByTestId("namespace-badge"); + + expect(ns).toHaveTextContent("default"); + }); + + it("renders proper selected items within dropdowns", async () => { + const model = getOnePodViewModel("foobar"); + const { findByText } = render(); + + expect(await findByText("dockerExporter")).toBeInTheDocument(); + expect(await findByText("docker-exporter")).toBeInTheDocument(); + }); + + it("renders sibling pods in dropdown", async () => { + const model = getFewPodsTabData("foobar"); + const { container, findByText } = render(); + + selectEvent.openMenu(container.querySelector(".pod-selector")); + expect(await findByText("deploymentPod2", { selector: ".pod-selector-menu .Select__option" })).toBeInTheDocument(); + expect(await findByText("deploymentPod1", { selector: ".pod-selector-menu .Select__option" })).toBeInTheDocument(); + }); + + it("renders sibling containers in dropdown", async () => { + const model = getFewPodsTabData("foobar"); + const { findByText, container } = render(); + + selectEvent.openMenu(container.querySelector(".container-selector")); + expect(await findByText("node-exporter-1")).toBeInTheDocument(); + expect(await findByText("init-node-exporter")).toBeInTheDocument(); + expect(await findByText("init-node-exporter-1")).toBeInTheDocument(); + }); + + it("renders pod owner as badge", async () => { + const model = getFewPodsTabData("foobar"); + const { findByText } = render(); + + expect(await findByText("super-deployment", { + exact: false, + })).toBeInTheDocument(); + }); + + it("updates tab name if selected pod changes", async () => { + const renameTab = jest.fn(); + const model = getFewPodsTabData("foobar", { renameTab }); + const { findByText, container } = render(); + + selectEvent.openMenu(container.querySelector(".pod-selector")); + + userEvent.click(await findByText("deploymentPod2", { selector: ".pod-selector-menu .Select__option" })); + expect(renameTab).toBeCalledWith("foobar", "Pod deploymentPod2"); + }); +}); diff --git a/src/renderer/components/dock/logs/__test__/log-search.test.tsx b/src/renderer/components/dock/logs/__test__/log-search.test.tsx new file mode 100644 index 0000000000..9514dc6dd2 --- /dev/null +++ b/src/renderer/components/dock/logs/__test__/log-search.test.tsx @@ -0,0 +1,147 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import React from "react"; +import { screen } from "@testing-library/react"; +import { Pod } from "../../../../../common/k8s-api/endpoints"; +import { dockerPod } from "./pod.mock"; +import { getDiForUnitTesting } from "../../../../getDiForUnitTesting"; +import type { DiRender } from "../../../test-utils/renderFor"; +import { renderFor } from "../../../test-utils/renderFor"; +import { LogTabViewModel, LogTabViewModelDependencies } from "../logs-view-model"; +import type { TabId } from "../../dock/store"; +import { LogSearch } from "../search"; +import userEvent from "@testing-library/user-event"; +import { SearchStore } from "../../../../search-store/search-store"; + +function mockLogTabViewModel(tabId: TabId, deps: Partial): LogTabViewModel { + return new LogTabViewModel(tabId, { + getLogs: jest.fn(), + getLogsWithoutTimestamps: jest.fn(), + getTimestampSplitLogs: jest.fn(), + getLogTabData: jest.fn(), + setLogTabData: jest.fn(), + loadLogs: jest.fn(), + reloadLogs: jest.fn(), + renameTab: jest.fn(), + stopLoadingLogs: jest.fn(), + getPodById: jest.fn(), + getPodsByOwnerId: jest.fn(), + areLogsPresent: jest.fn(), + createSearchStore: () => new SearchStore(), + ...deps, + }); +} + +const getOnePodViewModel = (tabId: TabId, deps: Partial = {}): LogTabViewModel => { + const selectedPod = new Pod(dockerPod); + + return mockLogTabViewModel(tabId, { + getLogTabData: () => ({ + selectedPodId: selectedPod.getId(), + selectedContainer: selectedPod.getContainers()[0].name, + namespace: selectedPod.getNs(), + showPrevious: false, + showTimestamps: false, + }), + getPodById: (id) => { + if (id === selectedPod.getId()) { + return selectedPod; + } + + return undefined; + }, + ...deps, + }); +}; + +describe("LogSearch tests", () => { + let render: DiRender; + + beforeEach(async () => { + const di = getDiForUnitTesting({ doGeneralOverrides: true }); + + render = renderFor(di); + + await di.runSetups(); + }); + + it("renders w/o errors", () => { + const model = getOnePodViewModel("foobar"); + const { container } = render( + , + ); + + expect(container).toBeInstanceOf(HTMLElement); + }); + + it("should scroll to new active overlay when clicking the previous button", async () => { + const scrollToOverlay = jest.fn(); + const model = getOnePodViewModel("foobar", { + getLogsWithoutTimestamps: () => [ + "hello", + "world", + ], + }); + + render( + , + ); + + userEvent.click(await screen.findByPlaceholderText("Search...")); + userEvent.keyboard("o"); + userEvent.click(await screen.findByText("keyboard_arrow_up")); + expect(scrollToOverlay).toBeCalled(); + }); + + it("should scroll to new active overlay when clicking the next button", async () => { + const scrollToOverlay = jest.fn(); + const model = getOnePodViewModel("foobar", { + getLogsWithoutTimestamps: () => [ + "hello", + "world", + ], + }); + + render( + , + ); + + userEvent.click(await screen.findByPlaceholderText("Search...")); + userEvent.keyboard("o"); + userEvent.click(await screen.findByText("keyboard_arrow_down")); + expect(scrollToOverlay).toBeCalled(); + }); + + it("next and previous should be disabled initially", async () => { + const scrollToOverlay = jest.fn(); + const model = getOnePodViewModel("foobar", { + getLogsWithoutTimestamps: () => [ + "hello", + "world", + ], + }); + + render( + , + ); + + userEvent.click(await screen.findByText("keyboard_arrow_down")); + userEvent.click(await screen.findByText("keyboard_arrow_up")); + expect(scrollToOverlay).not.toBeCalled(); + }); +}); diff --git a/src/renderer/components/dock/logs/__test__/pod.mock.ts b/src/renderer/components/dock/logs/__test__/pod.mock.ts new file mode 100644 index 0000000000..a74c7d5572 --- /dev/null +++ b/src/renderer/components/dock/logs/__test__/pod.mock.ts @@ -0,0 +1,208 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +export const dockerPod = { + apiVersion: "v1", + kind: "dummy", + metadata: { + uid: "dockerExporter", + name: "dockerExporter", + creationTimestamp: "dummy", + resourceVersion: "dummy", + namespace: "default", + }, + spec: { + initContainers: [] as any, + containers: [ + { + name: "docker-exporter", + image: "docker.io/prom/node-exporter:v1.0.0-rc.0", + imagePullPolicy: "pull", + }, + ], + serviceAccountName: "dummy", + serviceAccount: "dummy", + }, + status: { + phase: "Running", + conditions: [{ + type: "Running", + status: "Running", + lastProbeTime: 1, + lastTransitionTime: "Some time", + }], + hostIP: "dummy", + podIP: "dummy", + startTime: "dummy", + }, +}; + +export const deploymentPod1 = { + apiVersion: "v1", + kind: "dummy", + metadata: { + uid: "deploymentPod1", + name: "deploymentPod1", + creationTimestamp: "dummy", + resourceVersion: "dummy", + namespace: "default", + ownerReferences: [{ + apiVersion: "v1", + kind: "Deployment", + name: "super-deployment", + uid: "uuid", + controller: true, + blockOwnerDeletion: true, + }], + }, + spec: { + initContainers: [ + { + name: "init-node-exporter", + image: "docker.io/prom/node-exporter:v1.0.0-rc.0", + imagePullPolicy: "pull", + }, + { + name: "init-node-exporter-1", + image: "docker.io/prom/node-exporter:v1.0.0-rc.0", + imagePullPolicy: "pull", + }, + ], + containers: [ + { + name: "node-exporter", + image: "docker.io/prom/node-exporter:v1.0.0-rc.0", + imagePullPolicy: "pull", + }, + { + name: "node-exporter-1", + image: "docker.io/prom/node-exporter:v1.0.0-rc.0", + imagePullPolicy: "pull", + }, + ], + serviceAccountName: "dummy", + serviceAccount: "dummy", + }, + status: { + phase: "Running", + conditions: [{ + type: "Running", + status: "Running", + lastProbeTime: 1, + lastTransitionTime: "Some time", + }], + hostIP: "dummy", + podIP: "dummy", + startTime: "dummy", + }, +}; + +export const deploymentPod2 = { + apiVersion: "v1", + kind: "dummy", + metadata: { + uid: "deploymentPod2", + name: "deploymentPod2", + creationTimestamp: "dummy", + resourceVersion: "dummy", + namespace: "default", + ownerReferences: [{ + apiVersion: "v1", + kind: "Deployment", + name: "super-deployment", + uid: "uuid", + controller: true, + blockOwnerDeletion: true, + }], + }, + spec: { + initContainers: [ + { + name: "init-node-exporter", + image: "docker.io/prom/node-exporter:v1.0.0-rc.0", + imagePullPolicy: "pull", + }, + { + name: "init-node-exporter-1", + image: "docker.io/prom/node-exporter:v1.0.0-rc.0", + imagePullPolicy: "pull", + }, + ], + containers: [ + { + name: "node-exporter", + image: "docker.io/prom/node-exporter:v1.0.0-rc.0", + imagePullPolicy: "pull", + }, + { + name: "node-exporter-1", + image: "docker.io/prom/node-exporter:v1.0.0-rc.0", + imagePullPolicy: "pull", + }, + ], + serviceAccountName: "dummy", + serviceAccount: "dummy", + }, + status: { + phase: "Running", + conditions: [{ + type: "Running", + status: "Running", + lastProbeTime: 1, + lastTransitionTime: "Some time", + }], + hostIP: "dummy", + podIP: "dummy", + startTime: "dummy", + }, +}; + +export const deploymentPod3 = { + apiVersion: "v1", + kind: "dummy", + metadata: { + uid: "deploymentPod3", + name: "deploymentPod3", + creationTimestamp: "dummy", + resourceVersion: "dummy", + namespace: "default", + ownerReferences: [{ + apiVersion: "v1", + kind: "Deployment", + name: "super-deployment", + uid: "uuid", + controller: true, + blockOwnerDeletion: true, + }], + }, + spec: { + containers: [ + { + name: "node-exporter", + image: "docker.io/prom/node-exporter:v1.0.0-rc.0", + imagePullPolicy: "pull", + }, + { + name: "node-exporter-1", + image: "docker.io/prom/node-exporter:v1.0.0-rc.0", + imagePullPolicy: "pull", + }, + ], + serviceAccountName: "dummy", + serviceAccount: "dummy", + }, + status: { + phase: "Running", + conditions: [{ + type: "Running", + status: "Running", + lastProbeTime: 1, + lastTransitionTime: "Some time", + }], + hostIP: "dummy", + podIP: "dummy", + startTime: "dummy", + }, +}; diff --git a/src/renderer/components/dock/logs/__test__/to-bottom.test.tsx b/src/renderer/components/dock/logs/__test__/to-bottom.test.tsx new file mode 100644 index 0000000000..2040dada89 --- /dev/null +++ b/src/renderer/components/dock/logs/__test__/to-bottom.test.tsx @@ -0,0 +1,37 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import React from "react"; +import "@testing-library/jest-dom/extend-expect"; +import { fireEvent, render } from "@testing-library/react"; +import { ToBottom } from "../to-bottom"; +import { noop } from "../../../../utils"; + +describe("", () => { + it("renders w/o errors", () => { + const { container } = render(); + + expect(container).toBeInstanceOf(HTMLElement); + }); + + it("has 'To bottom' label", () => { + const { getByText } = render(); + + expect(getByText("To bottom")).toBeInTheDocument(); + }); + + it("has a arrow down icon", () => { + const { getByText } = render(); + + expect(getByText("expand_more")).toBeInTheDocument(); + }); + + it("fires an onclick event", () => { + const callback = jest.fn(); + const { getByText } = render(); + + fireEvent.click(getByText("To bottom")); + expect(callback).toBeCalled(); + }); +}); diff --git a/src/renderer/components/dock/logs/are-logs-present.injectable.ts b/src/renderer/components/dock/logs/are-logs-present.injectable.ts new file mode 100644 index 0000000000..7998e0ff2c --- /dev/null +++ b/src/renderer/components/dock/logs/are-logs-present.injectable.ts @@ -0,0 +1,26 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { bind } from "../../../utils"; +import type { TabId } from "../dock/store"; +import type { LogStore } from "./store"; +import logStoreInjectable from "./store.injectable"; + +interface Dependencies { + logStore: LogStore; +} + +function areLogsPresent({ logStore }: Dependencies, tabId: TabId) { + return logStore.areLogsPresent(tabId); +} + +const areLogsPresentInjectable = getInjectable({ + instantiate: (di) => bind(areLogsPresent, null, { + logStore: di.inject(logStoreInjectable), + }), + lifecycle: lifecycleEnum.singleton, +}); + +export default areLogsPresentInjectable; diff --git a/src/renderer/components/dock/log-store/call-for-logs/call-for-logs.injectable.ts b/src/renderer/components/dock/logs/call-for-logs.injectable.ts similarity index 70% rename from src/renderer/components/dock/log-store/call-for-logs/call-for-logs.injectable.ts rename to src/renderer/components/dock/logs/call-for-logs.injectable.ts index 5657786f9f..07957ab60b 100644 --- a/src/renderer/components/dock/log-store/call-for-logs/call-for-logs.injectable.ts +++ b/src/renderer/components/dock/logs/call-for-logs.injectable.ts @@ -3,10 +3,10 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; -import { podsApi } from "../../../../../common/k8s-api/endpoints"; +import podApiInjectable from "../../../../common/k8s-api/endpoints/pod.api.injectable"; const callForLogsInjectable = getInjectable({ - instantiate: () => podsApi.getLogs, + instantiate: (di) => di.inject(podApiInjectable).getLogs, lifecycle: lifecycleEnum.singleton, }); diff --git a/src/renderer/components/dock/logs/clear-log-tab-data.injectable.ts b/src/renderer/components/dock/logs/clear-log-tab-data.injectable.ts new file mode 100644 index 0000000000..23e35272b1 --- /dev/null +++ b/src/renderer/components/dock/logs/clear-log-tab-data.injectable.ts @@ -0,0 +1,26 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { bind } from "../../../utils"; +import type { TabId } from "../dock/store"; +import type { LogTabStore } from "./tab-store"; +import logTabStoreInjectable from "./tab-store.injectable"; + +interface Dependencies { + logTabStore: LogTabStore; +} + +function clearLogTabData({ logTabStore }: Dependencies, tabId: TabId): void { + logTabStore.clearData(tabId); +} + +const clearLogTabDataInjectable = getInjectable({ + instantiate: (di) => bind(clearLogTabData, null, { + logTabStore: di.inject(logTabStoreInjectable), + }), + lifecycle: lifecycleEnum.singleton, +}); + +export default clearLogTabDataInjectable; diff --git a/src/renderer/components/dock/log-controls.scss b/src/renderer/components/dock/logs/controls.scss similarity index 100% rename from src/renderer/components/dock/log-controls.scss rename to src/renderer/components/dock/logs/controls.scss diff --git a/src/renderer/components/dock/logs/controls.tsx b/src/renderer/components/dock/logs/controls.tsx new file mode 100644 index 0000000000..59aead384b --- /dev/null +++ b/src/renderer/components/dock/logs/controls.tsx @@ -0,0 +1,94 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import "./controls.scss"; + +import React from "react"; +import { observer } from "mobx-react"; + +import { cssNames } from "../../../utils"; +import { Checkbox } from "../../checkbox"; +import { Icon } from "../../icon"; +import type { LogTabViewModel } from "./logs-view-model"; +import { withInjectables } from "@ogre-tools/injectable-react"; +import openSaveFileDialogInjectable from "../../../utils/save-file.injectable"; + +export interface LogControlsProps { + model: LogTabViewModel; +} + +interface Dependencies { + openSaveFileDialog: (filename: string, contents: BlobPart | BlobPart[], type: string) => void; +} + +const NonInjectedLogControls = observer(({ openSaveFileDialog, model }: Dependencies & LogControlsProps) => { + const tabData = model.logTabData.get(); + + if (!tabData) { + return null; + } + + const logs = model.timestampSplitLogs.get(); + const { showTimestamps, showPrevious: previous } = tabData; + const since = logs.length ? logs[0][0] : null; + + const toggleTimestamps = () => { + model.updateLogTabData({ showTimestamps: !showTimestamps }); + }; + + const togglePrevious = () => { + model.updateLogTabData({ showPrevious: !previous }); + model.reloadLogs(); + }; + + const downloadLogs = () => { + const fileName = model.pod.get().getName(); + const logsToDownload: string[] = showTimestamps + ? model.logs.get() + : model.logsWithoutTimestamps.get(); + + openSaveFileDialog(`${fileName}.log`, logsToDownload.join("\n"), "text/plain"); + }; + + return ( +
    +
    + {since && ( + + Logs from{" "} + {new Date(since[0]).toLocaleString()} + + )} +
    +
    + + + +
    +
    + ); +}); + +export const LogControls = withInjectables(NonInjectedLogControls, { + getProps: (di, props) => ({ + openSaveFileDialog: di.inject(openSaveFileDialogInjectable), + ...props, + }), +}); diff --git a/src/renderer/components/dock/logs/create-pod-tab.injectable.ts b/src/renderer/components/dock/logs/create-pod-tab.injectable.ts new file mode 100644 index 0000000000..656c5709ef --- /dev/null +++ b/src/renderer/components/dock/logs/create-pod-tab.injectable.ts @@ -0,0 +1,36 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import type { Pod, IPodContainer } from "../../../../common/k8s-api/endpoints"; +import { bind } from "../../../utils"; +import type { TabId } from "../dock/store"; +import createLogsTabInjectable, { CreateLogsTabData } from "./create-tab.injectable"; + +export interface PodLogsTabData { + selectedPod: Pod; + selectedContainer: IPodContainer; +} + +interface Dependencies { + createLogsTab: (title: string, data: CreateLogsTabData) => TabId; +} + +function createPodLogsTab({ createLogsTab }: Dependencies, { selectedPod, selectedContainer }: PodLogsTabData): TabId { + return createLogsTab(`Pod ${selectedPod.getName()}`, { + owner: selectedPod.getOwnerRefs()[0], + namespace: selectedPod.getNs(), + selectedContainer: selectedContainer.name, + selectedPodId: selectedPod.getId(), + }); +} + +const createPodLogsTabInjectable = getInjectable({ + instantiate: (di) => bind(createPodLogsTab, null, { + createLogsTab: di.inject(createLogsTabInjectable), + }), + lifecycle: lifecycleEnum.singleton, +}); + +export default createPodLogsTabInjectable; diff --git a/src/renderer/components/dock/logs/create-search-store.injectable.ts b/src/renderer/components/dock/logs/create-search-store.injectable.ts new file mode 100644 index 0000000000..3ed42e920a --- /dev/null +++ b/src/renderer/components/dock/logs/create-search-store.injectable.ts @@ -0,0 +1,13 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { LogSearchStore } from "./search-store"; + +const createSearchStoreInjectable = getInjectable({ + instantiate: () => () => new LogSearchStore(), + lifecycle: lifecycleEnum.singleton, +}); + +export default createSearchStoreInjectable; diff --git a/src/renderer/components/dock/logs/create-tab.injectable.ts b/src/renderer/components/dock/logs/create-tab.injectable.ts new file mode 100644 index 0000000000..7b1c0df010 --- /dev/null +++ b/src/renderer/components/dock/logs/create-tab.injectable.ts @@ -0,0 +1,48 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { bind } from "../../../utils"; +import { DockTabCreate, DockTabData, TabKind, TabId, DockTabCreateOptions } from "../dock/store"; +import type { LogTabData } from "./tab-store"; +import * as uuid from "uuid"; +import { runInAction } from "mobx"; +import createDockTabInjectable from "../dock/create-tab.injectable"; +import setLogTabDataInjectable from "./set-log-tab-data.injectable"; + +export type CreateLogsTabData = Pick & Omit, "owner" | "selectedPodId" | "selectedContainer" | "namespace">; + +interface Dependencies { + createDockTab: (rawTabDesc: DockTabCreate, opts?: DockTabCreateOptions) => DockTabData; + setLogTabData: (tabId: string, data: LogTabData) => void; +} + +function createLogsTab({ createDockTab, setLogTabData }: Dependencies, title: string, data: CreateLogsTabData): TabId { + const id = `log-tab-${uuid.v4()}`; + + runInAction(() => { + createDockTab({ + id, + title, + kind: TabKind.POD_LOGS, + }); + setLogTabData(id, { + showTimestamps: false, + showPrevious: false, + ...data, + }); + }); + + return id; +} + +const createLogsTabInjectable = getInjectable({ + instantiate: (di) => bind(createLogsTab, null, { + createDockTab: di.inject(createDockTabInjectable), + setLogTabData: di.inject(setLogTabDataInjectable), + }), + lifecycle: lifecycleEnum.singleton, +}); + +export default createLogsTabInjectable; diff --git a/src/renderer/components/dock/logs/create-workload-tab.injectable.ts b/src/renderer/components/dock/logs/create-workload-tab.injectable.ts new file mode 100644 index 0000000000..d30926a189 --- /dev/null +++ b/src/renderer/components/dock/logs/create-workload-tab.injectable.ts @@ -0,0 +1,51 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import getPodsByOwnerIdInjectable from "../../+pods/get-pods-by-owner-id.injectable"; +import type { Pod } from "../../../../common/k8s-api/endpoints"; +import type { WorkloadKubeObject } from "../../../../common/k8s-api/workload-kube-object"; +import { bind } from "../../../utils"; +import type { TabId } from "../dock/store"; +import createLogsTabInjectable, { CreateLogsTabData } from "./create-tab.injectable"; + +export interface WorkloadLogsTabData { + workload: WorkloadKubeObject +} + +interface Dependencies { + createLogsTab: (title: string, data: CreateLogsTabData) => TabId; + getPodsByOwnerId: (id: string) => Pod[]; +} + +function createWorkloadLogsTab({ createLogsTab, getPodsByOwnerId }: Dependencies, { workload }: WorkloadLogsTabData): TabId | undefined { + const pods = getPodsByOwnerId(workload.getId()); + + if (pods.length === 0) { + return undefined; + } + + const selectedPod = pods[0]; + + return createLogsTab(`${workload.kind} ${selectedPod.getName()}`, { + selectedContainer: selectedPod.getAllContainers()[0].name, + selectedPodId: selectedPod.getId(), + namespace: selectedPod.getNs(), + owner: { + kind: workload.kind, + name: workload.getName(), + uid: workload.getId(), + }, + }); +} + +const createWorkloadLogsTabInjectable = getInjectable({ + instantiate: (di) => bind(createWorkloadLogsTab, null, { + createLogsTab: di.inject(createLogsTabInjectable), + getPodsByOwnerId: di.inject(getPodsByOwnerIdInjectable), + }), + lifecycle: lifecycleEnum.singleton, +}); + +export default createWorkloadLogsTabInjectable; diff --git a/src/renderer/components/dock/logs/get-log-tab-data.injectable.ts b/src/renderer/components/dock/logs/get-log-tab-data.injectable.ts new file mode 100644 index 0000000000..8a8dde1368 --- /dev/null +++ b/src/renderer/components/dock/logs/get-log-tab-data.injectable.ts @@ -0,0 +1,25 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { bind } from "../../../utils"; +import type { LogTabData, LogTabStore } from "./tab-store"; +import logTabStoreInjectable from "./tab-store.injectable"; + +interface Dependencies { + logTabStore: LogTabStore; +} + +function getLogTabData({ logTabStore }: Dependencies, tabId: string): LogTabData { + return logTabStore.getData(tabId); +} + +const getLogTabDataInjectable = getInjectable({ + instantiate: (di) => bind(getLogTabData, null, { + logTabStore: di.inject(logTabStoreInjectable), + }), + lifecycle: lifecycleEnum.singleton, +}); + +export default getLogTabDataInjectable; diff --git a/src/renderer/components/dock/logs/get-logs-without-timestamps.injectable.ts b/src/renderer/components/dock/logs/get-logs-without-timestamps.injectable.ts new file mode 100644 index 0000000000..cbe0596aec --- /dev/null +++ b/src/renderer/components/dock/logs/get-logs-without-timestamps.injectable.ts @@ -0,0 +1,25 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { bind } from "../../../utils"; +import type { LogStore } from "./store"; +import logStoreInjectable from "./store.injectable"; + +interface Dependencies { + logStore: LogStore; +} + +function getLogsWithoutTimestamps({ logStore }: Dependencies, tabId: string): string[] { + return logStore.getLogsWithoutTimestamps(tabId); +} + +const getLogsWithoutTimestampsInjectable = getInjectable({ + instantiate: (di) => bind(getLogsWithoutTimestamps, null, { + logStore: di.inject(logStoreInjectable), + }), + lifecycle: lifecycleEnum.singleton, +}); + +export default getLogsWithoutTimestampsInjectable; diff --git a/src/renderer/components/dock/logs/get-logs.injectable.ts b/src/renderer/components/dock/logs/get-logs.injectable.ts new file mode 100644 index 0000000000..9337a465a1 --- /dev/null +++ b/src/renderer/components/dock/logs/get-logs.injectable.ts @@ -0,0 +1,25 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { bind } from "../../../utils"; +import type { LogStore } from "./store"; +import logStoreInjectable from "./store.injectable"; + +interface Dependencies { + logStore: LogStore; +} + +function getLogs({ logStore }: Dependencies, tabId: string): string[] { + return logStore.getLogs(tabId); +} + +const getLogsInjectable = getInjectable({ + instantiate: (di) => bind(getLogs, null, { + logStore: di.inject(logStoreInjectable), + }), + lifecycle: lifecycleEnum.singleton, +}); + +export default getLogsInjectable; diff --git a/src/renderer/components/dock/logs/get-timestamp-split-logs.injectable.ts b/src/renderer/components/dock/logs/get-timestamp-split-logs.injectable.ts new file mode 100644 index 0000000000..bc28d3ebf2 --- /dev/null +++ b/src/renderer/components/dock/logs/get-timestamp-split-logs.injectable.ts @@ -0,0 +1,25 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { bind } from "../../../utils"; +import type { LogStore } from "./store"; +import logStoreInjectable from "./store.injectable"; + +interface Dependencies { + logStore: LogStore; +} + +function getTimestampSplitLogs({ logStore }: Dependencies, tabId: string): [string, string][] { + return logStore.getTimestampSplitLogs(tabId); +} + +const getTimestampSplitLogsInjectable = getInjectable({ + instantiate: (di) => bind(getTimestampSplitLogs, null, { + logStore: di.inject(logStoreInjectable), + }), + lifecycle: lifecycleEnum.singleton, +}); + +export default getTimestampSplitLogsInjectable; diff --git a/src/renderer/components/dock/logs/is-logs-tab-data-valid.injectable.ts b/src/renderer/components/dock/logs/is-logs-tab-data-valid.injectable.ts new file mode 100644 index 0000000000..d0b0335f76 --- /dev/null +++ b/src/renderer/components/dock/logs/is-logs-tab-data-valid.injectable.ts @@ -0,0 +1,26 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { bind } from "../../../utils"; +import type { TabId } from "../dock/store"; +import type { LogTabStore } from "./tab-store"; +import logTabStoreInjectable from "./tab-store.injectable"; + +interface Dependencies { + logTabStore: LogTabStore; +} + +function isLogsTabDataValid({ logTabStore }: Dependencies, tabId: TabId) { + return logTabStore.isDataValid(tabId); +} + +const isLogsTabDataValidInjectable = getInjectable({ + instantiate: (di) => bind(isLogsTabDataValid, null, { + logTabStore: di.inject(logTabStoreInjectable), + }), + lifecycle: lifecycleEnum.singleton, +}); + +export default isLogsTabDataValidInjectable; diff --git a/src/renderer/components/dock/log-list.scss b/src/renderer/components/dock/logs/list.scss similarity index 100% rename from src/renderer/components/dock/log-list.scss rename to src/renderer/components/dock/logs/list.scss diff --git a/src/renderer/components/dock/logs/list.tsx b/src/renderer/components/dock/logs/list.tsx new file mode 100644 index 0000000000..a2dd2d02a1 --- /dev/null +++ b/src/renderer/components/dock/logs/list.tsx @@ -0,0 +1,234 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import "./list.scss"; + +import React, { ForwardedRef, forwardRef, useEffect, useImperativeHandle, useRef, useState } from "react"; +import AnsiUp from "ansi_up"; +import DOMPurify from "dompurify"; +import debounce from "lodash/debounce"; +import { reaction, IComputedValue } from "mobx"; +import { observer } from "mobx-react"; +import moment from "moment-timezone"; +import type { Align, ListOnScrollProps } from "react-window"; +import { array, cssNames, disposer } from "../../../utils"; +import { VirtualList } from "../../virtual-list"; +import { ToBottom } from "./to-bottom"; +import type { LogTabViewModel } from "../logs/logs-view-model"; +import { Spinner } from "../../spinner"; +import { escapeRegexForSearch } from "./search-store"; +import { withInjectables } from "@ogre-tools/injectable-react"; +import localeTimezoneInjectable from "../../locale-date/locale-timezone.injectable"; + +export interface LogListProps { + model: LogTabViewModel; +} + +interface Dependencies { + localeTimezone: IComputedValue; +} + +const colorConverter = new AnsiUp(); +const lineHeight = 18; + +export interface LogListRef { + scrollToItem: (index: number, align: Align) => void; +} + +const NonInjectedLogList = observer(forwardRef(({ localeTimezone, model }: Dependencies & LogListProps, ref: ForwardedRef) => { + const [isJumpButtonVisible, setIsJumpButtonVisible] = useState(false); + const [isLastLineVisible, setIsLastLineVisible] = useState(true); + const virtualListDiv = useRef(); + const virtualListRef = useRef(); + const logs = model.logs.get(); + + const checkLoadIntent = (props: ListOnScrollProps) => { + const { scrollOffset } = props; + + if (scrollOffset === 0) { + model.loadLogs(); + } + }; + const scrollToBottom = () => { + if (virtualListDiv.current) { + virtualListDiv.current.scrollTop = virtualListDiv.current.scrollHeight; + } + }; + const scrollToItem = (index: number, align: Align) => { + virtualListRef.current?.scrollToItem(index, align); + }; + + // Increasing performance and giving some time for virtual list to settle down + const onScrollDebounced = debounce((props: ListOnScrollProps) => { + if (!virtualListDiv.current) return; + setButtonVisibility(props); + setLastLineVisibility(props); + checkLoadIntent(props); + }, 700); + const onScroll = (props: ListOnScrollProps) => { + setIsLastLineVisible(false); + onScrollDebounced(props); + }; + + useImperativeHandle(ref, () => ({ + scrollToItem, + })); + + /** + * A function is called by VirtualList for rendering each of the row + * @param rowIndex index of the log element in logs array + * @returns A react element with a row itself + */ + const getLogRow = (rowIndex: number) => { + const { searchQuery, isActiveOverlay } = model.searchStore; + const item = logs[rowIndex]; + const contents: React.ReactElement[] = []; + const ansiToHtml = (ansi: string) => DOMPurify.sanitize(colorConverter.ansi_to_html(ansi)); + + if (searchQuery) { + // If search is enabled, replace keyword with backgrounded + // Case-insensitive search (lowercasing query and keywords in line) + const regex = escapeRegexForSearch(searchQuery); + const matches = item.matchAll(regex); + const modified = item.replace(regex, match => match.toLowerCase()); + // Splitting text line by keyword + const pieces = modified.split(searchQuery.toLowerCase()); + + pieces.forEach((piece, index) => { + const active = isActiveOverlay(rowIndex, index); + const lastItem = index === pieces.length - 1; + const overlayValue = matches.next().value; + const overlay = !lastItem + ? + : null; + + contents.push( + + + {overlay} + , + ); + }); + } + + return ( +
    + {contents.length > 1 ? contents : ( + + )} + {/* For preserving copy-paste experience and keeping line breaks */} +
    +
    + ); + }; + const onLogsInitialLoad = (logs: string[], prevLogs: string[]) => { + if (!prevLogs.length && logs.length) { + setIsLastLineVisible(true); + } + }; + const onLogsUpdate = () => { + if (isLastLineVisible) { + setTimeout(() => { + scrollToBottom(); + }, 500); // Giving some time to VirtualList to prepare its outerRef (this.virtualListDiv) element + } + }; + const onUserScrolledUp = (logs: string[], prevLogs: string[]) => { + if (!virtualListDiv.current) return; + + const newLogsAdded = prevLogs.length < logs.length; + const scrolledToBeginning = virtualListDiv.current.scrollTop === 0; + + if (newLogsAdded && scrolledToBeginning) { + const firstLineContents = prevLogs[0]; + const lineToScroll = logs.findIndex((value) => value == firstLineContents); + + if (lineToScroll !== -1) { + scrollToItem(lineToScroll, "start"); + } + } + }; + const setButtonVisibility = (props: ListOnScrollProps) => { + const offset = 100 * lineHeight; + const { scrollHeight } = virtualListDiv.current; + const { scrollOffset } = props; + + setIsJumpButtonVisible(scrollHeight - scrollOffset >= offset); + }; + const setLastLineVisibility = (props: ListOnScrollProps) => { + const { scrollHeight, clientHeight } = virtualListDiv.current; + const { scrollOffset } = props; + + setIsLastLineVisible(clientHeight + scrollOffset === scrollHeight); + }; + + useEffect(() => disposer( + reaction(() => logs, (logs, prevLogs) => { + onLogsInitialLoad(logs, prevLogs); + onLogsUpdate(); + onUserScrolledUp(logs, prevLogs); + }), + ), []); + + const logLines = (() => { + const logTabData = model.logTabData.get(); + const timezone = localeTimezone.get(); + + if (!logTabData?.showTimestamps) { + return model.logsWithoutTimestamps.get(); + } + + return model.timestampSplitLogs + .get() + .map(([logTimestamp, log]) => (`${logTimestamp && moment.tz(logTimestamp, timezone).format()}${log}`)); + })(); + + const isLoading = model.isLoading.get(); + const isInitLoading = isLoading && !logLines.length; + const rowHeights = array.filled(logLines.length, lineHeight); + + if (isInitLoading) { + return ( +
    + +
    + ); + } + + if (!logLines.length) { + return ( +
    + There are no logs available for container +
    + ); + } + + return ( +
    + + {isJumpButtonVisible && ( + + )} +
    + ); +})); + +export const LogList = withInjectables(NonInjectedLogList, { + getProps: (di, props) => ({ + localeTimezone: di.inject(localeTimezoneInjectable), + ...props, + }), +}); diff --git a/src/renderer/components/dock/logs/load-logs.injectable.ts b/src/renderer/components/dock/logs/load-logs.injectable.ts new file mode 100644 index 0000000000..ea74b93505 --- /dev/null +++ b/src/renderer/components/dock/logs/load-logs.injectable.ts @@ -0,0 +1,28 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import type { IComputedValue } from "mobx"; +import type { Pod } from "../../../../common/k8s-api/endpoints"; +import { bind } from "../../../utils"; +import type { LogStore } from "./store"; +import logStoreInjectable from "./store.injectable"; +import type { LogTabData } from "./tab-store"; + +interface Dependencies { + logStore: LogStore; +} + +function loadLogs({ logStore }: Dependencies, tabId: string, pod: IComputedValue, logTabData: IComputedValue): Promise { + return logStore.load(tabId, pod, logTabData); +} + +const loadLogsInjectable = getInjectable({ + instantiate: (di) => bind(loadLogs, null, { + logStore: di.inject(logStoreInjectable), + }), + lifecycle: lifecycleEnum.singleton, +}); + +export default loadLogsInjectable; diff --git a/src/renderer/components/dock/logs/log-tab-data.validator.ts b/src/renderer/components/dock/logs/log-tab-data.validator.ts new file mode 100644 index 0000000000..32baf38fbb --- /dev/null +++ b/src/renderer/components/dock/logs/log-tab-data.validator.ts @@ -0,0 +1,38 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import Joi from "joi"; +import type { LogTabData, LogTabOwnerRef } from "./tab-store"; + +export const logTabDataValidator = Joi.object({ + owner: Joi + .object({ + uid: Joi + .string() + .required(), + name: Joi + .string() + .required(), + kind: Joi + .string() + .required(), + }) + .unknown(true) + .optional(), + selectedPodId: Joi + .string() + .required(), + namespace: Joi + .string() + .required(), + selectedContainer: Joi + .string() + .optional(), + showTimestamps: Joi + .boolean() + .required(), + showPrevious: Joi + .boolean() + .required(), +}); diff --git a/src/renderer/components/dock/logs/logs-view-model.injectable.ts b/src/renderer/components/dock/logs/logs-view-model.injectable.ts new file mode 100644 index 0000000000..812146a774 --- /dev/null +++ b/src/renderer/components/dock/logs/logs-view-model.injectable.ts @@ -0,0 +1,45 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { LogTabViewModel } from "./logs-view-model"; +import type { TabId } from "../dock/store"; +import getLogsInjectable from "./get-logs.injectable"; +import getLogsWithoutTimestampsInjectable from "./get-logs-without-timestamps.injectable"; +import getTimestampSplitLogsInjectable from "./get-timestamp-split-logs.injectable"; +import reloadLogsInjectable from "./reload-logs.injectable"; +import getLogTabDataInjectable from "./get-log-tab-data.injectable"; +import loadLogsInjectable from "./load-logs.injectable"; +import setLogTabDataInjectable from "./set-log-tab-data.injectable"; +import stopLoadingLogsInjectable from "./stop-loading-logs.injectable"; +import renameTabInjectable from "../dock/rename-tab.injectable"; +import areLogsPresentInjectable from "./are-logs-present.injectable"; +import getPodByIdInjectable from "../../+pods/get-pod-by-id.injectable"; +import getPodsByOwnerIdInjectable from "../../+pods/get-pods-by-owner-id.injectable"; +import createSearchStoreInjectable from "./create-search-store.injectable"; + +export interface InstantiateArgs { + tabId: TabId; +} + +const logsViewModelInjectable = getInjectable({ + instantiate: (di, { tabId }: InstantiateArgs) => new LogTabViewModel(tabId, { + getLogs: di.inject(getLogsInjectable), + getLogsWithoutTimestamps: di.inject(getLogsWithoutTimestampsInjectable), + getTimestampSplitLogs: di.inject(getTimestampSplitLogsInjectable), + reloadLogs: di.inject(reloadLogsInjectable), + getLogTabData: di.inject(getLogTabDataInjectable), + setLogTabData: di.inject(setLogTabDataInjectable), + loadLogs: di.inject(loadLogsInjectable), + renameTab: di.inject(renameTabInjectable), + stopLoadingLogs: di.inject(stopLoadingLogsInjectable), + areLogsPresent: di.inject(areLogsPresentInjectable), + getPodById: di.inject(getPodByIdInjectable), + getPodsByOwnerId: di.inject(getPodsByOwnerIdInjectable), + createSearchStore: di.inject(createSearchStoreInjectable), + }), + lifecycle: lifecycleEnum.transient, +}); + +export default logsViewModelInjectable; diff --git a/src/renderer/components/dock/logs/logs-view-model.ts b/src/renderer/components/dock/logs/logs-view-model.ts new file mode 100644 index 0000000000..495126a724 --- /dev/null +++ b/src/renderer/components/dock/logs/logs-view-model.ts @@ -0,0 +1,67 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import type { LogTabData } from "./tab-store"; +import { computed, IComputedValue } from "mobx"; +import type { TabId } from "../dock/store"; +import type { LogSearchStore } from "./search-store"; +import type { Pod } from "../../../../common/k8s-api/endpoints"; + +export interface LogTabViewModelDependencies { + getLogs: (tabId: TabId) => string[]; + getLogsWithoutTimestamps: (tabId: TabId) => string[]; + getTimestampSplitLogs: (tabId: TabId) => [string, string][]; + getLogTabData: (tabId: TabId) => LogTabData; + setLogTabData: (tabId: TabId, data: LogTabData) => void; + loadLogs: (tabId: TabId, pod: IComputedValue, logTabData: IComputedValue) => Promise; + reloadLogs: (tabId: TabId, pod: IComputedValue, logTabData: IComputedValue) => Promise; + renameTab: (tabId: TabId, title: string) => void; + stopLoadingLogs: (tabId: TabId) => void; + getPodById: (id: string) => Pod | undefined; + getPodsByOwnerId: (id: string) => Pod[]; + areLogsPresent: (tabId: TabId) => boolean; + createSearchStore: () => LogSearchStore; +} + +export class LogTabViewModel { + constructor(protected readonly tabId: TabId, private readonly dependencies: LogTabViewModelDependencies) {} + + readonly isLoading = computed(() => this.dependencies.areLogsPresent(this.tabId)); + readonly logs = computed(() => this.dependencies.getLogs(this.tabId)); + readonly logsWithoutTimestamps = computed(() => this.dependencies.getLogsWithoutTimestamps(this.tabId)); + readonly timestampSplitLogs = computed(() => this.dependencies.getTimestampSplitLogs(this.tabId)); + readonly logTabData = computed(() => this.dependencies.getLogTabData(this.tabId)); + readonly pods = computed(() => { + const data = this.logTabData.get(); + + if (!data) { + return []; + } + + if (typeof data.owner?.uid === "string") { + return this.dependencies.getPodsByOwnerId(data.owner.uid); + } + + return [this.dependencies.getPodById(data.selectedPodId)]; + }); + readonly pod = computed(() => { + const data = this.logTabData.get(); + + if (!data) { + return undefined; + } + + return this.dependencies.getPodById(data.selectedPodId); + }); + readonly searchStore = this.dependencies.createSearchStore(); + + updateLogTabData = (partialData: Partial) => { + this.dependencies.setLogTabData(this.tabId, { ...this.logTabData.get(), ...partialData }); + }; + + loadLogs = () => this.dependencies.loadLogs(this.tabId, this.pod, this.logTabData); + reloadLogs = () => this.dependencies.reloadLogs(this.tabId, this.pod, this.logTabData); + renameTab = (title: string) => this.dependencies.renameTab(this.tabId, title); + stopLoadingLogs = () => this.dependencies.stopLoadingLogs(this.tabId); +} diff --git a/src/renderer/components/dock/logs/logs-view-model/logs-view-model.injectable.ts b/src/renderer/components/dock/logs/logs-view-model/logs-view-model.injectable.ts deleted file mode 100644 index c3a11048fe..0000000000 --- a/src/renderer/components/dock/logs/logs-view-model/logs-view-model.injectable.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ -import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; -import dockStoreInjectable from "../../dock-store/dock-store.injectable"; -import logTabStoreInjectable from "../../log-tab-store/log-tab-store.injectable"; -import reloadedLogStoreInjectable from "../../log-store/reloaded-log-store.injectable"; -import { LogsViewModel } from "./logs-view-model"; - -const logsViewModelInjectable = getInjectable({ - instantiate: async (di) => new LogsViewModel({ - dockStore: di.inject(dockStoreInjectable), - logTabStore: di.inject(logTabStoreInjectable), - logStore: await di.inject(reloadedLogStoreInjectable), - }), - - lifecycle: lifecycleEnum.singleton, -}); - -export default logsViewModelInjectable; diff --git a/src/renderer/components/dock/logs/logs-view-model/logs-view-model.ts b/src/renderer/components/dock/logs/logs-view-model/logs-view-model.ts deleted file mode 100644 index 1bb54f65dd..0000000000 --- a/src/renderer/components/dock/logs/logs-view-model/logs-view-model.ts +++ /dev/null @@ -1,44 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ -import type { LogTabData, LogTabStore } from "../../log-tab-store/log-tab.store"; -import type { LogStore } from "../../log-store/log.store"; -import { computed, makeObservable } from "mobx"; - -interface Dependencies { - dockStore: { selectedTabId: string }, - logTabStore: LogTabStore - logStore: LogStore -} - -export class LogsViewModel { - constructor(private dependencies: Dependencies) { - makeObservable(this, { - logs: computed, - logsWithoutTimestamps: computed, - tabs: computed, - tabId: computed, - }); - } - - get logs() { - return this.dependencies.logStore.logs; - } - - get logsWithoutTimestamps() { - return this.dependencies.logStore.logsWithoutTimestamps; - } - - get tabs() { - return this.dependencies.logTabStore.tabs; - } - - get tabId() { - return this.dependencies.dockStore.selectedTabId; - } - - saveTab = (newTabs: LogTabData) => { - this.dependencies.logTabStore.setData(this.tabId, { ...this.tabs, ...newTabs }); - }; -} diff --git a/src/renderer/components/dock/logs/reload-logs.injectable.ts b/src/renderer/components/dock/logs/reload-logs.injectable.ts new file mode 100644 index 0000000000..9081e34c91 --- /dev/null +++ b/src/renderer/components/dock/logs/reload-logs.injectable.ts @@ -0,0 +1,28 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import type { IComputedValue } from "mobx"; +import type { Pod } from "../../../../common/k8s-api/endpoints"; +import { bind } from "../../../utils"; +import type { LogStore } from "./store"; +import logStoreInjectable from "./store.injectable"; +import type { LogTabData } from "./tab-store"; + +interface Dependencies { + logStore: LogStore; +} + +function reloadLogs({ logStore }: Dependencies, tabId: string, pod: IComputedValue, logTabData: IComputedValue): Promise { + return logStore.reload(tabId, pod, logTabData); +} + +const reloadLogsInjectable = getInjectable({ + instantiate: (di) => bind(reloadLogs, null, { + logStore: di.inject(logStoreInjectable), + }), + lifecycle: lifecycleEnum.singleton, +}); + +export default reloadLogsInjectable; diff --git a/src/renderer/components/dock/log-resource-selector.scss b/src/renderer/components/dock/logs/resource-selector.scss similarity index 100% rename from src/renderer/components/dock/log-resource-selector.scss rename to src/renderer/components/dock/logs/resource-selector.scss diff --git a/src/renderer/components/dock/logs/resource-selector.tsx b/src/renderer/components/dock/logs/resource-selector.tsx new file mode 100644 index 0000000000..459069fd61 --- /dev/null +++ b/src/renderer/components/dock/logs/resource-selector.tsx @@ -0,0 +1,106 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import "./resource-selector.scss"; + +import React from "react"; +import { observer } from "mobx-react"; + +import { Badge } from "../../badge"; +import { Select, SelectOption } from "../../select"; +import type { LogTabViewModel } from "./logs-view-model"; +import type { IPodContainer, Pod } from "../../../../common/k8s-api/endpoints"; + +export interface LogResourceSelectorProps { + model: LogTabViewModel; +} + +function getSelectOptions(containers: IPodContainer[]): SelectOption[] { + return containers.map(container => ({ + value: container.name, + label: container.name, + })); +} + +export const LogResourceSelector = observer(({ model }: LogResourceSelectorProps) => { + const tabData = model.logTabData.get(); + + if (!tabData) { + return null; + } + + const { selectedContainer, owner } = tabData; + const pods = model.pods.get(); + const pod = model.pod.get(); + + if (!pod) { + return null; + } + + const onContainerChange = (option: SelectOption) => { + model.updateLogTabData({ + selectedContainer: option.value, + }); + model.reloadLogs(); + }; + + const onPodChange = ({ value }: SelectOption) => { + model.updateLogTabData({ + selectedPodId: value.getId(), + selectedContainer: value.getAllContainers()[0]?.name, + }); + model.renameTab(`Pod ${value.getName()}`); + model.reloadLogs(); + }; + + const containerSelectOptions = [ + { + label: "Containers", + options: getSelectOptions(pod.getContainers()), + }, + { + label: "Init Containers", + options: getSelectOptions(pod.getInitContainers()), + }, + ]; + + const podSelectOptions = pods.map(pod => ({ + label: pod.getName(), + value: pod, + })); + + return ( +
    + Namespace + { + owner && ( + <> + Owner + + ) + } + Pod + +
    + ); +}); + diff --git a/src/renderer/search-store/search-store.ts b/src/renderer/components/dock/logs/search-store.ts similarity index 83% rename from src/renderer/search-store/search-store.ts rename to src/renderer/components/dock/logs/search-store.ts index 5b4e4f5c7a..1c02cfc69f 100644 --- a/src/renderer/search-store/search-store.ts +++ b/src/renderer/components/dock/logs/search-store.ts @@ -3,23 +3,24 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import { action, computed, observable, reaction, makeObservable } from "mobx"; -import type { DockStore } from "../components/dock/dock-store/dock.store"; -import { boundMethod } from "../utils"; +import { action, computed, observable, makeObservable } from "mobx"; +import { boundMethod } from "../../../utils"; -interface Dependencies { - dockStore: DockStore -} +const escapingRegex = /[-[\]{}()*+?.,\\^$|#\s]/g; -export class SearchStore { - /** - * An utility methods escaping user string to safely pass it into new Regex(variable) - * @param value Unescaped string - */ - public static escapeRegex(value?: string): string { - return value ? value.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, "\\$&") : ""; +/** + * An utility methods escaping user string to safely pass it into new Regex(variable) + * @param value Unescaped string + */ +export function escapeRegexForSearch(value: string | undefined): RegExp { + if (value) { + return new RegExp(value.replace(escapingRegex, "\\$"), "gi"); } + return new RegExp(""); +} + +export class LogSearchStore { /** * Text in the search input * @@ -41,11 +42,8 @@ export class SearchStore { */ @observable activeOverlayIndex = -1; - constructor(dependencies: Dependencies) { + constructor() { makeObservable(this); - reaction(() => dependencies.dockStore.selectedTabId, () => { - this.reset(); - }); } /** @@ -81,7 +79,7 @@ export class SearchStore { * @returns Array of line indexes [0, 0, 14, 17, 17, 17, 20...] */ private findOccurrences(lines: string[], query?: string): number[] { - const regex = new RegExp(SearchStore.escapeRegex(query), "gi"); + const regex = escapeRegexForSearch(query); return lines .flatMap((line, index) => Array.from(line.matchAll(regex), () => index)); diff --git a/src/renderer/components/dock/log-search.scss b/src/renderer/components/dock/logs/search.scss similarity index 100% rename from src/renderer/components/dock/log-search.scss rename to src/renderer/components/dock/logs/search.scss diff --git a/src/renderer/components/dock/log-search.tsx b/src/renderer/components/dock/logs/search.tsx similarity index 57% rename from src/renderer/components/dock/log-search.tsx rename to src/renderer/components/dock/logs/search.tsx index af35311750..ae633f2a5d 100644 --- a/src/renderer/components/dock/log-search.tsx +++ b/src/renderer/components/dock/logs/search.tsx @@ -3,53 +3,47 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import "./log-search.scss"; +import "./search.scss"; import React, { useEffect } from "react"; import { observer } from "mobx-react"; -import { SearchInput } from "../input"; -import type { SearchStore } from "../../search-store/search-store"; -import { Icon } from "../icon"; -import { withInjectables } from "@ogre-tools/injectable-react"; -import searchStoreInjectable from "../../search-store/search-store.injectable"; +import { SearchInput } from "../../input"; +import { Icon } from "../../icon"; +import type { LogTabViewModel } from "./logs-view-model"; export interface PodLogSearchProps { - onSearch: (query: string) => void - toPrevOverlay: () => void - toNextOverlay: () => void + onSearch?: (query: string) => void; + scrollToOverlay: (lineNumber: number | undefined) => void; + model: LogTabViewModel; } -interface Props extends PodLogSearchProps { - logs: string[] -} +export const LogSearch = observer(({ onSearch, scrollToOverlay, model: { logTabData, searchStore, ...model }}: PodLogSearchProps) => { + const tabData = logTabData.get(); -interface Dependencies { - searchStore: SearchStore -} + if (!tabData) { + return null; + } -const NonInjectedLogSearch = observer((props: Props & Dependencies) => { - const { logs, onSearch, toPrevOverlay, toNextOverlay, searchStore } = props; + const logs = tabData.showTimestamps + ? model.logs.get() + : model.logsWithoutTimestamps.get(); const { setNextOverlayActive, setPrevOverlayActive, searchQuery, occurrences, activeFind, totalFinds } = searchStore; const jumpDisabled = !searchQuery || !occurrences.length; - const findCounts = ( -
    - {activeFind}/{totalFinds} -
    - ); const setSearch = (query: string) => { searchStore.onSearch(logs, query); - onSearch(query); + onSearch?.(query); + scrollToOverlay(searchStore.activeOverlayLine); }; const onPrevOverlay = () => { setPrevOverlayActive(); - toPrevOverlay(); + scrollToOverlay(searchStore.activeOverlayLine); }; const onNextOverlay = () => { setNextOverlayActive(); - toNextOverlay(); + scrollToOverlay(searchStore.activeOverlayLine); }; const onClear = () => { @@ -58,7 +52,11 @@ const NonInjectedLogSearch = observer((props: Props & Dependencies) => { const onKeyDown = (evt: React.KeyboardEvent) => { if (evt.key === "Enter") { - onNextOverlay(); + if (evt.shiftKey) { + onPrevOverlay(); + } else { + onNextOverlay(); + } } }; @@ -73,7 +71,11 @@ const NonInjectedLogSearch = observer((props: Props & Dependencies) => { value={searchQuery} onChange={setSearch} showClearIcon={true} - contentRight={totalFinds > 0 && findCounts} + contentRight={totalFinds > 0 && ( +
    + {activeFind}/{totalFinds} +
    + )} onClear={onClear} onKeyDown={onKeyDown} /> @@ -92,14 +94,3 @@ const NonInjectedLogSearch = observer((props: Props & Dependencies) => { ); }); - -export const LogSearch = withInjectables( - NonInjectedLogSearch, - - { - getProps: (di, props) => ({ - searchStore: di.inject(searchStoreInjectable), - ...props, - }), - }, -); diff --git a/src/renderer/components/dock/logs/set-log-tab-data.injectable.ts b/src/renderer/components/dock/logs/set-log-tab-data.injectable.ts new file mode 100644 index 0000000000..598f4a9168 --- /dev/null +++ b/src/renderer/components/dock/logs/set-log-tab-data.injectable.ts @@ -0,0 +1,25 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { bind } from "../../../utils"; +import type { LogTabData, LogTabStore } from "./tab-store"; +import logTabStoreInjectable from "./tab-store.injectable"; + +interface Dependencies { + logTabStore: LogTabStore; +} + +function setLogTabData({ logTabStore }: Dependencies, tabId: string, data: LogTabData): void { + return logTabStore.setData(tabId, data); +} + +const setLogTabDataInjectable = getInjectable({ + instantiate: (di) => bind(setLogTabData, null, { + logTabStore: di.inject(logTabStoreInjectable), + }), + lifecycle: lifecycleEnum.singleton, +}); + +export default setLogTabDataInjectable; diff --git a/src/renderer/components/dock/logs/stop-loading-logs.injectable.ts b/src/renderer/components/dock/logs/stop-loading-logs.injectable.ts new file mode 100644 index 0000000000..cdf8aa7576 --- /dev/null +++ b/src/renderer/components/dock/logs/stop-loading-logs.injectable.ts @@ -0,0 +1,25 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { bind } from "../../../utils"; +import type { LogStore } from "./store"; +import logStoreInjectable from "./store.injectable"; + +interface Dependencies { + logStore: LogStore; +} + +function stopLoadingLogs({ logStore }: Dependencies, tabId: string): void { + return logStore.stopLoadingLogs(tabId); +} + +const stopLoadingLogsInjectable = getInjectable({ + instantiate: (di) => bind(stopLoadingLogs, null, { + logStore: di.inject(logStoreInjectable), + }), + lifecycle: lifecycleEnum.singleton, +}); + +export default stopLoadingLogsInjectable; diff --git a/src/renderer/components/dock/log-store/log-store.injectable.ts b/src/renderer/components/dock/logs/store.injectable.ts similarity index 54% rename from src/renderer/components/dock/log-store/log-store.injectable.ts rename to src/renderer/components/dock/logs/store.injectable.ts index 0133e75225..a1f4b79de9 100644 --- a/src/renderer/components/dock/log-store/log-store.injectable.ts +++ b/src/renderer/components/dock/logs/store.injectable.ts @@ -3,15 +3,11 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; -import { LogStore } from "./log.store"; -import logTabStoreInjectable from "../log-tab-store/log-tab-store.injectable"; -import dockStoreInjectable from "../dock-store/dock-store.injectable"; -import callForLogsInjectable from "./call-for-logs/call-for-logs.injectable"; +import { LogStore } from "./store"; +import callForLogsInjectable from "./call-for-logs.injectable"; const logStoreInjectable = getInjectable({ instantiate: (di) => new LogStore({ - logTabStore: di.inject(logTabStoreInjectable), - dockStore: di.inject(dockStoreInjectable), callForLogs: di.inject(callForLogsInjectable), }), diff --git a/src/renderer/components/dock/log-store/log.store.ts b/src/renderer/components/dock/logs/store.ts similarity index 50% rename from src/renderer/components/dock/log-store/log.store.ts rename to src/renderer/components/dock/logs/store.ts index 39c751975f..fb0c446119 100644 --- a/src/renderer/components/dock/log-store/log.store.ts +++ b/src/renderer/components/dock/logs/store.ts @@ -3,49 +3,27 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import { autorun, computed, observable, makeObservable } from "mobx"; - -import { IPodLogsQuery, Pod } from "../../../../common/k8s-api/endpoints"; -import { autoBind, interval } from "../../../utils"; -import { DockStore, TabId, TabKind } from "../dock-store/dock.store"; -import type { LogTabStore } from "../log-tab-store/log-tab.store"; +import { observable, IComputedValue, when } from "mobx"; +import type { IPodLogsQuery, Pod } from "../../../../common/k8s-api/endpoints"; +import { getOrInsertWith, interval, IntervalFn } from "../../../utils"; +import type { TabId } from "../dock/store"; +import type { LogTabData } from "./tab-store"; type PodLogLine = string; const logLinesToLoad = 500; interface Dependencies { - logTabStore: LogTabStore - dockStore: DockStore callForLogs: ({ namespace, name }: { namespace: string, name: string }, query: IPodLogsQuery) => Promise } export class LogStore { - private refresher = interval(10, () => { - const id = this.dependencies.dockStore.selectedTabId; + protected podLogs = observable.map(); + protected refreshers = new Map(); - if (!this.podLogs.get(id)) return; - this.loadMore(id); - }); + constructor(private dependencies: Dependencies) {} - @observable podLogs = observable.map(); - - constructor(private dependencies: Dependencies) { - makeObservable(this); - autoBind(this); - - autorun(() => { - const { selectedTab, isOpen } = this.dependencies.dockStore; - - if (selectedTab?.kind === TabKind.POD_LOGS && isOpen) { - this.refresher.start(); - } else { - this.refresher.stop(); - } - }, { delay: 500 }); - } - - handlerError(tabId: TabId, error: any): void { + protected handlerError(tabId: TabId, error: any): void { if (error.error && !(error.message || error.reason || error.code)) { error = error.error; } @@ -55,7 +33,7 @@ export class LogStore { `Reason: ${error.reason} (${error.code})`, ]; - this.refresher.stop(); + this.stopLoadingLogs(tabId); this.podLogs.set(tabId, message); } @@ -65,20 +43,36 @@ export class LogStore { * Also, it handles loading errors, rewriting whole logs with error * messages */ - load = async () => { - const tabId = this.dependencies.dockStore.selectedTabId; - + public async load(tabId: TabId, computedPod: IComputedValue, logTabData: IComputedValue): Promise { try { - const logs = await this.loadLogs(tabId, { - tailLines: this.lines + logLinesToLoad, + const logs = await this.loadLogs(computedPod, logTabData, { + tailLines: this.getLogLines(tabId) + logLinesToLoad, }); - this.refresher.start(); + this.getRefresher(tabId, computedPod, logTabData).start(); this.podLogs.set(tabId, logs); } catch (error) { this.handlerError(tabId, error); } - }; + } + + private getRefresher(tabId: TabId, computedPod: IComputedValue, logTabData: IComputedValue): IntervalFn { + return getOrInsertWith(this.refreshers, tabId, () => ( + interval(10, () => { + if (this.podLogs.has(tabId)) { + this.loadMore(tabId, computedPod, logTabData); + } + }) + )); + } + + /** + * Stop loading more logs for a given tab + * @param tabId The ID of the logs tab to stop loading more logs for + */ + public stopLoadingLogs(tabId: TabId): void { + this.refreshers.get(tabId)?.stop(); + } /** * Function is used to refresher/stream-like requests. @@ -86,14 +80,14 @@ export class LogStore { * starting from last line received. * @param tabId */ - loadMore = async (tabId: TabId) => { + public async loadMore(tabId: TabId, computedPod: IComputedValue, logTabData: IComputedValue): Promise { if (!this.podLogs.get(tabId).length) { return; } try { const oldLogs = this.podLogs.get(tabId); - const logs = await this.loadLogs(tabId, { + const logs = await this.loadLogs(computedPod, logTabData, { sinceTime: this.getLastSinceTime(tabId), }); @@ -102,7 +96,7 @@ export class LogStore { } catch (error) { this.handlerError(tabId, error); } - }; + } /** * Main logs loading function adds necessary data to payload and makes @@ -111,48 +105,67 @@ export class LogStore { * @param params request parameters described in IPodLogsQuery interface * @returns A fetch request promise */ - async loadLogs(tabId: TabId, params: Partial): Promise { - const data = this.dependencies.logTabStore.getData(tabId); + private async loadLogs(computedPod: IComputedValue, logTabData: IComputedValue, params: Partial): Promise { + await when(() => Boolean(computedPod.get() && logTabData.get()), { timeout: 5_000 }); - const { selectedContainer, previous } = data; - const pod = new Pod(data.selectedPod); + const { selectedContainer, showPrevious } = logTabData.get(); + const pod = computedPod.get(); const namespace = pod.getNs(); const name = pod.getName(); const result = await this.dependencies.callForLogs({ namespace, name }, { ...params, timestamps: true, // Always setting timestamp to separate old logs from new ones - container: selectedContainer.name, - previous, + container: selectedContainer, + previous: showPrevious, }); return result.trimEnd().split("\n"); } /** + * @deprecated This depends on dockStore, which should be removed * Converts logs into a string array * @returns Length of log lines */ - @computed get lines(): number { return this.logs.length; } + getLogLines(tabId: TabId): number{ + return this.getLogs(tabId).length; + } - /** - * Returns logs with timestamps for selected tab - */ - @computed - get logs() { - return this.podLogs.get(this.dependencies.dockStore.selectedTabId) ?? []; + areLogsPresent(tabId: TabId): boolean { + return !this.podLogs.has(tabId); + } + + getLogs(tabId: TabId): string[]{ + return this.podLogs.get(tabId) ?? []; + } + + getLogsWithoutTimestamps(tabId: TabId): string[]{ + return this.getLogs(tabId).map(this.removeTimestamps); + } + + getTimestampSplitLogs(tabId: TabId): [string, string][]{ + return this.getLogs(tabId).map(this.splitOutTimestamp); } /** + * @deprecated This now only returns the empty array + * Returns logs with timestamps for selected tab + */ + get logs(): string[] { + return []; + } + + /** + * @deprecated This now only returns the empty array * Removes timestamps from each log line and returns changed logs * @returns Logs without timestamps */ - @computed - get logsWithoutTimestamps() { + get logsWithoutTimestamps(): string[] { return this.logs.map(item => this.removeTimestamps(item)); } @@ -161,7 +174,7 @@ export class LogStore { * (this allows to avoid getting the last stamp in the selection) * @param tabId */ - getLastSinceTime(tabId: TabId) { + getLastSinceTime(tabId: TabId): string { const logs = this.podLogs.get(tabId); const timestamps = this.getTimestamps(logs[logs.length - 1]); const stamp = new Date(timestamps ? timestamps[0] : null); @@ -181,21 +194,21 @@ export class LogStore { return [extraction[1], extraction[2]]; } - getTimestamps(logs: string) { + getTimestamps(logs: string): RegExpMatchArray { return logs.match(/^\d+\S+/gm); } - removeTimestamps(logs: string) { + removeTimestamps(logs: string): string { return logs.replace(/^\d+.*?\s/gm, ""); } - clearLogs(tabId: TabId) { + clearLogs(tabId: TabId): void { this.podLogs.delete(tabId); } - reload = async () => { - this.clearLogs(this.dependencies.dockStore.selectedTabId); + reload(tabId: TabId, computedPod: IComputedValue, logTabData: IComputedValue): Promise { + this.clearLogs(tabId); - await this.load(); - }; + return this.load(tabId, computedPod, logTabData); + } } diff --git a/src/renderer/components/dock/logs/tab-storage.injectable.ts b/src/renderer/components/dock/logs/tab-storage.injectable.ts new file mode 100644 index 0000000000..8dddd899d9 --- /dev/null +++ b/src/renderer/components/dock/logs/tab-storage.injectable.ts @@ -0,0 +1,21 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import type { StorageLayer } from "../../../utils"; +import createStorageInjectable from "../../../utils/create-storage/create-storage.injectable"; +import type { DockTabStorageState } from "../dock-tab/store"; +import type { LogTabData } from "./tab-store"; + +let storage: StorageLayer>; + +const logTabStorageInjectable = getInjectable({ + setup: async (di) => { + storage = await di.inject(createStorageInjectable)("pod_logs", {}); + }, + instantiate: () => storage, + lifecycle: lifecycleEnum.singleton, +}); + +export default logTabStorageInjectable; diff --git a/src/renderer/components/dock/log-tab-store/log-tab-store.injectable.ts b/src/renderer/components/dock/logs/tab-store.injectable.ts similarity index 55% rename from src/renderer/components/dock/log-tab-store/log-tab-store.injectable.ts rename to src/renderer/components/dock/logs/tab-store.injectable.ts index 7359d4dc5e..f95027f98a 100644 --- a/src/renderer/components/dock/log-tab-store/log-tab-store.injectable.ts +++ b/src/renderer/components/dock/logs/tab-store.injectable.ts @@ -3,14 +3,12 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; -import { LogTabStore } from "./log-tab.store"; -import dockStoreInjectable from "../dock-store/dock-store.injectable"; -import createStorageInjectable from "../../../utils/create-storage/create-storage.injectable"; +import logTabStorageInjectable from "./tab-storage.injectable"; +import { LogTabStore } from "./tab-store"; const logTabStoreInjectable = getInjectable({ instantiate: (di) => new LogTabStore({ - dockStore: di.inject(dockStoreInjectable), - createStorage: di.inject(createStorageInjectable), + storage: di.inject(logTabStorageInjectable), }), lifecycle: lifecycleEnum.singleton, diff --git a/src/renderer/components/dock/logs/tab-store.ts b/src/renderer/components/dock/logs/tab-store.ts new file mode 100644 index 0000000000..47e265ed64 --- /dev/null +++ b/src/renderer/components/dock/logs/tab-store.ts @@ -0,0 +1,70 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { DockTabStore } from "../dock-tab/store"; +import type { TabId } from "../dock/store"; +import { logTabDataValidator } from "./log-tab-data.validator"; + +export interface LogTabOwnerRef { + /** + * The uid of the owner + */ + uid: string; + /** + * The name of the owner + */ + name: string; + /** + * The kind of the owner + */ + kind: string; +} + +export interface LogTabData { + /** + * The owning workload for this logging tab + */ + owner?: LogTabOwnerRef; + + /** + * The uid of the currently selected pod + */ + selectedPodId: string; + + /** + * The namespace of the pods/workload + */ + namespace: string; + + /** + * The name of the currently selected container within the currently selected + * pod + */ + selectedContainer: string; + + /** + * Whether to show timestamps in the logs + */ + showTimestamps: boolean; + + /** + * Whether to show the logs of the previous container instance + */ + showPrevious: boolean; +} + +export class LogTabStore extends DockTabStore { + /** + * Returns true if the data for `tabId` is valid + */ + isDataValid(tabId: TabId): boolean { + if (!this.getData(tabId)) { + return true; + } + + return !logTabDataValidator.validate(this.getData(tabId)).error; + } +} + diff --git a/src/renderer/components/dock/to-bottom.tsx b/src/renderer/components/dock/logs/to-bottom.tsx similarity index 94% rename from src/renderer/components/dock/to-bottom.tsx rename to src/renderer/components/dock/logs/to-bottom.tsx index db4fd16562..99df41a85c 100644 --- a/src/renderer/components/dock/to-bottom.tsx +++ b/src/renderer/components/dock/logs/to-bottom.tsx @@ -3,7 +3,7 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ import React from "react"; -import { Icon } from "../icon"; +import { Icon } from "../../icon"; export function ToBottom({ onClick }: { onClick: () => void }) { return ( diff --git a/src/renderer/components/dock/logs/view.tsx b/src/renderer/components/dock/logs/view.tsx new file mode 100644 index 0000000000..2ca8b7eb94 --- /dev/null +++ b/src/renderer/components/dock/logs/view.tsx @@ -0,0 +1,104 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import React, { createRef, useEffect } from "react"; +import { observer } from "mobx-react"; +import { InfoPanel } from "../info-panel/info-panel"; +import { LogResourceSelector } from "./resource-selector"; +import { LogList, LogListRef } from "./list"; +import { LogSearch } from "./search"; +import { LogControls } from "./controls"; +import { withInjectables } from "@ogre-tools/injectable-react"; +import logsViewModelInjectable from "./logs-view-model.injectable"; +import type { LogTabViewModel } from "./logs-view-model"; +import type { DockTabData } from "../dock/store"; +import { cssNames, Disposer } from "../../../utils"; +import type { KubeWatchSubscribeStoreOptions } from "../../../kube-watch-api/kube-watch-api"; +import subscribeStoresInjectable from "../../../kube-watch-api/subscribe-stores.injectable"; +import type { KubeObjectStore } from "../../../../common/k8s-api/kube-object.store"; +import type { KubeObject } from "../../../../common/k8s-api/kube-object"; +import type { PodStore } from "../../+pods/store"; +import podStoreInjectable from "../../+pods/store.injectable"; + +export interface LogsDockTabProps { + className?: string; + tab: DockTabData; +} + +interface Dependencies { + model: LogTabViewModel; + subscribeStores: (stores: KubeObjectStore[], opts?: KubeWatchSubscribeStoreOptions) => Disposer; + podStore: PodStore; +} + +const NonInjectedLogsDockTab = observer(({ podStore, className, tab, model, subscribeStores }: Dependencies & LogsDockTabProps) => { + const logListElement = createRef(); + const data = model.logTabData.get(); + + useEffect(() => { + model.reloadLogs(); + + return model.stopLoadingLogs; + }, []); + useEffect(() => subscribeStores([ + podStore, + ], { + namespaces: data ? [data.namespace] : [], + }), [data?.namespace]); + + const scrollToOverlay = (overlayLine: number | undefined) => { + if (!logListElement.current || overlayLine === undefined) { + return; + } + + // Scroll vertically + logListElement.current.scrollToItem(overlayLine, "center"); + // Scroll horizontally in timeout since virtual list need some time to prepare its contents + setTimeout(() => { + const overlay = document.querySelector(".PodLogs .list span.active"); + + if (!overlay) return; + overlay.scrollIntoViewIfNeeded(); + }, 100); + }; + + if (!data) { + return null; + } + + return ( +
    + + + +
    + )} + showSubmitClose={false} + showButtons={false} + showStatusPanel={false} + /> + + + + ); +}); + + +export const LogsDockTab = withInjectables(NonInjectedLogsDockTab, { + getProps: (di, props) => ({ + model: di.inject(logsViewModelInjectable, { + tabId: props.tab.id, + }), + subscribeStores: di.inject(subscribeStoresInjectable), + podStore: di.inject(podStoreInjectable), + ...props, + }), +}); diff --git a/src/renderer/components/dock/terminal-store/terminal.store.ts b/src/renderer/components/dock/terminal-store/terminal.store.ts deleted file mode 100644 index 9203edb87b..0000000000 --- a/src/renderer/components/dock/terminal-store/terminal.store.ts +++ /dev/null @@ -1,152 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import { autorun, observable, when } from "mobx"; -import { autoBind, noop } from "../../../utils"; -import type { Terminal } from "../terminal/terminal"; -import { TerminalApi, TerminalChannels } from "../../../api/terminal-api"; -import { - DockStore, - DockTab, - DockTabCreate, - TabId, - TabKind, -} from "../dock-store/dock.store"; -import { WebSocketApiState } from "../../../api/websocket-api"; -import { Notifications } from "../../notifications"; - -export interface ITerminalTab extends DockTab { - node?: string; // activate node shell mode -} - -interface Dependencies { - createTerminalTab: () => DockTabCreate - dockStore: DockStore - createTerminal: (tabId: TabId, api: TerminalApi) => Terminal -} - -export class TerminalStore { - protected terminals = new Map(); - protected connections = observable.map(); - - constructor(private dependencies: Dependencies) { - autoBind(this); - - // connect active tab - autorun(() => { - const { selectedTab, isOpen } = dependencies.dockStore; - - if (selectedTab?.kind === TabKind.TERMINAL && isOpen) { - this.connect(selectedTab.id); - } - }); - // disconnect closed tabs - autorun(() => { - const currentTabs = dependencies.dockStore.tabs.map(tab => tab.id); - - for (const [tabId] of this.connections) { - if (!currentTabs.includes(tabId)) this.disconnect(tabId); - } - }); - } - - connect(tabId: TabId) { - if (this.isConnected(tabId)) { - return; - } - const tab: ITerminalTab = this.dependencies.dockStore.getTabById(tabId); - const api = new TerminalApi({ - id: tabId, - node: tab.node, - }); - const terminal = this.dependencies.createTerminal(tabId, api); - - this.connections.set(tabId, api); - this.terminals.set(tabId, terminal); - - api.connect(); - } - - disconnect(tabId: TabId) { - if (!this.isConnected(tabId)) { - return; - } - const terminal = this.terminals.get(tabId); - const terminalApi = this.connections.get(tabId); - - terminal.destroy(); - terminalApi.destroy(); - this.connections.delete(tabId); - this.terminals.delete(tabId); - } - - reconnect(tabId: TabId) { - this.connections.get(tabId)?.connect(); - } - - isConnected(tabId: TabId) { - return Boolean(this.connections.get(tabId)); - } - - isDisconnected(tabId: TabId) { - return this.connections.get(tabId)?.readyState === WebSocketApiState.CLOSED; - } - - async sendCommand(command: string, options: { enter?: boolean; newTab?: boolean; tabId?: TabId } = {}) { - const { enter, newTab, tabId } = options; - - if (tabId) { - this.dependencies.dockStore.selectTab(tabId); - } - - if (newTab) { - const tab = this.dependencies.createTerminalTab(); - - await when(() => this.connections.has(tab.id)); - - const shellIsReady = when(() => this.connections.get(tab.id).isReady); - const notifyVeryLong = setTimeout(() => { - shellIsReady.cancel(); - Notifications.info( - "If terminal shell is not ready please check your shell init files, if applicable.", - { - timeout: 4_000, - }, - ); - }, 10_000); - - await shellIsReady.catch(noop); - clearTimeout(notifyVeryLong); - } - - const terminalApi = this.connections.get(this.dependencies.dockStore.selectedTabId); - - if (terminalApi) { - if (enter) { - command += "\r"; - } - - terminalApi.sendMessage({ - type: TerminalChannels.STDIN, - data: command, - }); - } else { - console.warn( - "The selected tab is does not have a connection. Cannot send command.", - { tabId: this.dependencies.dockStore.selectedTabId, command }, - ); - } - } - - getTerminal(tabId: TabId) { - return this.terminals.get(tabId); - } - - reset() { - [...this.connections].forEach(([tabId]) => { - this.disconnect(tabId); - }); - } -} diff --git a/src/renderer/components/dock/terminal-window.tsx b/src/renderer/components/dock/terminal-window.tsx deleted file mode 100644 index 5d76f69543..0000000000 --- a/src/renderer/components/dock/terminal-window.tsx +++ /dev/null @@ -1,74 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import "./terminal-window.scss"; - -import React from "react"; -import { disposeOnUnmount, observer } from "mobx-react"; -import { cssNames } from "../../utils"; -import type { Terminal } from "./terminal/terminal"; -import type { TerminalStore } from "./terminal-store/terminal.store"; -import { ThemeStore } from "../../theme.store"; -import { DockTab, TabKind, TabId, DockStore } from "./dock-store/dock.store"; -import { withInjectables } from "@ogre-tools/injectable-react"; -import dockStoreInjectable from "./dock-store/dock-store.injectable"; -import terminalStoreInjectable from "./terminal-store/terminal-store.injectable"; - -interface Props { - tab: DockTab; -} - -interface Dependencies { - dockStore: DockStore - terminalStore: TerminalStore -} - -@observer -class NonInjectedTerminalWindow extends React.Component { - public elem: HTMLElement; - public terminal: Terminal; - - componentDidMount() { - disposeOnUnmount(this, [ - this.props.dockStore.onTabChange(({ tabId }) => this.activate(tabId), { - tabKind: TabKind.TERMINAL, - fireImmediately: true, - }), - - // refresh terminal available space (cols/rows) when resized - this.props.dockStore.onResize(() => this.terminal?.fitLazy(), { - fireImmediately: true, - }), - ]); - } - - activate(tabId: TabId) { - this.terminal?.detach(); // detach previous - this.terminal = this.props.terminalStore.getTerminal(tabId); - this.terminal.attachTo(this.elem); - } - - render() { - return ( -
    this.elem = elem} - /> - ); - } -} - -export const TerminalWindow = withInjectables( - NonInjectedTerminalWindow, - - { - getProps: (di, props) => ({ - dockStore: di.inject(dockStoreInjectable), - terminalStore: di.inject(terminalStoreInjectable), - ...props, - }), - }, -); - diff --git a/src/renderer/components/dock/terminal/clear-terminal-tab-data.injectable.ts b/src/renderer/components/dock/terminal/clear-terminal-tab-data.injectable.ts new file mode 100644 index 0000000000..37794aba7d --- /dev/null +++ b/src/renderer/components/dock/terminal/clear-terminal-tab-data.injectable.ts @@ -0,0 +1,26 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { bind } from "../../../utils"; +import type { TabId } from "../dock/store"; +import type { TerminalStore } from "./store"; +import terminalStoreInjectable from "./store.injectable"; + +interface Dependencies { + terminalStore: TerminalStore; +} + +function clearTerminalTabData({ terminalStore }: Dependencies, tabId: TabId): void { + terminalStore.destroy(tabId); +} + +const clearTerminalTabDataInjectable = getInjectable({ + instantiate: (di) => bind(clearTerminalTabData, null, { + terminalStore: di.inject(terminalStoreInjectable), + }), + lifecycle: lifecycleEnum.singleton, +}); + +export default clearTerminalTabDataInjectable; diff --git a/src/renderer/components/dock/terminal/create-tab.injectable.ts b/src/renderer/components/dock/terminal/create-tab.injectable.ts new file mode 100644 index 0000000000..8d2b58551d --- /dev/null +++ b/src/renderer/components/dock/terminal/create-tab.injectable.ts @@ -0,0 +1,30 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { DockTabCreate, DockTabCreateOptions, DockTabCreateSpecific, DockTabData, TabKind } from "../dock/store"; +import { bind } from "../../../utils"; +import createDockTabInjectable from "../dock/create-tab.injectable"; + +interface Dependencies { + createDockTab: (data: DockTabCreate, opts?: DockTabCreateOptions) => DockTabData; +} + +export function createTerminalTab({ createDockTab }: Dependencies, tabParams: DockTabCreateSpecific = {}) { + return createDockTab({ + title: "Terminal", + ...tabParams, + kind: TabKind.TERMINAL, + }); +} + +const createTerminalTabInjectable = getInjectable({ + instantiate: (di) => bind(createTerminalTab, null, { + createDockTab: di.inject(createDockTabInjectable), + }), + + lifecycle: lifecycleEnum.singleton, +}); + +export default createTerminalTabInjectable; diff --git a/src/renderer/components/dock/terminal/create-terminal.injectable.ts b/src/renderer/components/dock/terminal/create-terminal.injectable.ts index b52b24d13f..4660438382 100644 --- a/src/renderer/components/dock/terminal/create-terminal.injectable.ts +++ b/src/renderer/components/dock/terminal/create-terminal.injectable.ts @@ -4,20 +4,21 @@ */ import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; import { Terminal } from "./terminal"; -import type { TabId } from "../dock-store/dock.store"; -import type { TerminalApi } from "../../../api/terminal-api"; -import dockStoreInjectable from "../dock-store/dock-store.injectable"; +import { bind } from "../../../utils"; +import addElementEventListenerInjectable from "../../../event-listeners/add-element-event-listener.injectable"; +import addWindowEventListenerInjectable from "../../../event-listeners/add-window-event-listener.injectable"; +import terminalColorsInjectable from "../../../themes/terminal-colors.injectable"; +import terminalConfigInjectable from "../../../../common/user-preferences/terminal-config.injectable"; +import terminalCopyOnSelectInjectable from "../../../../common/user-preferences/terminal-copy-on-select.injectable"; const createTerminalInjectable = getInjectable({ - instantiate: (di) => { - const dependencies = { - dockStore: di.inject(dockStoreInjectable), - }; - - return (tabId: TabId, api: TerminalApi) => - new Terminal(dependencies, tabId, api); - }, - + instantiate: (di) => bind(Terminal.create, null, { + addElementEventListener: di.inject(addElementEventListenerInjectable), + addWindowEventListener: di.inject(addWindowEventListenerInjectable), + terminalColors: di.inject(terminalColorsInjectable), + terminalConfig: di.inject(terminalConfigInjectable), + terminalCopyOnSelect: di.inject(terminalCopyOnSelectInjectable), + }), lifecycle: lifecycleEnum.singleton, }); diff --git a/src/renderer/components/dock/terminal-tab.tsx b/src/renderer/components/dock/terminal/dock-tab.tsx similarity index 70% rename from src/renderer/components/dock/terminal-tab.tsx rename to src/renderer/components/dock/terminal/dock-tab.tsx index a633ffb062..2fc9d436d2 100644 --- a/src/renderer/components/dock/terminal-tab.tsx +++ b/src/renderer/components/dock/terminal/dock-tab.tsx @@ -3,19 +3,17 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import "./terminal-tab.scss"; - import React from "react"; import { observer } from "mobx-react"; -import { boundMethod, cssNames } from "../../utils"; -import { DockTab, DockTabProps } from "./dock-tab"; -import { Icon } from "../icon"; -import type { TerminalStore } from "./terminal-store/terminal.store"; -import type { DockStore } from "./dock-store/dock.store"; +import { boundMethod, cssNames } from "../../../utils"; +import { DockTab, DockTabProps } from "../dock-tab"; +import { Icon } from "../../icon"; +import type { TerminalStore } from "./store"; +import type { DockStore } from "../dock/store"; import { reaction } from "mobx"; import { withInjectables } from "@ogre-tools/injectable-react"; -import dockStoreInjectable from "./dock-store/dock-store.injectable"; -import terminalStoreInjectable from "./terminal-store/terminal-store.injectable"; +import dockStoreInjectable from "../dock/store.injectable"; +import terminalStoreInjectable from "./store.injectable"; interface Props extends DockTabProps { } @@ -73,15 +71,11 @@ class NonInjectedTerminalTab extends React.Component { } } -export const TerminalTab = withInjectables( - NonInjectedTerminalTab, - - { - getProps: (di, props) => ({ - dockStore: di.inject(dockStoreInjectable), - terminalStore: di.inject(terminalStoreInjectable), - ...props, - }), - }, -); +export const TerminalTab = withInjectables(NonInjectedTerminalTab, { + getProps: (di, props) => ({ + dockStore: di.inject(dockStoreInjectable), + terminalStore: di.inject(terminalStoreInjectable), + ...props, + }), +}); diff --git a/src/renderer/components/dock/terminal/get-terminal-api.injectable.ts b/src/renderer/components/dock/terminal/get-terminal-api.injectable.ts new file mode 100644 index 0000000000..41f8682ad5 --- /dev/null +++ b/src/renderer/components/dock/terminal/get-terminal-api.injectable.ts @@ -0,0 +1,27 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import type { TerminalApi } from "../../../api/terminal-api"; +import { bind } from "../../../utils"; +import type { TabId } from "../dock/store"; +import type { TerminalStore } from "./store"; +import terminalStoreInjectable from "./store.injectable"; + +interface Dependencies { + terminalStore: TerminalStore; +} + +function getTerminalApi({ terminalStore }: Dependencies, tabId: TabId): TerminalApi { + return terminalStore.getTerminalApi(tabId); +} + +const getTerminalApiInjectable = getInjectable({ + instantiate: (di) => bind(getTerminalApi, null, { + terminalStore: di.inject(terminalStoreInjectable), + }), + lifecycle: lifecycleEnum.singleton, +}); + +export default getTerminalApiInjectable; diff --git a/src/renderer/components/dock/terminal/is-disconnected.injectable.ts b/src/renderer/components/dock/terminal/is-disconnected.injectable.ts new file mode 100644 index 0000000000..58ee0807c8 --- /dev/null +++ b/src/renderer/components/dock/terminal/is-disconnected.injectable.ts @@ -0,0 +1,13 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import terminalStoreInjectable from "./store.injectable"; + +const isTerminalDisconnectedInjectable = getInjectable({ + instantiate: (di) => di.inject(terminalStoreInjectable).isDisconnected, + lifecycle: lifecycleEnum.singleton, +}); + +export default isTerminalDisconnectedInjectable; diff --git a/src/renderer/components/dock/terminal/reconnect.injectable.ts b/src/renderer/components/dock/terminal/reconnect.injectable.ts new file mode 100644 index 0000000000..bc531a5175 --- /dev/null +++ b/src/renderer/components/dock/terminal/reconnect.injectable.ts @@ -0,0 +1,13 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import terminalStoreInjectable from "./store.injectable"; + +const reconnectTerminalInjectable = getInjectable({ + instantiate: (di) => di.inject(terminalStoreInjectable).reconnect, + lifecycle: lifecycleEnum.singleton, +}); + +export default reconnectTerminalInjectable; diff --git a/src/renderer/components/dock/terminal/send-command.injectable.ts b/src/renderer/components/dock/terminal/send-command.injectable.ts new file mode 100644 index 0000000000..80f13d897b --- /dev/null +++ b/src/renderer/components/dock/terminal/send-command.injectable.ts @@ -0,0 +1,90 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { when } from "mobx"; +import { TerminalApi, TerminalChannels } from "../../../api/terminal-api"; +import { bind, noop } from "../../../utils"; +import { Notifications } from "../../notifications"; +import selectDockTabInjectable from "../dock/select-tab.injectable"; +import type { DockTabData, TabId } from "../dock/store"; +import createTerminalTabInjectable from "./create-tab.injectable"; +import getTerminalApiInjectable from "./get-terminal-api.injectable"; + +interface Dependencies { + selectTab: (tabId: TabId) => void; + createTerminalTab: () => DockTabData; + getTerminalApi: (tabId: TabId) => TerminalApi; +} + +export interface SendCommandOptions { + /** + * Emit an enter after the command + */ + enter?: boolean; + + /** + * @deprecated This option is ignored and infered to be `true` if `tabId` is not provided + */ + newTab?: any; + + /** + * Specify a specific terminal tab to send this command to + */ + tabId?: TabId; +} + +async function sendCommand({ selectTab, createTerminalTab, getTerminalApi }: Dependencies, command: string, options: SendCommandOptions = {}): Promise { + let { tabId } = options; + + if (tabId) { + selectTab(tabId); + } else { + tabId = createTerminalTab().id; + } + + await when(() => Boolean(getTerminalApi(tabId))); + + const terminalApi = getTerminalApi(tabId); + const shellIsReady = when(() =>terminalApi.isReady); + const notifyVeryLong = setTimeout(() => { + shellIsReady.cancel(); + Notifications.info( + "If terminal shell is not ready please check your shell init files, if applicable.", + { + timeout: 4_000, + }, + ); + }, 10_000); + + await shellIsReady.catch(noop); + clearTimeout(notifyVeryLong); + + if (terminalApi) { + if (options.enter) { + command += "\r"; + } + + terminalApi.sendMessage({ + type: TerminalChannels.STDIN, + data: command, + }); + } else { + console.warn( + "The selected tab is does not have a connection. Cannot send command.", + { tabId, command }, + ); + } +} + +const sendCommandInjectable = getInjectable({ + instantiate: (di) => bind(sendCommand, null, { + createTerminalTab: di.inject(createTerminalTabInjectable), + selectTab: di.inject(selectDockTabInjectable), + getTerminalApi: di.inject(getTerminalApiInjectable), + }), + lifecycle: lifecycleEnum.singleton, +}); + +export default sendCommandInjectable; diff --git a/src/renderer/components/dock/terminal-store/terminal-store.injectable.ts b/src/renderer/components/dock/terminal/store.injectable.ts similarity index 52% rename from src/renderer/components/dock/terminal-store/terminal-store.injectable.ts rename to src/renderer/components/dock/terminal/store.injectable.ts index 6e0990b31e..06c4c4d7f4 100644 --- a/src/renderer/components/dock/terminal-store/terminal-store.injectable.ts +++ b/src/renderer/components/dock/terminal/store.injectable.ts @@ -3,15 +3,11 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; -import { TerminalStore } from "./terminal.store"; -import createTerminalTabInjectable from "../create-terminal-tab/create-terminal-tab.injectable"; -import dockStoreInjectable from "../dock-store/dock-store.injectable"; -import createTerminalInjectable from "../terminal/create-terminal.injectable"; +import { TerminalStore } from "./store"; +import createTerminalInjectable from "./create-terminal.injectable"; const terminalStoreInjectable = getInjectable({ instantiate: (di) => new TerminalStore({ - createTerminalTab: di.inject(createTerminalTabInjectable), - dockStore: di.inject(dockStoreInjectable), createTerminal: di.inject(createTerminalInjectable), }), diff --git a/src/renderer/components/dock/terminal/store.ts b/src/renderer/components/dock/terminal/store.ts new file mode 100644 index 0000000000..ac5eaaa54b --- /dev/null +++ b/src/renderer/components/dock/terminal/store.ts @@ -0,0 +1,87 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { action, observable } from "mobx"; +import type { Terminal } from "./terminal"; +import { TerminalApi } from "../../../api/terminal-api"; +import type { DockTabData, TabId } from "../dock/store"; +import { WebSocketApiState } from "../../../api/websocket-api"; + +export interface ITerminalTab extends DockTabData { + node?: string; // activate node shell mode +} + +interface Dependencies { + createTerminal: (tabId: TabId, api: TerminalApi) => Terminal; +} + +export class TerminalStore { + protected terminals = new Map(); + protected connections = observable.map(); + + constructor(private readonly dependencies: Dependencies) { + } + + @action + connect(tab: ITerminalTab) { + if (this.isConnected(tab.id)) { + return; + } + const api = new TerminalApi({ + id: tab.id, + node: tab.node, + }); + const terminal = this.dependencies.createTerminal(tab.id, api); + + this.connections.set(tab.id, api); + this.terminals.set(tab.id, terminal); + + api.connect(); + } + + @action + destroy(tabId: TabId) { + const terminal = this.terminals.get(tabId); + const terminalApi = this.connections.get(tabId); + + terminal?.destroy(); + terminalApi?.destroy(); + this.connections.delete(tabId); + this.terminals.delete(tabId); + } + + /** + * @deprecated use `this.destroy()` instead + */ + disconnect(tabId: TabId) { + this.destroy(tabId); + } + + reconnect(tabId: TabId) { + this.connections.get(tabId)?.connect(); + } + + isConnected(tabId: TabId) { + return Boolean(this.connections.get(tabId)); + } + + isDisconnected(tabId: TabId) { + return this.connections.get(tabId)?.readyState === WebSocketApiState.CLOSED; + } + + getTerminal(tabId: TabId) { + return this.terminals.get(tabId); + } + + getTerminalApi(tabId: TabId) { + return this.connections.get(tabId); + } + + reset() { + [...this.connections].forEach(([tabId]) => { + this.destroy(tabId); + }); + } +} diff --git a/src/renderer/components/dock/terminal-tab.scss b/src/renderer/components/dock/terminal/terminal-tab.scss similarity index 100% rename from src/renderer/components/dock/terminal-tab.scss rename to src/renderer/components/dock/terminal/terminal-tab.scss diff --git a/src/renderer/components/dock/terminal/terminal-tab.tsx b/src/renderer/components/dock/terminal/terminal-tab.tsx new file mode 100644 index 0000000000..497195285c --- /dev/null +++ b/src/renderer/components/dock/terminal/terminal-tab.tsx @@ -0,0 +1,69 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import "./terminal-tab.scss"; + +import React, { useEffect } from "react"; +import { observer } from "mobx-react"; +import { cssNames } from "../../../utils"; +import { DockTab, DockTabProps } from "../dock-tab/dock-tab"; +import { Icon } from "../../icon"; +import type { TabId } from "../dock/store"; +import { reaction } from "mobx"; +import { withInjectables } from "@ogre-tools/injectable-react"; +import closeDockTabInjectable from "../dock/close-tab.injectable"; +import isTerminalDisconnectedInjectable from "./is-disconnected.injectable"; +import reconnectTerminalInjectable from "./reconnect.injectable"; + +export interface TerminalTabProps extends DockTabProps { +} + +interface Dependencies { + closeDockTab: (tabId: TabId) => void; + isTerminalDisconnected: (tabId: TabId) => boolean; + reconnectTerminal: (tabId: TabId) => void; +} + +const NonInjectedTerminalTab = observer(({ reconnectTerminal, isTerminalDisconnected, closeDockTab, value: tab, className, ...props }: Dependencies & TerminalTabProps) => { + useEffect(() => reaction( + () => isTerminalDisconnected(tab.id), + isDisconnected => { + if (isDisconnected) { + closeDockTab(tab.id); + } + }, + ), []); + + const disconnected = isTerminalDisconnected(tab.id); + const tabIcon = ; + const classNames = cssNames("TerminalTab", className, { disconnected }); + + return ( + reconnectTerminal(tab.id)} + /> + )} + /> + ); +}); + +export const TerminalTab = withInjectables(NonInjectedTerminalTab, { + getProps: (di, props) => ({ + closeDockTab: di.inject(closeDockTabInjectable), + isTerminalDisconnected: di.inject(isTerminalDisconnectedInjectable), + reconnectTerminal: di.inject(reconnectTerminalInjectable), + ...props, + }), +}); diff --git a/src/renderer/components/dock/terminal-window.scss b/src/renderer/components/dock/terminal/terminal-window.scss similarity index 100% rename from src/renderer/components/dock/terminal-window.scss rename to src/renderer/components/dock/terminal/terminal-window.scss diff --git a/src/renderer/components/dock/terminal/terminal-window.tsx b/src/renderer/components/dock/terminal/terminal-window.tsx new file mode 100644 index 0000000000..b4d2aa24fe --- /dev/null +++ b/src/renderer/components/dock/terminal/terminal-window.tsx @@ -0,0 +1,67 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import "./terminal-window.scss"; + +import React, { useEffect, useRef, useState } from "react"; +import { observer } from "mobx-react"; +import { cssNames, disposer } from "../../../utils"; +import type { Terminal } from "./terminal"; +import type { TerminalStore } from "./store"; +import type { Theme } from "../../../themes/store"; +import { TabKind, TabId, DockStore } from "../dock/store"; +import { withInjectables } from "@ogre-tools/injectable-react"; +import dockStoreInjectable from "../dock/store.injectable"; +import terminalStoreInjectable from "./store.injectable"; +import activeThemeInjectable from "../../../themes/active-theme.injectable"; +import type { IComputedValue } from "mobx"; + +interface Dependencies { + dockStore: DockStore; + terminalStore: TerminalStore; + activeTheme: IComputedValue; +} + +const NonInjectedTerminalWindow = observer(({ dockStore, terminalStore, activeTheme }: Dependencies) => { + const element = useRef(); + const [terminal, setTerminal] = useState(null); + + const activate = (tabId: TabId) => { + terminal?.detach(); // detach previous + + const newTerminal = terminalStore.getTerminal(tabId); + + setTerminal(newTerminal); + newTerminal.attachTo(element.current); + }; + + useEffect(() => disposer( + dockStore.onTabChange(({ tabId }) => activate(tabId), { + tabKind: TabKind.TERMINAL, + fireImmediately: true, + }), + + // refresh terminal available space (cols/rows) when resized + dockStore.onResize(() => terminal?.fitLazy(), { + fireImmediately: true, + }), + ), []); + + return ( +
    + ); +}); + +export const TerminalWindow = withInjectables(NonInjectedTerminalWindow, { + getProps: (di, props) => ({ + dockStore: di.inject(dockStoreInjectable), + terminalStore: di.inject(terminalStoreInjectable), + activeTheme: di.inject(activeThemeInjectable), + ...props, + }), +}); diff --git a/src/renderer/components/dock/terminal/terminal.ts b/src/renderer/components/dock/terminal/terminal.ts index 39b42d6521..9225989543 100644 --- a/src/renderer/components/dock/terminal/terminal.ts +++ b/src/renderer/components/dock/terminal/terminal.ts @@ -4,21 +4,27 @@ */ import debounce from "lodash/debounce"; -import { reaction } from "mobx"; -import { Terminal as XTerm } from "xterm"; +import { IComputedValue, reaction } from "mobx"; +import { ITheme, Terminal as XTerm } from "xterm"; import { FitAddon } from "xterm-addon-fit"; -import type { DockStore, TabId } from "../dock-store/dock.store"; +import type { TabId } from "../dock/store"; import { TerminalApi, TerminalChannels } from "../../../api/terminal-api"; -import { ThemeStore } from "../../../theme.store"; import { disposer } from "../../../utils"; -import { isMac } from "../../../../common/vars"; +import { isMac, defaultTerminalFontFamily } from "../../../../common/vars"; import { once } from "lodash"; -import { UserStore } from "../../../../common/user-store"; import { clipboard } from "electron"; import logger from "../../../../common/logger"; +import type { TerminalConfig } from "../../../../common/user-preferences/preferences-helpers"; +import font from "../../fonts/roboto-mono-nerd.ttf"; +import type { AddElementEventListener } from "../../../event-listeners/add-element-event-listener.injectable"; +import type { AddWindowEventListener } from "../../../event-listeners/add-window-event-listener.injectable"; -interface Dependencies { - dockStore: DockStore +export interface TerminalDependencies { + readonly terminalColors: IComputedValue; + readonly terminalCopyOnSelect: IComputedValue; + readonly terminalConfig: IComputedValue; + addWindowEventListener: AddWindowEventListener; + addElementEventListener: AddElementEventListener; } export class Terminal { @@ -27,35 +33,23 @@ export class Terminal { } static async preloadFonts() { - const fontPath = require("../../fonts/roboto-mono-nerd.ttf").default; // eslint-disable-line @typescript-eslint/no-var-requires - const fontFace = new FontFace("RobotoMono", `url(${fontPath})`); + const fontFace = new FontFace(defaultTerminalFontFamily, `url(${font})`); await fontFace.load(); document.fonts.add(fontFace); } - private xterm: XTerm | null = new XTerm({ - cursorBlink: true, - cursorStyle: "bar", - fontSize: 13, - fontFamily: "RobotoMono", - }); + private xterm: XTerm | null; private readonly fitAddon = new FitAddon(); private scrollPos = 0; private disposer = disposer(); - get elem() { + protected get elem() { return this.xterm?.element; } - get viewport() { - return this.xterm.element.querySelector(".xterm-viewport"); - } - - get isActive() { - const { isOpen, selectedTabId } = this.dependencies.dockStore; - - return isOpen && selectedTabId === this.tabId; + protected get viewport() { + return this.xterm.element.querySelector(".xterm-viewport") as HTMLElement; } attachTo(parentElem: HTMLElement) { @@ -71,38 +65,58 @@ export class Terminal { } } - constructor(private dependencies: Dependencies, public tabId: TabId, protected api: TerminalApi) { + static create(...args: ConstructorParameters) { + return new Terminal(...args); + } + + constructor({ terminalColors, terminalConfig, terminalCopyOnSelect, addWindowEventListener, addElementEventListener }: TerminalDependencies, public tabId: TabId, protected api: TerminalApi) { + this.xterm = new XTerm({ + cursorBlink: true, + cursorStyle: "bar", + ...terminalConfig.get(), + theme: terminalColors.get(), + }); + // enable terminal addons this.xterm.loadAddon(this.fitAddon); this.xterm.open(Terminal.spawningPool); this.xterm.registerLinkMatcher(/https?:\/\/[^\s]+/i, this.onClickLink); this.xterm.attachCustomKeyEventHandler(this.keyHandler); - this.xterm.onSelectionChange(this.onSelectionChange); + this.xterm.onSelectionChange(() => { + const selection = this.xterm.getSelection().trim(); + + if (terminalCopyOnSelect.get() && selection) { + clipboard.writeText(selection); + } + }); // bind events const onDataHandler = this.xterm.onData(this.onData); const clearOnce = once(this.onClear); - this.viewport.addEventListener("scroll", this.onScroll); - this.elem.addEventListener("contextmenu", this.onContextMenu); - this.api.once("ready", clearOnce); - this.api.once("connected", clearOnce); - this.api.on("data", this.onApiData); - window.addEventListener("resize", this.onResize); + const onContextMenu = () => { + // don't paste if the clipboard doesn't have text + if (terminalCopyOnSelect.get() && clipboard.has("text/plain")) { + this.xterm.paste(clipboard.readText()); + } + }; + + this.api + .once("ready", clearOnce) + .once("connected", clearOnce) + .on("data", data => this.xterm.write(data)); this.disposer.push( - reaction(() => ThemeStore.getInstance().xtermColors, colors => { - this.xterm?.setOption("theme", colors); - }, { - fireImmediately: true, - }), - dependencies.dockStore.onResize(this.onResize), + addElementEventListener(this.viewport, "scroll", () => this.scrollPos = this.viewport.scrollTop), + addElementEventListener(this.elem, "contextmenu", onContextMenu), + addWindowEventListener("resize", this.onResize), + reaction(() => terminalColors.get(), theme => this.xterm.options.theme = theme), + reaction(() => terminalConfig.get().fontFamily, fontFamily => this.xterm.options.fontFamily = fontFamily), + reaction(() => terminalConfig.get().fontSize, fontSize => this.xterm.options.fontSize = fontSize), () => onDataHandler.dispose(), () => this.fitAddon.dispose(), () => this.api.removeAllListeners(), - () => window.removeEventListener("resize", this.onResize), - () => this.elem.removeEventListener("contextmenu", this.onContextMenu), ); } @@ -114,17 +128,15 @@ export class Terminal { } } - fit = () => { + protected fit = () => { // Since this function is debounced we need to read this value as late as possible - if (!this.isActive || !this.xterm) { + if (!this.xterm) { return; } try { this.fitAddon.fit(); - const { cols, rows } = this.xterm; - - this.api.sendTerminalSize(cols, rows); + this.api.sendTerminalSize(this.xterm); } catch (error) { // see https://github.com/lensapp/lens/issues/1891 logger.error(`[TERMINAL]: failed to resize terminal to fit`, error); @@ -137,62 +149,35 @@ export class Terminal { this.xterm.focus(); }; - onApiData = (data: string) => { - this.xterm.write(data); + protected onData = (data: string) => { + if (this.api.isReady) { + this.api.sendMessage({ + type: TerminalChannels.STDIN, + data, + }); + } }; - onData = (data: string) => { - if (!this.api.isReady) return; - this.api.sendMessage({ - type: TerminalChannels.STDIN, - data, - }); - }; - - onScroll = () => { - this.scrollPos = this.viewport.scrollTop; - }; - - onClear = () => { + protected onClear = () => { this.xterm.clear(); }; - onResize = () => { + protected onResize = () => { this.fitLazy(); this.focus(); }; - onActivate = () => { + protected onActivate = () => { this.fit(); setTimeout(() => this.focus(), 250); // delay used to prevent focus on active tab this.viewport.scrollTop = this.scrollPos; // restore last scroll position }; - onClickLink = (evt: MouseEvent, link: string) => { + protected onClickLink = (evt: MouseEvent, link: string) => { window.open(link, "_blank"); }; - onContextMenu = () => { - if ( - // don't paste if user hasn't turned on the feature - UserStore.getInstance().terminalCopyOnSelect - - // don't paste if the clipboard doesn't have text - && clipboard.availableFormats().includes("text/plain") - ) { - this.xterm.paste(clipboard.readText()); - } - }; - - onSelectionChange = () => { - const selection = this.xterm.getSelection().trim(); - - if (UserStore.getInstance().terminalCopyOnSelect && selection) { - clipboard.writeText(selection); - } - }; - - keyHandler = (evt: KeyboardEvent): boolean => { + protected keyHandler = (evt: KeyboardEvent): boolean => { const { code, ctrlKey, metaKey } = evt; // Handle custom hotkey bindings diff --git a/src/renderer/components/dock/terminal/view.tsx b/src/renderer/components/dock/terminal/view.tsx new file mode 100644 index 0000000000..d96ba0a7c3 --- /dev/null +++ b/src/renderer/components/dock/terminal/view.tsx @@ -0,0 +1,67 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import "./terminal-window.scss"; + +import React, { useEffect, useRef, useState } from "react"; +import { observer } from "mobx-react"; +import { cssNames, disposer } from "../../../utils"; +import type { Terminal } from "./terminal"; +import type { ITerminalTab, TerminalStore } from "./store"; +import type { Theme } from "../../../themes/store"; +import type { DockStore } from "../dock/store"; +import { withInjectables } from "@ogre-tools/injectable-react"; +import dockStoreInjectable from "../dock/store.injectable"; +import terminalStoreInjectable from "./store.injectable"; +import activeThemeInjectable from "../../../themes/active-theme.injectable"; +import type { IComputedValue } from "mobx"; + +export interface TerminalWindowProps { + tab: ITerminalTab; +} + +interface Dependencies { + dockStore: DockStore; + terminalStore: TerminalStore; + activeTheme: IComputedValue; +} + +const NonInjectedTerminalWindow = observer(({ dockStore, terminalStore, activeTheme, tab }: Dependencies & TerminalWindowProps) => { + const element = useRef(); + const [terminal, setTerminal] = useState(null); + + useEffect(() => { + terminal?.detach(); // detach previous + terminalStore.connect(tab); + + const newTerminal = terminalStore.getTerminal(tab.id); + + setTerminal(newTerminal); + newTerminal.attachTo(element.current); + }, [tab.id]); + + useEffect(() => disposer( + // refresh terminal available space (cols/rows) when resized + dockStore.onResize(() => terminal?.fitLazy(), { + fireImmediately: true, + }), + ), []); + + return ( +
    + ); +}); + +export const TerminalWindow = withInjectables(NonInjectedTerminalWindow, { + getProps: (di, props) => ({ + dockStore: di.inject(dockStoreInjectable), + terminalStore: di.inject(terminalStoreInjectable), + activeTheme: di.inject(activeThemeInjectable), + ...props, + }), +}); diff --git a/src/renderer/components/dock/upgrade-chart-store/upgrade-chart-store.injectable.ts b/src/renderer/components/dock/upgrade-chart-store/upgrade-chart-store.injectable.ts deleted file mode 100644 index 7a660c133a..0000000000 --- a/src/renderer/components/dock/upgrade-chart-store/upgrade-chart-store.injectable.ts +++ /dev/null @@ -1,29 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ -import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; -import { UpgradeChartStore } from "./upgrade-chart.store"; -import releaseStoreInjectable from "../../+apps-releases/release-store.injectable"; -import dockStoreInjectable from "../dock-store/dock-store.injectable"; -import createDockTabStoreInjectable from "../dock-tab-store/create-dock-tab-store.injectable"; -import createStorageInjectable from "../../../utils/create-storage/create-storage.injectable"; - -const upgradeChartStoreInjectable = getInjectable({ - instantiate: (di) => { - const createDockTabStore = di.inject(createDockTabStoreInjectable); - - const valuesStore = createDockTabStore(); - - return new UpgradeChartStore({ - releaseStore: di.inject(releaseStoreInjectable), - dockStore: di.inject(dockStoreInjectable), - createStorage: di.inject(createStorageInjectable), - valuesStore, - }); - }, - - lifecycle: lifecycleEnum.singleton, -}); - -export default upgradeChartStoreInjectable; diff --git a/src/renderer/components/dock/upgrade-chart-store/upgrade-chart.store.ts b/src/renderer/components/dock/upgrade-chart-store/upgrade-chart.store.ts deleted file mode 100644 index 917bc46ce8..0000000000 --- a/src/renderer/components/dock/upgrade-chart-store/upgrade-chart.store.ts +++ /dev/null @@ -1,119 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import { action, autorun, computed, IReactionDisposer, reaction, makeObservable } from "mobx"; -import { DockStore, DockTab, TabId, TabKind } from "../dock-store/dock.store"; -import { DockTabStorageState, DockTabStore } from "../dock-tab-store/dock-tab.store"; -import { getReleaseValues } from "../../../../common/k8s-api/endpoints/helm-releases.api"; -import type { ReleaseStore } from "../../+apps-releases/release.store"; -import { iter, StorageHelper } from "../../../utils"; - -export interface IChartUpgradeData { - releaseName: string; - releaseNamespace: string; -} - -interface Dependencies { - releaseStore: ReleaseStore - valuesStore: DockTabStore - dockStore: DockStore - createStorage: (storageKey: string, options: DockTabStorageState) => StorageHelper> -} - -export class UpgradeChartStore extends DockTabStore { - private watchers = new Map(); - - @computed private get releaseNameReverseLookup(): Map { - return new Map(iter.map(this.data, ([id, { releaseName }]) => [releaseName, id])); - } - - get values() { - return this.dependencies.valuesStore; - } - - constructor(protected dependencies : Dependencies) { - super(dependencies, { - storageKey: "chart_releases", - }); - - makeObservable(this); - - autorun(() => { - const { selectedTab, isOpen } = dependencies.dockStore; - - if (selectedTab?.kind === TabKind.UPGRADE_CHART && isOpen) { - this.loadData(selectedTab.id); - } - }, { delay: 250 }); - - autorun(() => { - const objects = [...this.data.values()]; - - objects.forEach(({ releaseName }) => this.createReleaseWatcher(releaseName)); - }); - } - - private createReleaseWatcher(releaseName: string) { - if (this.watchers.get(releaseName)) { - return; - } - const dispose = reaction(() => { - const release = this.dependencies.releaseStore.getByName(releaseName); - - return release?.getRevision(); // watch changes only by revision - }, - release => { - const releaseTab = this.getTabByRelease(releaseName); - - if (!this.dependencies.releaseStore.isLoaded || !releaseTab) { - return; - } - - // auto-reload values if was loaded before - if (release) { - if (this.dependencies.dockStore.selectedTab === releaseTab && this.values.getData(releaseTab.id)) { - this.loadValues(releaseTab.id); - } - } - // clean up watcher, close tab if release not exists / was removed - else { - dispose(); - this.watchers.delete(releaseName); - this.dependencies.dockStore.closeTab(releaseTab.id); - } - }); - - this.watchers.set(releaseName, dispose); - } - - isLoading(tabId = this.dependencies.dockStore.selectedTabId) { - const values = this.values.getData(tabId); - - return !this.dependencies.releaseStore.isLoaded || values === undefined; - } - - @action - async loadData(tabId: TabId) { - const values = this.values.getData(tabId); - - await Promise.all([ - !this.dependencies.releaseStore.isLoaded && this.dependencies.releaseStore.loadFromContextNamespaces(), - !values && this.loadValues(tabId), - ]); - } - - @action - async loadValues(tabId: TabId) { - this.values.clearData(tabId); // reset - const { releaseName, releaseNamespace } = this.getData(tabId); - const values = await getReleaseValues(releaseName, releaseNamespace, true); - - this.values.setData(tabId, values); - } - - getTabByRelease(releaseName: string): DockTab { - return this.dependencies.dockStore.getTabById(this.releaseNameReverseLookup.get(releaseName)); - } -} diff --git a/src/renderer/components/dock/upgrade-chart.tsx b/src/renderer/components/dock/upgrade-chart.tsx deleted file mode 100644 index 0136d65d53..0000000000 --- a/src/renderer/components/dock/upgrade-chart.tsx +++ /dev/null @@ -1,175 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import "./upgrade-chart.scss"; - -import React from "react"; -import { action, makeObservable, observable, reaction } from "mobx"; -import { disposeOnUnmount, observer } from "mobx-react"; -import { cssNames } from "../../utils"; -import type { DockTab } from "./dock-store/dock.store"; -import { InfoPanel } from "./info-panel"; -import type { UpgradeChartStore } from "./upgrade-chart-store/upgrade-chart.store"; -import { Spinner } from "../spinner"; -import type { ReleaseStore } from "../+apps-releases/release.store"; -import { Badge } from "../badge"; -import { EditorPanel } from "./editor-panel"; -import { helmChartStore, IChartVersion } from "../+apps-helm-charts/helm-chart.store"; -import type { HelmRelease } from "../../../common/k8s-api/endpoints/helm-releases.api"; -import { Select, SelectOption } from "../select"; -import { withInjectables } from "@ogre-tools/injectable-react"; -import releaseStoreInjectable from "../+apps-releases/release-store.injectable"; -import upgradeChartStoreInjectable from "./upgrade-chart-store/upgrade-chart-store.injectable"; - -interface Props { - className?: string; - tab: DockTab; -} - -interface Dependencies { - releaseStore: ReleaseStore - upgradeChartStore: UpgradeChartStore -} - -@observer -export class NonInjectedUpgradeChart extends React.Component { - @observable error: string; - @observable versions = observable.array(); - @observable version: IChartVersion; - - constructor(props: Props & Dependencies) { - super(props); - makeObservable(this); - } - - componentDidMount() { - this.loadVersions(); - - disposeOnUnmount(this, [ - reaction(() => this.release, () => this.loadVersions()), - ]); - } - - get tabId() { - return this.props.tab.id; - } - - get release(): HelmRelease { - const tabData = this.props.upgradeChartStore.getData(this.tabId); - - if (!tabData) return null; - - return this.props.releaseStore.getByName(tabData.releaseName); - } - - get value() { - return this.props.upgradeChartStore.values.getData(this.tabId); - } - - async loadVersions() { - if (!this.release) return; - this.version = null; - this.versions.clear(); - const versions = await helmChartStore.getVersions(this.release.getChart()); - - this.versions.replace(versions); - this.version = this.versions[0]; - } - - @action - onChange = (value: string) => { - this.error = ""; - this.props.upgradeChartStore.values.setData(this.tabId, value); - }; - - @action - onError = (error: Error | string) => { - this.error = error.toString(); - }; - - upgrade = async () => { - if (this.error) return null; - const { version, repo } = this.version; - const releaseName = this.release.getName(); - const releaseNs = this.release.getNs(); - - await this.props.releaseStore.update(releaseName, releaseNs, { - chart: this.release.getChart(), - values: this.value, - repo, version, - }); - - return ( -

    - Release {releaseName} successfully upgraded to version {version} -

    - ); - }; - - formatVersionLabel = ({ value }: SelectOption) => { - const chartName = this.release.getChart(); - const { repo, version } = value; - - return `${repo}/${chartName}-${version}`; - }; - - render() { - const { tabId, release, value, error, onChange, onError, upgrade, versions, version } = this; - const { className } = this.props; - - if (!release || this.props.upgradeChartStore.isLoading() || !version) { - return ; - } - const currentVersion = release.getVersion(); - const controlsAndInfo = ( -
    - Release - Namespace - Version - Upgrade version - setVersion(value)} + /> +
    + ); + + return ( +
    + + +
    + ); +}); + +export const UpgradeChart = withInjectables(NonInjectedUpgradeChart, { + getProps: (di, props) => ({ + upgradeChartTabStore: di.inject(upgradeChartTabStoreInjectable), + upgradeChartValues: di.inject(upgradeChartValuesInjectable), + releases: di.inject(releasesInjectable), + updateRelease: di.inject(updateReleaseInjectable), + ...props, + }), +}); diff --git a/src/renderer/components/drawer/drawer.tsx b/src/renderer/components/drawer/drawer.tsx index ce06c26945..f8039db59b 100644 --- a/src/renderer/components/drawer/drawer.tsx +++ b/src/renderer/components/drawer/drawer.tsx @@ -5,21 +5,30 @@ import "./drawer.scss"; -import React from "react"; +import React, { useEffect, useRef, useState } from "react"; import { clipboard } from "electron"; import { createPortal } from "react-dom"; -import { cssNames, noop, StorageHelper } from "../../utils"; +import { cssNames, disposer, Disposer, noop, StorageLayer } from "../../utils"; import { Icon } from "../icon"; import { Animate, AnimateName } from "../animate"; -import { history } from "../../navigation"; import { ResizeDirection, ResizeGrowthDirection, ResizeSide, ResizingAnchor } from "../resizing-anchor"; -import drawerStorageInjectable, { - defaultDrawerWidth, -} from "./drawer-storage/drawer-storage.injectable"; +import drawerStorageInjectable, { defaultDrawerWidth, DrawerState } from "./storage.injectable"; import { withInjectables } from "@ogre-tools/injectable-react"; +import { observer } from "mobx-react"; +import addWindowEventListenerInjectable from "../../event-listeners/add-window-event-listener.injectable"; +import { observable } from "mobx"; +import historyInjectable from "../../navigation/history.injectable"; +import type { History } from "history"; export type DrawerPosition = "top" | "left" | "right" | "bottom"; +const resizingAnchorProps = new Map(); + +resizingAnchorProps.set("right", [ResizeDirection.HORIZONTAL, ResizeSide.LEADING, ResizeGrowthDirection.RIGHT_TO_LEFT]); +resizingAnchorProps.set("left", [ResizeDirection.HORIZONTAL, ResizeSide.TRAILING, ResizeGrowthDirection.LEFT_TO_RIGHT]); +resizingAnchorProps.set("top", [ResizeDirection.VERTICAL, ResizeSide.TRAILING, ResizeGrowthDirection.TOP_TO_BOTTOM]); +resizingAnchorProps.set("bottom", [ResizeDirection.VERTICAL, ResizeSide.LEADING, ResizeGrowthDirection.BOTTOM_TO_TOP]); + export interface DrawerProps { open: boolean; title: React.ReactNode; @@ -37,70 +46,43 @@ export interface DrawerProps { animation?: AnimateName; onClose?: () => void; toolbar?: React.ReactNode; + children?: React.ReactChild | React.ReactChild[]; } -const defaultProps: Partial = { - position: "right", - animation: "slide-right", - usePortal: false, - onClose: noop, -}; - -interface State { - isCopied: boolean; - width: number; -} - -const resizingAnchorProps = new Map(); - -resizingAnchorProps.set("right", [ResizeDirection.HORIZONTAL, ResizeSide.LEADING, ResizeGrowthDirection.RIGHT_TO_LEFT]); -resizingAnchorProps.set("left", [ResizeDirection.HORIZONTAL, ResizeSide.TRAILING, ResizeGrowthDirection.LEFT_TO_RIGHT]); -resizingAnchorProps.set("top", [ResizeDirection.VERTICAL, ResizeSide.TRAILING, ResizeGrowthDirection.TOP_TO_BOTTOM]); -resizingAnchorProps.set("bottom", [ResizeDirection.VERTICAL, ResizeSide.LEADING, ResizeGrowthDirection.BOTTOM_TO_TOP]); - interface Dependencies { - drawerStorage: StorageHelper<{ width: number }>; + history: History; + state: StorageLayer; + addWindowEventListener: (type: K, listener: (this: Window, ev: WindowEventMap[K]) => any, options?: boolean | AddEventListenerOptions) => Disposer; } -class NonInjectedDrawer extends React.Component { - static defaultProps = defaultProps as object; +const NonInjectedDrawer = observer(({ + history, + state, + addWindowEventListener, + className, + contentClass, + animation = "slide-right", + open, + position = "right", + title, + children, + toolbar, + size, + usePortal = false, + onClose = noop, +}: Dependencies & DrawerProps) => { + const contentElem = useRef(); + const scrollElem = useRef(); + const [mouseDownTarget, setMouseDownTarget] = useState(); + const [isCopied, setIsCopied] = useState(false); + const [scrollPos] = useState(observable.map()); + const { width } = state.get(); - private mouseDownTarget: HTMLElement; - private contentElem: HTMLElement; - private scrollElem: HTMLElement; - private scrollPos = new Map(); - - private stopListenLocation = history.listen(() => { - this.restoreScrollPos(); - }); - - public state = { - isCopied: false, - width: this.props.drawerStorage.get().width, + const resizeWidth = (width: number) => { + state.merge({ width }); }; - componentDidMount() { - // Using window target for events to make sure they will be catched after other places (e.g. Dialog) - window.addEventListener("mousedown", this.onMouseDown); - window.addEventListener("click", this.onClickOutside); - window.addEventListener("keydown", this.onEscapeKey); - window.addEventListener("click", this.fixUpTripleClick); - } - - componentWillUnmount() { - this.stopListenLocation(); - window.removeEventListener("mousedown", this.onMouseDown); - window.removeEventListener("click", this.onClickOutside); - window.removeEventListener("click", this.fixUpTripleClick); - window.removeEventListener("keydown", this.onEscapeKey); - } - - resizeWidth = (width: number) => { - this.setState({ width }); - this.props.drawerStorage.merge({ width }); - }; - - fixUpTripleClick = (ev: MouseEvent) => { + const fixUpTripleClick = (ev: MouseEvent) => { // detail: A count of consecutive clicks that happened in a short amount of time if (ev.detail === 3) { const selection = window.getSelection(); @@ -109,132 +91,132 @@ class NonInjectedDrawer extends React.Component { - if (!this.scrollElem) return; - const key = history.location.key; + const saveScrollPos = () => { + if (scrollElem.current) { + const { key } = history.location; - this.scrollPos.set(key, this.scrollElem.scrollTop); + scrollPos.set(key, scrollElem.current.scrollTop); + } }; - restoreScrollPos = () => { - if (!this.scrollElem) return; - const key = history.location.key; + const restoreScrollPos = () => { + if (scrollElem.current) { + const { key } = history.location; - this.scrollElem.scrollTop = this.scrollPos.get(key) || 0; + scrollElem.current.scrollTop = scrollPos.get(key) || 0; + } }; - onEscapeKey = (evt: KeyboardEvent) => { - if (!this.props.open) { + const onEscapeKey = (evt: KeyboardEvent) => { + if (!open) { return; } if (evt.code === "Escape") { - this.close(); + close(); } }; - onClickOutside = (evt: MouseEvent) => { - const { contentElem, mouseDownTarget, close, props: { open }} = this; - - if (!open || evt.defaultPrevented || contentElem.contains(mouseDownTarget)) { + const onClickOutside = (evt: MouseEvent) => { + if (!open || evt.defaultPrevented || contentElem.current.contains(mouseDownTarget)) { return; } + const clickedElem = evt.target as HTMLElement; const isOutsideAnyDrawer = !clickedElem.closest(".Drawer"); if (isOutsideAnyDrawer) { close(); } - this.mouseDownTarget = null; + setMouseDownTarget(undefined); }; - onMouseDown = (evt: MouseEvent) => { - if (this.props.open) { - this.mouseDownTarget = evt.target as HTMLElement; + const onMouseDown = (evt: MouseEvent) => { + if (open) { + setMouseDownTarget(evt.target as HTMLElement); } }; - close = () => { - const { open, onClose } = this.props; - - if (open) onClose(); + const close = () => { + if (open) { + onClose(); + } }; - copyTitle = (title: string) => { + const copyTitle = (title: string) => { const itemName = title.split(":").splice(1).join(":") || title; // copy whole if no : clipboard.writeText(itemName.trim()); - this.setState({ isCopied: true }); - setTimeout(() => { - this.setState({ isCopied: false }); - }, 3000); + setIsCopied(true); + setTimeout(() => setIsCopied(false), 3000); }; - render() { - const { className, contentClass, animation, open, position, title, children, toolbar, size, usePortal } = this.props; - const { isCopied, width } = this.state; - const copyTooltip = isCopied ? "Copied!" : "Copy"; - const copyIcon = isCopied ? "done" : "content_copy"; - const canCopyTitle = typeof title === "string" && title.length > 0; - const [direction, placement, growthDirection] = resizingAnchorProps.get(position); - const drawerSize = size || `${width}px`; + useEffect(() => disposer( + history.listen(restoreScrollPos), + addWindowEventListener("mousedown", onMouseDown), + addWindowEventListener("click", onClickOutside), + addWindowEventListener("keydown", onEscapeKey), + addWindowEventListener("click", fixUpTripleClick), + ), []); - const drawer = ( - -
    this.contentElem = e} - > -
    -
    -
    - {title} - {canCopyTitle && ( - this.copyTitle(title)}/> - )} -
    - {toolbar} - -
    -
    this.scrollElem = e} - > - {children} + const copyTooltip = isCopied ? "Copied!" : "Copy"; + const copyIcon = isCopied ? "done" : "content_copy"; + const canCopyTitle = typeof title === "string" && title.length > 0; + const [direction, placement, growthDirection] = resizingAnchorProps.get(position); + const drawerSize = size || `${width}px`; + + const drawer = ( + +
    +
    +
    +
    + {title} + {canCopyTitle && ( + copyTitle(title)}/> + )}
    + {toolbar} + +
    +
    + {children}
    - { - !size && ( - width} - onDrag={this.resizeWidth} - onDoubleClick={() => this.resizeWidth(defaultDrawerWidth)} - minExtent={300} - maxExtent={window.innerWidth * 0.9} - /> - ) - }
    - - ); + { + !size && ( + width} + onDrag={resizeWidth} + onDoubleClick={() => resizeWidth(defaultDrawerWidth)} + minExtent={300} + maxExtent={window.innerWidth * 0.9} + /> + ) + } +
    +
    + ); - return usePortal ? createPortal(drawer, document.body) : drawer; - } -} - -export const Drawer = withInjectables( - NonInjectedDrawer, - - { - getProps: (di, props) => ({ - drawerStorage: di.inject(drawerStorageInjectable), - ...props, - }), - }, -); + return usePortal ? createPortal(drawer, document.body) : drawer; +}); +export const Drawer = withInjectables(NonInjectedDrawer, { + getProps: (di, props) => ({ + history: di.inject(historyInjectable), + state: di.inject(drawerStorageInjectable), + addWindowEventListener: di.inject(addWindowEventListenerInjectable), + ...props, + }), +}); diff --git a/src/renderer/components/drawer/storage.injectable.ts b/src/renderer/components/drawer/storage.injectable.ts new file mode 100644 index 0000000000..534178af2c --- /dev/null +++ b/src/renderer/components/drawer/storage.injectable.ts @@ -0,0 +1,27 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import type { StorageLayer } from "../../utils"; +import createStorageInjectable from "../../utils/create-storage/create-storage.injectable"; + +export const defaultDrawerWidth = 725; + +export interface DrawerState { + width: number; +} + +let storage: StorageLayer; + +const drawerStorageInjectable = getInjectable({ + setup: async (di) => { + storage = await di.inject(createStorageInjectable)("drawer", { + width: defaultDrawerWidth, + }); + }, + instantiate: () => storage, + lifecycle: lifecycleEnum.singleton, +}); + +export default drawerStorageInjectable; diff --git a/src/renderer/components/file-picker/file-picker.tsx b/src/renderer/components/file-picker/file-picker.tsx index 5c622bc6d6..3151504585 100644 --- a/src/renderer/components/file-picker/file-picker.tsx +++ b/src/renderer/components/file-picker/file-picker.tsx @@ -176,7 +176,7 @@ export class FilePicker extends React.Component { this.status = FileInputStatus.PROCESSING; const paths: string[] = []; - const promises = totalSizeLimitedFiles.map(async file => { + const promises = totalSizeLimitedFiles.map(file => { const destinationPath = path.join(uploadDir, file.name); paths.push(destinationPath); diff --git a/src/renderer/components/hotbar/__tests__/hotbar-remove-command.test.tsx b/src/renderer/components/hotbar/__tests__/hotbar-remove-command.test.tsx index 95963844c8..8024f79319 100644 --- a/src/renderer/components/hotbar/__tests__/hotbar-remove-command.test.tsx +++ b/src/renderer/components/hotbar/__tests__/hotbar-remove-command.test.tsx @@ -9,14 +9,12 @@ import { fireEvent } from "@testing-library/react"; import React from "react"; import type { DependencyInjectionContainer } from "@ogre-tools/injectable"; import { getDiForUnitTesting } from "../../../getDiForUnitTesting"; -import { DiRender, renderFor } from "../../test-utils/renderFor"; -import hotbarManagerInjectable from "../../../../common/hotbar-store.injectable"; -import { ThemeStore } from "../../../theme.store"; +import { type DiRender, renderFor } from "../../test-utils/renderFor"; +import hotbarStoreInjectable from "../../../../common/hotbar-store/store.injectable"; import { ConfirmDialog } from "../../confirm-dialog"; -import type { HotbarStore } from "../../../../common/hotbar-store"; -import { UserStore } from "../../../../common/user-store"; +import type { HotbarStore } from "../../../../common/hotbar-store/store"; import mockFs from "mock-fs"; -import directoryForUserDataInjectable from "../../../../common/app-paths/directory-for-user-data/directory-for-user-data.injectable"; +import directoryForUserDataInjectable from "../../../../common/app-paths/directory-for-user-data.injectable"; const mockHotbars: { [id: string]: any } = { "1": { @@ -38,19 +36,14 @@ describe("", () => { di.override(directoryForUserDataInjectable, () => "some-directory-for-user-data"); render = renderFor(di); - - UserStore.createInstance(); - ThemeStore.createInstance(); }); afterEach(() => { mockFs.restore(); - ThemeStore.resetInstance(); - UserStore.resetInstance(); }); it("renders w/o errors", async () => { - di.override(hotbarManagerInjectable, () => ({ + di.override(hotbarStoreInjectable, () => ({ hotbars: [mockHotbars["1"]], getById: (id: string) => mockHotbars[id], remove: () => { @@ -69,7 +62,7 @@ describe("", () => { it("calls remove if you click on the entry", async () => { const removeMock = jest.fn(); - di.override(hotbarManagerInjectable, () => ({ + di.override(hotbarStoreInjectable, () => ({ hotbars: [mockHotbars["1"]], getById: (id: string) => mockHotbars[id], remove: removeMock, diff --git a/src/renderer/components/hotbar/hotbar-add-command.tsx b/src/renderer/components/hotbar/hotbar-add-command.tsx index c283a6dc05..11ad93ddd9 100644 --- a/src/renderer/components/hotbar/hotbar-add-command.tsx +++ b/src/renderer/components/hotbar/hotbar-add-command.tsx @@ -6,11 +6,11 @@ import React from "react"; import { observer } from "mobx-react"; import { Input, InputValidator } from "../input"; -import type { CreateHotbarData, CreateHotbarOptions } from "../../../common/hotbar-types"; +import type { CreateHotbarData, CreateHotbarOptions } from "../../../common/hotbar-store/hotbar-types"; import { withInjectables } from "@ogre-tools/injectable-react"; -import commandOverlayInjectable from "../command-palette/command-overlay.injectable"; -import hotbarManagerInjectable from "../../../common/hotbar-store.injectable"; import uniqueHotbarNameInjectable from "../input/validators/unique-hotbar-name.injectable"; +import createNewHotbarInjectable from "../../../common/hotbar-store/create-new-hotbar.injectable"; +import closeCommandDialogInjectable from "../command-palette/close-command-dialog.injectable"; interface Dependencies { closeCommandOverlay: () => void; @@ -49,8 +49,8 @@ const NonInjectedHotbarAddCommand = observer(({ closeCommandOverlay, addHotbar, export const HotbarAddCommand = withInjectables(NonInjectedHotbarAddCommand, { getProps: (di, props) => ({ - closeCommandOverlay: di.inject(commandOverlayInjectable).close, - addHotbar: di.inject(hotbarManagerInjectable).add, + closeCommandOverlay: di.inject(closeCommandDialogInjectable), + addHotbar: di.inject(createNewHotbarInjectable), uniqueHotbarName: di.inject(uniqueHotbarNameInjectable), ...props, }), diff --git a/src/renderer/components/hotbar/hotbar-entity-icon.tsx b/src/renderer/components/hotbar/hotbar-entity-icon.tsx index 63bf7dc6c9..f60440dc72 100644 --- a/src/renderer/components/hotbar/hotbar-entity-icon.tsx +++ b/src/renderer/components/hotbar/hotbar-entity-icon.tsx @@ -5,43 +5,39 @@ import styles from "./hotbar-entity-icon.module.scss"; -import React, { HTMLAttributes } from "react"; -import { makeObservable, observable } from "mobx"; +import React, { HTMLAttributes, useState } from "react"; +import { IComputedValue, observable } from "mobx"; import { observer } from "mobx-react"; +import { withInjectables } from "@ogre-tools/injectable-react"; -import type { CatalogEntity, CatalogEntityContextMenu, CatalogEntityContextMenuContext } from "../../../common/catalog"; -import { catalogCategoryRegistry } from "../../api/catalog-category-registry"; -import { catalogEntityRegistry } from "../../api/catalog-entity-registry"; -import { navigate } from "../../navigation"; +import type { CatalogCategory, CatalogEntity, CatalogEntityContextMenu, CatalogEntityData, CatalogEntityKindData } from "../../../common/catalog"; import { cssNames, IClassName } from "../../utils"; import { Icon } from "../icon"; import { HotbarIcon } from "./hotbar-icon"; import { LensKubernetesClusterStatus } from "../../../common/catalog-entities/kubernetes-cluster"; +import getCategoryForEntityInjectable from "../../catalog/get-category-for-entity.injectable"; +import activeEntityInjectable from "../../catalog/active-entity.injectable"; +import { navigate } from "../../navigation"; -interface Props extends HTMLAttributes { +export interface HotbarEntityIconProps extends HTMLAttributes { entity: CatalogEntity; index: number; errorClass?: IClassName; - add: (item: CatalogEntity, index: number) => void; - remove: (uid: string) => void; + removeById: (id: string) => void; size?: number; } -@observer -export class HotbarEntityIcon extends React.Component { - @observable private contextMenu: CatalogEntityContextMenuContext = { - menuItems: [], - navigate: (url: string) => navigate(url), - }; +interface Dependencies { + activeEntity: IComputedValue; + getCategoryForEntity: (data: CatalogEntityData & CatalogEntityKindData) => CatalogCategory; +} - constructor(props: Props) { - super(props); - makeObservable(this); - } +const NonInjectedHotbarEntityIcon = observer(({ getCategoryForEntity, activeEntity, entity, index, errorClass, removeById, size, className, children, ...elemProps }: Dependencies & HotbarEntityIconProps) => { + const [menuItems] = useState(observable.array()); - get kindIcon() { + const kindIcon = () => { const className = styles.badge; - const category = catalogCategoryRegistry.getCategoryForEntity(this.props.entity); + const category = getCategoryForEntity(entity); if (!category) { return ; @@ -52,61 +48,62 @@ export class HotbarEntityIcon extends React.Component { } else { return ; } - } + }; - get ledIcon() { - if (this.props.entity.kind !== "KubernetesCluster") { + const ledIcon = () => { + if (entity.kind !== "KubernetesCluster") { return null; } - const className = cssNames(styles.led, { [styles.online]: this.props.entity.status.phase === LensKubernetesClusterStatus.CONNECTED }); // TODO: make it more generic + const className = cssNames(styles.led, { [styles.online]: entity.status.phase === LensKubernetesClusterStatus.CONNECTED }); // TODO: make it more generic return
    ; - } + }; - isActive(item: CatalogEntity) { - return catalogEntityRegistry.activeEntity?.metadata?.uid == item.getId(); - } + const isActive = (item: CatalogEntity) => { + return activeEntity.get()?.metadata?.uid == item.getId(); + }; - async onMenuOpen() { - const menuItems: CatalogEntityContextMenu[] = []; + const onMenuOpen = () =>{ + menuItems.replace([ + { + title: "Remove from Hotbar", + onClick: () => removeById(entity.getId()), + }, + ]); - menuItems.unshift({ - title: "Remove from Hotbar", - onClick: () => this.props.remove(this.props.entity.metadata.uid), + entity.onContextMenuOpen({ + menuItems, + navigate, }); + }; - this.contextMenu.menuItems = menuItems; + return ( + onMenuOpen()} + disabled={!entity} + menuItems={menuItems} + tooltip={`${entity.metadata.name} (${entity.metadata.source})`} + {...elemProps} + > + { ledIcon() } + { kindIcon() } + + ); +}); - await this.props.entity.onContextMenuOpen(this.contextMenu); - } - - render() { - if (!this.contextMenu) { - return null; - } - - const { entity, errorClass, add, remove, index, children, ...elemProps } = this.props; - - return ( - this.onMenuOpen()} - disabled={!entity} - menuItems={this.contextMenu.menuItems} - tooltip={`${entity.metadata.name} (${entity.metadata.source})`} - {...elemProps} - > - { this.ledIcon } - { this.kindIcon } - - ); - } -} +export const HotbarEntityIcon = withInjectables(NonInjectedHotbarEntityIcon, { + getProps: (di, props) => ({ + getCategoryForEntity: di.inject(getCategoryForEntityInjectable), + activeEntity: di.inject(activeEntityInjectable), + ...props, + }), +}); diff --git a/src/renderer/components/hotbar/hotbar-icon.tsx b/src/renderer/components/hotbar/hotbar-icon.tsx index f22b30c66e..2419a8e0fa 100644 --- a/src/renderer/components/hotbar/hotbar-icon.tsx +++ b/src/renderer/components/hotbar/hotbar-icon.tsx @@ -9,14 +9,16 @@ import React, { useState } from "react"; import type { CatalogEntityContextMenu } from "../../../common/catalog"; import { cssNames } from "../../utils"; -import { ConfirmDialog } from "../confirm-dialog"; -import { Menu, MenuItem } from "../menu"; +import { Menu } from "../menu"; import { observer } from "mobx-react"; import { Avatar, AvatarProps } from "../avatar"; import { Icon } from "../icon"; import { Tooltip } from "../tooltip"; +import { withInjectables } from "@ogre-tools/injectable-react"; +import type { RenderEntityContextMenuItem } from "../../catalog/render-context-menu-item.injectable"; +import renderEntityContextMenuItemInjectable from "../../catalog/render-context-menu-item.injectable"; -export interface Props extends AvatarProps { +export interface HotbarIconProps extends AvatarProps { uid: string; source: string; material?: string; @@ -27,25 +29,28 @@ export interface Props extends AvatarProps { tooltip?: string; } -function onMenuItemClick(menuItem: CatalogEntityContextMenu) { - if (menuItem.confirm) { - ConfirmDialog.open({ - okButtonProps: { - primary: false, - accent: true, - }, - ok: () => { - menuItem.onClick(); - }, - message: menuItem.confirm.message, - }); - } else { - menuItem.onClick(); - } +interface Dependencies { + renderEntityContextMenuItem: RenderEntityContextMenuItem; } -export const HotbarIcon = observer(({ menuItems = [], size = 40, tooltip, ...props }: Props) => { - const { uid, title, src, material, active, className, source, disabled, onMenuOpen, onClick, children, ...rest } = props; +const NonInjectedHotbarIcon = observer(({ + renderEntityContextMenuItem, + menuItems = [], + size = 40, + tooltip, + uid, + title, + src, + material, + active, + className, + source, + disabled, + onMenuOpen, + onClick, + children, + ...rest +}: Dependencies & HotbarIconProps) => { const id = `hotbarIcon-${uid}`; const [menuOpen, setMenuOpen] = useState(false); @@ -80,15 +85,17 @@ export const HotbarIcon = observer(({ menuItems = [], size = 40, tooltip, ...pro onMenuOpen?.(); toggleMenu(); }} - close={() => toggleMenu()}> - { - menuItems.map((menuItem) => ( - onMenuItemClick(menuItem)}> - {menuItem.title} - - )) - } + close={() => toggleMenu()} + > + {menuItems.map(renderEntityContextMenuItem("title"))}
    ); }); + +export const HotbarIcon = withInjectables(NonInjectedHotbarIcon, { + getProps: (di, props) => ({ + renderEntityContextMenuItem: di.inject(renderEntityContextMenuItemInjectable), + ...props, + }), +}); diff --git a/src/renderer/components/hotbar/hotbar-menu.tsx b/src/renderer/components/hotbar/hotbar-menu.tsx index 4d6f57a89a..cfcb8b0520 100644 --- a/src/renderer/components/hotbar/hotbar-menu.tsx +++ b/src/renderer/components/hotbar/hotbar-menu.tsx @@ -5,89 +5,69 @@ import "./hotbar-menu.scss"; -import React from "react"; +import React, { useState } from "react"; import { observer } from "mobx-react"; import { HotbarEntityIcon } from "./hotbar-entity-icon"; import { cssNames, IClassName } from "../../utils"; -import { catalogEntityRegistry } from "../../api/catalog-entity-registry"; -import { HotbarStore } from "../../../common/hotbar-store"; -import type { CatalogEntity } from "../../api/catalog-entity"; import { DragDropContext, Draggable, Droppable, DropResult } from "react-beautiful-dnd"; import { HotbarSelector } from "./hotbar-selector"; import { HotbarCell } from "./hotbar-cell"; import { HotbarIcon } from "./hotbar-icon"; -import { defaultHotbarCells, HotbarItem } from "../../../common/hotbar-types"; -import { action, makeObservable, observable } from "mobx"; +import { defaultHotbarCells, HotbarItem } from "../../../common/hotbar-store/hotbar-types"; +import type { IComputedValue } from "mobx"; +import { withInjectables } from "@ogre-tools/injectable-react"; +import catalogEntityRegistryInjectable from "../../catalog/entity-registry.injectable"; +import activeHotbarInjectable from "../../../common/hotbar-store/active-hotbar.injectable"; +import type { CatalogEntity } from "../../../common/catalog"; +import onRunInjectable from "../../catalog/on-run.injectable"; +import getEntityByIdInjectable from "../../catalog/get-entity-by-id.injectable"; +import type { Hotbar } from "../../../common/hotbar-store/hotbar"; -interface Props { +export interface HotbarMenuProps { className?: IClassName; } -@observer -export class HotbarMenu extends React.Component { - @observable draggingOver = false; +interface Dependencies { + activeHotbar: IComputedValue; + onRun: (entity: CatalogEntity) => void; + getEntityById: (id: string) => CatalogEntity; +} - constructor(props: Props) { - super(props); - makeObservable(this); - } +const NonInjectedHotbarMenu = observer(({ activeHotbar, onRun, getEntityById, className }: Dependencies & HotbarMenuProps) => { + const [draggingOver, setDraggingOver] = useState(false); + const hotbar = activeHotbar.get(); - get hotbar() { - return HotbarStore.getInstance().getActive(); - } + const getEntity = (item: HotbarItem) => { + return getEntityById(item?.entity.uid) ?? null; + }; + const onDragStart = () => setDraggingOver(true); + const onDragEnd = ({ source, destination }: DropResult) => { + setDraggingOver(false); - getEntity(item: HotbarItem) { - const hotbar = HotbarStore.getInstance().getActive(); - - if (!hotbar) { - return null; - } - - return catalogEntityRegistry.getById(item?.entity.uid) ?? null; - } - - @action - onDragStart() { - this.draggingOver = true; - } - - @action - onDragEnd(result: DropResult) { - const { source, destination } = result; - - this.draggingOver = false; - - if (!destination) { // Dropped outside of the list + if (!destination) { + // Dropped outside of the list return; } const from = parseInt(source.droppableId); const to = parseInt(destination.droppableId); - HotbarStore.getInstance().restackItems(from, to); - } + hotbar.restackItems(from, to); + }; - removeItem(uid: string) { - const hotbar = HotbarStore.getInstance(); + const removeItemById = (id: string) => { + hotbar.removeItemById(id); + }; - hotbar.removeFromHotbar(uid); - } - - addItem(entity: CatalogEntity, index = -1) { - const hotbar = HotbarStore.getInstance(); - - hotbar.addToHotbar(entity, index); - } - - getMoveAwayDirection(entityId: string, cellIndex: number) { - const draggableItemIndex = this.hotbar.items.findIndex(item => item?.entity.uid == entityId); + const getMoveAwayDirection = (entityId: string, cellIndex: number) => { + const draggableItemIndex = hotbar.items.findIndex(item => item?.entity.uid == entityId); return draggableItemIndex > cellIndex ? "animateDown" : "animateUp"; - } + }; - renderGrid() { - return this.hotbar.items.map((item, index) => { - const entity = this.getEntity(item); + const renderGrid = () => { + return hotbar.items.map((item, index) => { + const entity = getEntity(item); return ( @@ -99,7 +79,7 @@ export class HotbarMenu extends React.Component { className={cssNames({ isDraggingOver: snapshot.isDraggingOver, isDraggingOwner: snapshot.draggingOverWith == entity?.getId(), - }, this.getMoveAwayDirection(snapshot.draggingOverWith, index))} + }, getMoveAwayDirection(snapshot.draggingOverWith, index))} {...provided.droppableProps} > {item && ( @@ -124,10 +104,9 @@ export class HotbarMenu extends React.Component { key={index} index={index} entity={entity} - onClick={() => catalogEntityRegistry.onRun(entity)} + onClick={() => onRun(entity)} className={cssNames({ isDragging: snapshot.isDragging })} - remove={this.removeItem} - add={this.addItem} + removeById={removeItemById} size={40} /> ) : ( @@ -139,7 +118,7 @@ export class HotbarMenu extends React.Component { menuItems={[ { title: "Remove from Hotbar", - onClick: () => this.removeItem(item.entity.uid), + onClick: () => removeItemById(item.entity.uid), }, ]} disabled @@ -157,22 +136,27 @@ export class HotbarMenu extends React.Component { ); }); - } + }; - render() { - const { className } = this.props; - const hotbarStore = HotbarStore.getInstance(); - const hotbar = hotbarStore.getActive(); - - return ( -
    -
    - this.onDragStart()} onDragEnd={(result) => this.onDragEnd(result)}> - {this.renderGrid()} - -
    - + return ( +
    +
    + onDragStart()} onDragEnd={(result) => onDragEnd(result)}> + {renderGrid()} +
    - ); - } -} + +
    + ); +}); + +export const HotbarMenu = withInjectables(NonInjectedHotbarMenu, { + getProps: (di, props) => ({ + entityRegistry: di.inject(catalogEntityRegistryInjectable), + activeHotbar: di.inject(activeHotbarInjectable), + onRun: di.inject(onRunInjectable), + getEntityById: di.inject(getEntityByIdInjectable), + ...props, + }), +}); + diff --git a/src/renderer/components/hotbar/hotbar-remove-command.tsx b/src/renderer/components/hotbar/hotbar-remove-command.tsx index ed3cd275ca..092f8a7414 100644 --- a/src/renderer/components/hotbar/hotbar-remove-command.tsx +++ b/src/renderer/components/hotbar/hotbar-remove-command.tsx @@ -6,11 +6,12 @@ import React from "react"; import { observer } from "mobx-react"; import { Select } from "../select"; -import hotbarManagerInjectable from "../../../common/hotbar-store.injectable"; -import { ConfirmDialog } from "../confirm-dialog"; +import hotbarStoreInjectable from "../../../common/hotbar-store/store.injectable"; +import type { ConfirmDialogParams } from "../confirm-dialog"; import { withInjectables } from "@ogre-tools/injectable-react"; import commandOverlayInjectable from "../command-palette/command-overlay.injectable"; -import type { Hotbar } from "../../../common/hotbar-types"; +import type { Hotbar } from "../../../common/hotbar-store/hotbar"; +import openConfirmDialogInjectable from "../confirm-dialog/dialog-open.injectable"; interface Dependencies { closeCommandOverlay: () => void; @@ -20,9 +21,10 @@ interface Dependencies { remove: (hotbar: Hotbar) => void; getDisplayLabel: (hotbar: Hotbar) => string; }; + openConfirmDialog: (params: ConfirmDialogParams) => void; } -const NonInjectedHotbarRemoveCommand = observer(({ closeCommandOverlay, hotbarManager }: Dependencies) => { +const NonInjectedHotbarRemoveCommand = observer(({ closeCommandOverlay, hotbarManager, openConfirmDialog }: Dependencies) => { const options = hotbarManager.hotbars.map(hotbar => ({ value: hotbar.id, label: hotbarManager.getDisplayLabel(hotbar), @@ -36,8 +38,7 @@ const NonInjectedHotbarRemoveCommand = observer(({ closeCommandOverlay, hotbarMa } closeCommandOverlay(); - // TODO: make confirm dialog injectable - ConfirmDialog.open({ + openConfirmDialog({ okButtonProps: { label: "Remove Hotbar", primary: false, @@ -71,7 +72,8 @@ const NonInjectedHotbarRemoveCommand = observer(({ closeCommandOverlay, hotbarMa export const HotbarRemoveCommand = withInjectables(NonInjectedHotbarRemoveCommand, { getProps: (di, props) => ({ closeCommandOverlay: di.inject(commandOverlayInjectable).close, - hotbarManager: di.inject(hotbarManagerInjectable), + hotbarManager: di.inject(hotbarStoreInjectable), + openConfirmDialog: di.inject(openConfirmDialogInjectable), ...props, }), }); diff --git a/src/renderer/components/hotbar/hotbar-rename-command.tsx b/src/renderer/components/hotbar/hotbar-rename-command.tsx index e82bd44d3c..a04892a927 100644 --- a/src/renderer/components/hotbar/hotbar-rename-command.tsx +++ b/src/renderer/components/hotbar/hotbar-rename-command.tsx @@ -6,9 +6,9 @@ import React, { useState } from "react"; import { observer } from "mobx-react"; import { Select } from "../select"; -import hotbarManagerInjectable from "../../../common/hotbar-store.injectable"; +import hotbarStoreInjectable from "../../../common/hotbar-store/store.injectable"; import { Input, InputValidator } from "../input"; -import type { Hotbar } from "../../../common/hotbar-types"; +import type { Hotbar } from "../../../common/hotbar-store/hotbar"; import { withInjectables } from "@ogre-tools/injectable-react"; import commandOverlayInjectable from "../command-palette/command-overlay.injectable"; import uniqueHotbarNameInjectable from "../input/validators/unique-hotbar-name.injectable"; @@ -84,7 +84,7 @@ const NonInjectedHotbarRenameCommand = observer(({ closeCommandOverlay, hotbarMa export const HotbarRenameCommand = withInjectables(NonInjectedHotbarRenameCommand, { getProps: (di, props) => ({ closeCommandOverlay: di.inject(commandOverlayInjectable).close, - hotbarManager: di.inject(hotbarManagerInjectable), + hotbarManager: di.inject(hotbarStoreInjectable), uniqueHotbarName: di.inject(uniqueHotbarNameInjectable), ...props, }), diff --git a/src/renderer/components/hotbar/hotbar-selector.tsx b/src/renderer/components/hotbar/hotbar-selector.tsx index 68a8f0e81b..1a08a995a5 100644 --- a/src/renderer/components/hotbar/hotbar-selector.tsx +++ b/src/renderer/components/hotbar/hotbar-selector.tsx @@ -7,11 +7,11 @@ import styles from "./hotbar-selector.module.scss"; import React, { useRef, useState } from "react"; import { Icon } from "../icon"; import { Badge } from "../badge"; -import hotbarManagerInjectable from "../../../common/hotbar-store.injectable"; +import hotbarStoreInjectable from "../../../common/hotbar-store/store.injectable"; import { HotbarSwitchCommand } from "./hotbar-switch-command"; import { Tooltip, TooltipPosition } from "../tooltip"; import { observer } from "mobx-react"; -import type { Hotbar } from "../../../common/hotbar-types"; +import type { Hotbar } from "../../../common/hotbar-store/hotbar"; import { withInjectables } from "@ogre-tools/injectable-react"; import commandOverlayInjectable from "../command-palette/command-overlay.injectable"; import { cssNames } from "../../utils"; @@ -86,7 +86,7 @@ const NonInjectedHotbarSelector = observer(({ hotbar, hotbarManager, openCommand export const HotbarSelector = withInjectables(NonInjectedHotbarSelector, { getProps: (di, props) => ({ - hotbarManager: di.inject(hotbarManagerInjectable), + hotbarManager: di.inject(hotbarStoreInjectable), openCommandOverlay: di.inject(commandOverlayInjectable).open, ...props, }), diff --git a/src/renderer/components/hotbar/hotbar-switch-command.tsx b/src/renderer/components/hotbar/hotbar-switch-command.tsx index 0263b971c6..e525063509 100644 --- a/src/renderer/components/hotbar/hotbar-switch-command.tsx +++ b/src/renderer/components/hotbar/hotbar-switch-command.tsx @@ -6,12 +6,12 @@ import React from "react"; import { observer } from "mobx-react"; import { Select } from "../select"; -import hotbarManagerInjectable from "../../../common/hotbar-store.injectable"; +import hotbarStoreInjectable from "../../../common/hotbar-store/store.injectable"; import type { CommandOverlay } from "../command-palette"; import { HotbarAddCommand } from "./hotbar-add-command"; import { HotbarRemoveCommand } from "./hotbar-remove-command"; import { HotbarRenameCommand } from "./hotbar-rename-command"; -import type { Hotbar } from "../../../common/hotbar-types"; +import type { Hotbar } from "../../../common/hotbar-store/hotbar"; import { withInjectables } from "@ogre-tools/injectable-react"; import commandOverlayInjectable from "../command-palette/command-overlay.injectable"; @@ -80,7 +80,7 @@ const NonInjectedHotbarSwitchCommand = observer(({ hotbarManager, commandOverlay export const HotbarSwitchCommand = withInjectables(NonInjectedHotbarSwitchCommand, { getProps: (di, props) => ({ - hotbarManager: di.inject(hotbarManagerInjectable), + hotbarManager: di.inject(hotbarStoreInjectable), commandOverlay: di.inject(commandOverlayInjectable), ...props, }), diff --git a/src/renderer/components/input/validators/unique-hotbar-name.injectable.ts b/src/renderer/components/input/validators/unique-hotbar-name.injectable.ts index 9676d189d5..fb69258e88 100644 --- a/src/renderer/components/input/validators/unique-hotbar-name.injectable.ts +++ b/src/renderer/components/input/validators/unique-hotbar-name.injectable.ts @@ -4,15 +4,26 @@ */ import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; -import hotbarManagerInjectable from "../../../../common/hotbar-store.injectable"; +import getHotbarByNameInjectable from "../../../../common/hotbar-store/get-hotbar-by-name.injectable"; +import type { Hotbar } from "../../../../common/hotbar-store/hotbar"; import type { InputValidator } from "../input_validators"; -const uniqueHotbarNameInjectable = getInjectable({ - instantiate: di => ({ +interface Dependencies { + getHotbarByName: (name: string) => Hotbar; +} + +function getUniqueHotbarName({ getHotbarByName }: Dependencies): InputValidator { + return { condition: ({ required }) => required, message: () => "Hotbar with this name already exists", - validate: value => !di.inject(hotbarManagerInjectable).getByName(value), - } as InputValidator), + validate: value => !getHotbarByName(value), + }; +} + +const uniqueHotbarNameInjectable = getInjectable({ + instantiate: di => getUniqueHotbarName({ + getHotbarByName: di.inject(getHotbarByNameInjectable), + }), lifecycle: lifecycleEnum.singleton, }); diff --git a/src/renderer/components/item-object-list/item-list-layout.tsx b/src/renderer/components/item-object-list/item-list-layout.tsx index 2d6a91fe70..54498733aa 100644 --- a/src/renderer/components/item-object-list/item-list-layout.tsx +++ b/src/renderer/components/item-object-list/item-list-layout.tsx @@ -7,21 +7,11 @@ import "./item-list-layout.scss"; import groupBy from "lodash/groupBy"; import React, { ReactNode } from "react"; -import { computed, makeObservable } from "mobx"; +import { computed, IComputedValue, makeObservable } from "mobx"; import { observer } from "mobx-react"; -import { ConfirmDialog, ConfirmDialogParams } from "../confirm-dialog"; +import type { ConfirmDialogParams } from "../confirm-dialog"; import { Table, TableCell, TableCellProps, TableHead, TableProps, TableRow, TableRowProps, TableSortCallbacks } from "../table"; -import { - boundMethod, - cssNames, - IClassName, - isReactNode, - noop, - ObservableToggleSet, - prevDefault, - stopPropagation, - StorageHelper, -} from "../../utils"; +import { boundMethod, cssNames, IClassName, isReactNode, noop, prevDefault, stopPropagation, StorageLayer } from "../../utils"; import { AddRemoveButtons, AddRemoveButtonsProps } from "../add-remove-buttons"; import { NoItems } from "../no-items"; import { Spinner } from "../spinner"; @@ -29,17 +19,17 @@ import type { ItemObject, ItemStore } from "../../../common/item.store"; import { SearchInputUrlProps, SearchInputUrl } from "../input"; import { Filter, FilterType, pageFilters } from "./page-filters.store"; import { PageFiltersList } from "./page-filters-list"; -import { ThemeStore } from "../../theme.store"; +import type { Theme } from "../../themes/store"; import { MenuActions } from "../menu/menu-actions"; import { MenuItem } from "../menu"; import { Checkbox } from "../checkbox"; -import { UserStore } from "../../../common/user-store"; -import type { NamespaceStore } from "../+namespaces/namespace-store/namespace.store"; -import namespaceStoreInjectable from "../+namespaces/namespace-store/namespace-store.injectable"; import { withInjectables } from "@ogre-tools/injectable-react"; -import itemListLayoutStorageInjectable - from "./item-list-layout-storage/item-list-layout-storage.injectable"; - +import openConfirmDialogInjectable from "../confirm-dialog/dialog-open.injectable"; +import type { ItemListLayoutState } from "./storage.injectable"; +import itemListLayoutStorageInjectable from "./storage.injectable"; +import isTableColumnHiddenInjectable from "../../../common/user-preferences/is-table-column-hidden.injectable"; +import toggleTableColumnVisibilityInjectable from "../../../common/user-preferences/toggle-table-column-visibility.injectable"; +import activeThemeInjectable from "../../themes/active-theme.injectable"; export type SearchFilter = (item: I) => string | number | (string | number)[]; export type SearchFilters = Record>; @@ -53,6 +43,14 @@ export interface HeaderPlaceholders { info?: ReactNode; } +interface Dependencies { + openConfirmDialog: (params: ConfirmDialogParams) => void; + storage: StorageLayer; + isTableColumnHidden: (tableId: string, ...columnIds: string[]) => boolean; + toggleTableColumnVisibility: (tableId: string, columnId: string) => void; + activeTheme: IComputedValue; +} + export type HeaderCustomizer = (placeholders: HeaderPlaceholders) => HeaderPlaceholders; export interface ItemListLayoutProps { tableId?: string; @@ -82,7 +80,7 @@ export interface ItemListLayoutProps { renderTableHeader: TableCellProps[] | null; renderTableContents: (item: I) => (ReactNode | TableCellProps)[]; renderItemMenu?: (item: I, store: ItemStore) => ReactNode; - customizeTableRowProps?: (item: I) => Partial; + customizeTableRowProps?: (item: I) => Partial>; addRemoveButtons?: Partial; virtual?: boolean; @@ -122,11 +120,6 @@ const defaultProps: Partial> = { failedToLoadMessage: "Failed to load items", }; -interface Dependencies { - namespaceStore: NamespaceStore; - itemListLayoutStorage: StorageHelper<{ showFilters: boolean }>; -} - @observer class NonInjectedItemListLayout extends React.Component & Dependencies> { static defaultProps = defaultProps as object; @@ -136,25 +129,25 @@ class NonInjectedItemListLayout extends React.Component extends React.Component store.loadAll(this.props.namespaceStore.contextNamespaces)); + stores.forEach(store => store.loadAll()); } private filterCallbacks: ItemsFilters = { @@ -302,7 +295,7 @@ class NonInjectedItemListLayout extends React.Component 0 ? <>, and {tailCount} more : null; const message = selectedCount <= 1 ?

    Remove item {selectedNames}?

    :

    Remove {selectedCount} items {selectedNames}{tail}?

    ; - ConfirmDialog.open({ + this.props.openConfirmDialog({ ok: removeSelectedItems, labelOk: "Remove", message, @@ -451,11 +444,12 @@ class NonInjectedItemListLayout extends React.Component @@ -484,23 +478,23 @@ class NonInjectedItemListLayout extends React.Component {renderTableHeader.map((cellProps, index) => ( - !cellProps.showWithColumn && ( + isConfigurable && !cellProps.showWithColumn && ( `} value={this.showColumn(cellProps)} - onChange={() => UserStore.getInstance().toggleTableColumnVisibility(tableId, cellProps.id)} + onChange={() => toggleTableColumnVisibility(tableId, cellProps.id)} /> ) @@ -527,23 +521,17 @@ class NonInjectedItemListLayout extends React.Component( - props: ItemListLayoutProps, -) { - const InjectedItemListLayout = withInjectables< - Dependencies, - ItemListLayoutProps - >( - NonInjectedItemListLayout, - - { - getProps: (di, props) => ({ - namespaceStore: di.inject(namespaceStoreInjectable), - itemListLayoutStorage: di.inject(itemListLayoutStorageInjectable), - ...props, - }), - }, - ); +const InjectedItemListLayout = withInjectables>(NonInjectedItemListLayout, { + getProps: (di, props) => ({ + openConfirmDialog: di.inject(openConfirmDialogInjectable), + storage: di.inject(itemListLayoutStorageInjectable), + isTableColumnHidden: di.inject(isTableColumnHiddenInjectable), + toggleTableColumnVisibility: di.inject(toggleTableColumnVisibilityInjectable), + activeTheme: di.inject(activeThemeInjectable), + ...props, + }), +}); +export function ItemListLayout(props: ItemListLayoutProps) { return ; } diff --git a/src/renderer/components/item-object-list/storage.injectable.ts b/src/renderer/components/item-object-list/storage.injectable.ts new file mode 100644 index 0000000000..25cfec745e --- /dev/null +++ b/src/renderer/components/item-object-list/storage.injectable.ts @@ -0,0 +1,25 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import type { StorageLayer } from "../../utils"; +import createStorageInjectable from "../../utils/create-storage/create-storage.injectable"; + +export interface ItemListLayoutState { + showFilters: boolean; +} + +let storage: StorageLayer; + +const itemListLayoutStorageInjectable = getInjectable({ + setup: async (di) => { + storage = await di.inject(createStorageInjectable)("item_list_layout", { + showFilters: false, + }); + }, + instantiate: () => storage, + lifecycle: lifecycleEnum.singleton, +}); + +export default itemListLayoutStorageInjectable ; diff --git a/src/renderer/components/kube-object-details/kube-details-items/internal-items.tsx b/src/renderer/components/kube-object-details/kube-details-items/internal-items.tsx new file mode 100644 index 0000000000..ba2725cd7f --- /dev/null +++ b/src/renderer/components/kube-object-details/kube-details-items/internal-items.tsx @@ -0,0 +1,430 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import React from "react"; +import type { KubeObjectDetailsProps } from ".."; +import { type HpaDetailsProps, HpaDetails } from "../../+autoscalers"; +import { LimitRangeDetails } from "../../+limit-ranges"; +import { ConfigMapDetails } from "../../+config-maps"; +import { PodDisruptionBudgetDetails } from "../../+pod-disruption-budgets"; +import { ResourceQuotaDetails } from "../../+resource-quotas"; +import { SecretDetails } from "../../+secrets"; +import { CustomResourceDefinitionDetails } from "../../+custom-resource"; +import { EventDetails } from "../../+events"; +import { KubeEventDetails } from "../../+events/kube-event-details"; +import { NamespaceDetails } from "../../+namespaces"; +import { EndpointDetails } from "../../+endpoints"; +import { IngressDetails } from "../../+ingresses"; +import { NetworkPolicyDetails } from "../../+network-policies"; +import { ServiceDetails } from "../../+services"; +import { NodeDetails } from "../../+nodes"; +import { PodSecurityPolicyDetails } from "../../+pod-security-policies"; +import { StorageClassDetails } from "../../+storage-classes"; +import { PersistentVolumeClaimDetails } from "../../+persistent-volume-claims"; +import { PersistentVolumeDetails } from "../../+persistent-volumes"; +import { ClusterRoleBindingDetails } from "../../+cluster-role-bindings"; +import { ClusterRoleDetails } from "../../+cluster-roles"; +import { RoleBindingDetails } from "../../+role-bindings"; +import { RoleDetails } from "../../+roles"; +import { ServiceAccountsDetails } from "../../+service-accounts"; +import { CronJobDetails } from "../../+cronjobs"; +import { DaemonSetDetails } from "../../+daemonsets"; +import { DeploymentDetails } from "../../+deployments"; +import { JobDetails } from "../../+jobs"; +import { PodDetails } from "../../+pods"; +import { ReplicaSetDetails } from "../../+replica-sets"; +import { StatefulSetDetails } from "../../+stateful-sets"; +import type { KubeObjectDetailRegistration } from "./kube-detail-items"; + +export const internalItems: Required[] = [ + { + kind: "HorizontalPodAutoscaler", + apiVersions: ["autoscaling/v2beta1"], + components: { + // Note: this line is left in the long form as a validation that this usecase is valid + Details: (props: HpaDetailsProps) => , + }, + }, + { + kind: "HorizontalPodAutoscaler", + apiVersions: ["autoscaling/v2beta1"], + priority: 5, + components: { + // Note: this line is left in the long form as a validation that this usecase is valid + Details: (props: KubeObjectDetailsProps) => , + }, + }, + { + kind: "LimitRange", + apiVersions: ["v1"], + components: { + Details: LimitRangeDetails, + }, + }, + { + kind: "ConfigMap", + apiVersions: ["v1"], + components: { + Details: ConfigMapDetails, + }, + }, + { + kind: "ConfigMap", + apiVersions: ["v1"], + priority: 5, + components: { + Details: KubeEventDetails, + }, + }, + { + kind: "PodDisruptionBudget", + apiVersions: ["policy/v1beta1"], + components: { + Details: PodDisruptionBudgetDetails, + }, + }, + { + kind: "ResourceQuota", + apiVersions: ["v1"], + components: { + Details: ResourceQuotaDetails, + }, + }, + { + kind: "Secret", + apiVersions: ["v1"], + components: { + Details: SecretDetails, + }, + }, + { + kind: "CustomResourceDefinition", + apiVersions: ["apiextensions.k8s.io/v1", "apiextensions.k8s.io/v1beta1"], + components: { + Details: CustomResourceDefinitionDetails, + }, + }, + { + kind: "Event", + apiVersions: ["v1"], + components: { + Details: EventDetails, + }, + }, + { + kind: "Namespace", + apiVersions: ["v1"], + components: { + Details: NamespaceDetails, + }, + }, + { + kind: "Endpoints", + apiVersions: ["v1"], + components: { + Details: EndpointDetails, + }, + }, + { + kind: "Endpoints", + apiVersions: ["v1"], + priority: 5, + components: { + Details: KubeEventDetails, + }, + }, + { + kind: "Ingress", + apiVersions: ["networking.k8s.io/v1", "extensions/v1beta1"], + components: { + Details: IngressDetails, + }, + }, + { + kind: "Ingress", + apiVersions: ["networking.k8s.io/v1", "extensions/v1beta1"], + priority: 5, + components: { + Details: KubeEventDetails, + }, + }, + { + kind: "NetworkPolicy", + apiVersions: ["networking.k8s.io/v1"], + components: { + Details: NetworkPolicyDetails, + }, + }, + { + kind: "NetworkPolicy", + apiVersions: ["networking.k8s.io/v1"], + priority: 5, + components: { + Details: KubeEventDetails, + }, + }, + { + kind: "Service", + apiVersions: ["v1"], + components: { + Details: ServiceDetails, + }, + }, + { + kind: "Service", + apiVersions: ["v1"], + priority: 5, + components: { + Details: KubeEventDetails, + }, + }, + { + kind: "Node", + apiVersions: ["v1"], + components: { + Details: NodeDetails, + }, + }, + { + kind: "Node", + apiVersions: ["v1"], + priority: 5, + components: { + Details: KubeEventDetails, + }, + }, + { + kind: "PodSecurityPolicy", + apiVersions: ["policy/v1beta1"], + components: { + Details: PodSecurityPolicyDetails, + }, + }, + { + kind: "StorageClass", + apiVersions: ["storage.k8s.io/v1"], + components: { + Details: StorageClassDetails, + }, + }, + { + kind: "StorageClass", + apiVersions: ["storage.k8s.io/v1"], + priority: 5, + components: { + Details: KubeEventDetails, + }, + }, + { + kind: "PersistentVolumeClaim", + apiVersions: ["v1"], + components: { + Details: PersistentVolumeClaimDetails, + }, + }, + { + kind: "PersistentVolumeClaim", + apiVersions: ["v1"], + priority: 5, + components: { + Details: KubeEventDetails, + }, + }, + { + kind: "PersistentVolume", + apiVersions: ["v1"], + components: { + Details: PersistentVolumeDetails, + }, + }, + { + kind: "PersistentVolume", + apiVersions: ["v1"], + priority: 5, + components: { + Details: KubeEventDetails, + }, + }, + { + kind: "Role", + apiVersions: ["rbac.authorization.k8s.io/v1"], + components: { + Details: RoleDetails, + }, + }, + { + kind: "Role", + apiVersions: ["rbac.authorization.k8s.io/v1"], + priority: 5, + components: { + Details: KubeEventDetails, + }, + }, + { + kind: "ClusterRole", + apiVersions: ["rbac.authorization.k8s.io/v1"], + components: { + Details: ClusterRoleDetails, + }, + }, + { + kind: "ClusterRole", + apiVersions: ["rbac.authorization.k8s.io/v1"], + priority: 5, + components: { + Details: KubeEventDetails, + }, + }, + { + kind: "RoleBinding", + apiVersions: ["rbac.authorization.k8s.io/v1"], + components: { + Details: RoleBindingDetails, + }, + }, + { + kind: "RoleBinding", + apiVersions: ["rbac.authorization.k8s.io/v1"], + priority: 5, + components: { + Details: KubeEventDetails, + }, + }, + { + kind: "ClusterRoleBinding", + apiVersions: ["rbac.authorization.k8s.io/v1"], + components: { + Details: ClusterRoleBindingDetails, + }, + }, + { + kind: "ClusterRoleBinding", + apiVersions: ["rbac.authorization.k8s.io/v1"], + priority: 5, + components: { + Details: KubeEventDetails, + }, + }, + { + kind: "ServiceAccount", + apiVersions: ["v1"], + components: { + Details: ServiceAccountsDetails, + }, + }, + { + kind: "ServiceAccount", + apiVersions: ["v1"], + priority: 5, + components: { + Details: KubeEventDetails, + }, + }, + { + kind: "CronJob", + apiVersions: ["batch/v1beta1"], + components: { + Details: CronJobDetails, + }, + }, + { + kind: "CronJob", + apiVersions: ["batch/v1beta1"], + priority: 5, + components: { + Details: KubeEventDetails, + }, + }, + { + kind: "DaemonSet", + apiVersions: ["apps/v1"], + components: { + Details: DaemonSetDetails, + }, + }, + { + kind: "DaemonSet", + apiVersions: ["apps/v1"], + priority: 5, + components: { + Details: KubeEventDetails, + }, + }, + { + kind: "Deployment", + apiVersions: ["apps/v1"], + components: { + Details: DeploymentDetails, + }, + }, + { + kind: "Deployment", + apiVersions: ["apps/v1"], + priority: 5, + components: { + Details: KubeEventDetails, + }, + }, + { + kind: "Job", + apiVersions: ["batch/v1"], + components: { + Details: JobDetails, + }, + }, + { + kind: "Job", + apiVersions: ["batch/v1"], + priority: 5, + components: { + Details: KubeEventDetails, + }, + }, + { + kind: "Pod", + apiVersions: ["v1"], + components: { + Details: PodDetails, + }, + }, + { + kind: "Pod", + apiVersions: ["v1"], + priority: 5, + components: { + Details: KubeEventDetails, + }, + }, + { + kind: "ReplicaSet", + apiVersions: ["apps/v1"], + components: { + Details: ReplicaSetDetails, + }, + }, + { + kind: "ReplicaSet", + apiVersions: ["apps/v1"], + priority: 5, + components: { + Details: KubeEventDetails, + }, + }, + { + kind: "StatefulSet", + apiVersions: ["apps/v1"], + components: { + Details: StatefulSetDetails, + }, + }, + { + kind: "StatefulSet", + apiVersions: ["apps/v1"], + priority: 5, + components: { + Details: KubeEventDetails, + }, + }, +].map(({ priority = 50, ...item }) => ({ priority, ...item })); diff --git a/src/renderer/components/kube-object-details/kube-details-items/kube-detail-items.d.ts b/src/renderer/components/kube-object-details/kube-details-items/kube-detail-items.d.ts new file mode 100644 index 0000000000..a9c09ee911 --- /dev/null +++ b/src/renderer/components/kube-object-details/kube-details-items/kube-detail-items.d.ts @@ -0,0 +1,21 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +/** + * The components for a details item + */ +export interface KubeObjectDetailComponents { + Details: React.ComponentType>; +} + +/** + * The registration type for extensions + */ +export interface KubeObjectDetailRegistration { + kind: string; + apiVersions: string[]; + components: KubeObjectDetailComponents; + priority?: number; +} diff --git a/src/renderer/components/kube-object-details/kube-details-items/kube-details.injectable.ts b/src/renderer/components/kube-object-details/kube-details-items/kube-details.injectable.ts new file mode 100644 index 0000000000..02d38f897e --- /dev/null +++ b/src/renderer/components/kube-object-details/kube-details-items/kube-details.injectable.ts @@ -0,0 +1,45 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { orderBy } from "lodash"; +import { computed } from "mobx"; +import rendererExtensionsInjectable from "../../../../extensions/renderer-extensions.injectable"; +import { internalItems } from "./internal-items"; +import type { KubeObjectDetailComponents } from "./kube-detail-items"; + +type Kind = string; +type ApiVersion = string; + +const kubeDetailItemsInjectable = getInjectable({ + instantiate: di => computed(() => { + const res = new Map>(); + const extensionItems = di.inject(rendererExtensionsInjectable).get() + .flatMap(ext => ext.kubeObjectDetailItems) + .map(({ priority = 50, ...item }) => ({ priority, ...item })); + const items = orderBy([...internalItems, ...extensionItems], "priority", "desc"); + + for (const item of items) { + if (!res.has(item.kind)) { + res.set(item.kind, new Map()); + } + + const byVersions = res.get(item.kind); + + for (const apiVersion of item.apiVersions) { + if (!byVersions.has(apiVersion)) { + byVersions.set(apiVersion, []); + } + + byVersions.get(apiVersion).push(item.components); + } + } + + return res; + }), + lifecycle: lifecycleEnum.singleton, +}); + +export default kubeDetailItemsInjectable; diff --git a/src/renderer/components/kube-object-details/kube-object-details.tsx b/src/renderer/components/kube-object-details/kube-object-details.tsx index 0301302714..a9ce10e2d6 100644 --- a/src/renderer/components/kube-object-details/kube-object-details.tsx +++ b/src/renderer/components/kube-object-details/kube-object-details.tsx @@ -5,135 +5,141 @@ import "./kube-object-details.scss"; -import React from "react"; -import { disposeOnUnmount, observer } from "mobx-react"; -import { computed, observable, reaction, makeObservable } from "mobx"; +import React, { ReactNode, useEffect, useState } from "react"; +import { observer } from "mobx-react"; +import { reaction, IComputedValue } from "mobx"; import { Drawer } from "../drawer"; import type { KubeObject } from "../../../common/k8s-api/kube-object"; import { Spinner } from "../spinner"; -import { apiManager } from "../../../common/k8s-api/api-manager"; -import { crdStore } from "../+custom-resources/crd.store"; +import type { ApiManager } from "../../../common/k8s-api/api-manager"; +import type { CustomResourceDefinitionStore } from "../+custom-resource/store"; import { KubeObjectMenu } from "../kube-object-menu"; -import { KubeObjectDetailRegistry } from "../../api/kube-object-detail-registry"; -import { CrdResourceDetails } from "../+custom-resources"; +import { CustomResourceDetails } from "../+custom-resource"; import { KubeObjectMeta } from "../kube-object-meta"; import { hideDetails, kubeDetailsUrlParam } from "../kube-detail-params"; - +import { withInjectables } from "@ogre-tools/injectable-react"; +import type { KubeObjectDetailComponents } from "./kube-details-items/kube-detail-items"; +import kubeDetailItemsInjectable from "./kube-details-items/kube-details.injectable"; +import apiManagerInjectable from "../../../common/k8s-api/api-manager.injectable"; +import crdStoreInjectable from "../+custom-resource/store.injectable"; export interface KubeObjectDetailsProps { className?: string; object: T; } -@observer -export class KubeObjectDetails extends React.Component { - @observable isLoading = false; - @observable.ref loadingError: React.ReactNode; +interface Dependencies { + kubeDetailItems: IComputedValue[]>>>; + apiManager: ApiManager; + crdStore: CustomResourceDefinitionStore; +} - constructor(props: {}) { - super(props); - makeObservable(this); - } +const NonInjectedKubeObjectDetails = observer(({ kubeDetailItems, apiManager, crdStore }: Dependencies) => { + const [loading, setLoading] = useState(false); + const [loadingError, setLoadingError] = useState(""); - @computed get path() { - return kubeDetailsUrlParam.get(); - } - - @computed get object() { + const getKubeObjectByPath = (path: string): KubeObject | undefined => { try { return apiManager - .getStore(this.path) - ?.getByPath(this.path); + .getStore(path) + ?.getByPath(path); } catch (error) { - console.error(`[KUBE-OBJECT-DETAILS]: failed to get store or object: ${error}`, { path: this.path }); - - return undefined; + return void console.error(`[KUBE-OBJECT-DETAILS]: failed to get store or object: ${error}`, { path }); } - } + }; - @disposeOnUnmount - loader = reaction(() => [ - this.path, - this.object, // resource might be updated via watch-event or from already opened details - crdStore.items.length, // crd stores initialized after loading - ], async () => { - this.loadingError = ""; - const { path, object } = this; + useEffect(() => reaction( + () => [ + kubeDetailsUrlParam.get(), + getKubeObjectByPath(kubeDetailsUrlParam.get()), // resource might be updated via watch-event or from already opened details + crdStore.items.length, // crd stores initialized after loading + ] as const, async ([path, kubeObject]) => { + setLoadingError(""); - if (!object) { - const store = apiManager.getStore(path); + if (!kubeObject) { + const store = apiManager.getStore(path); - if (store) { - this.isLoading = true; + if (store) { + setLoading(true); - try { - await store.loadFromPath(path); - } catch (err) { - this.loadingError = <>Resource loading has failed: {err.toString()}; - } finally { - this.isLoading = false; + try { + await store.loadFromPath(path); + } catch (err) { + setLoadingError(<>Resource loading has failed: {err.toString()}); + } finally { + setLoading(false); + } } } - } - }); + }, + ), []); - render() { - const { object, isLoading, loadingError } = this; - const isOpen = !!(object || isLoading || loadingError); + const detailsPath = kubeDetailsUrlParam.get(); + const kubeObject = getKubeObjectByPath(detailsPath); - if (!object) { - return ( - } - onClose={hideDetails} - > - {isLoading && } - {loadingError &&
    {loadingError}
    } -
    - ); - } - - const { kind, getName } = object; - const title = `${kind}: ${getName()}`; - const details = KubeObjectDetailRegistry - .getInstance() - .getItemsForKind(object.kind, object.apiVersion) - .map((item, index) => ( - - )); - - if (details.length === 0) { - const crd = crdStore.getByObject(object); - - /** - * This is a fallback so that if a custom resource object doesn't have - * any defined details we should try and display at least some details - */ - if (crd) { - details.push(); - } - } - - if (details.length === 0) { - // if we still don't have any details to show, just show the standard object metadata - details.push(); - } + const isOpen = !!(kubeObject || loading || loadingError); + if (!kubeObject) { return ( } + title="" + toolbar={} onClose={hideDetails} > - {isLoading && } + {loading && } {loadingError &&
    {loadingError}
    } - {details}
    ); } -} + + const { kind, getName } = kubeObject; + const title = `${kind}: ${getName()}`; + const details: React.ReactElement[] = kubeDetailItems.get() + .get(kubeObject.kind) + .get(kubeObject.apiVersion) + .map(({ Details }, index) => ( +
    + )); + + if (details.length === 0) { + const crd = crdStore.getByObject(kubeObject); + + /** + * This is a fallback so that if a custom resource object doesn't have + * any defined details we should try and display at least some details + */ + if (crd) { + details.push(); + } + } + + if (details.length === 0) { + // if we still don't have any details to show, just show the standard object metadata + details.push(); + } + + return ( + } + onClose={hideDetails} + > + {loading && } + {loadingError &&
    {loadingError}
    } + <>{details} +
    + ); +}); + +export const KubeObjectDetails = withInjectables(NonInjectedKubeObjectDetails, { + getProps: (di, props) => ({ + kubeDetailItems: di.inject(kubeDetailItemsInjectable), + apiManager: di.inject(apiManagerInjectable), + crdStore: di.inject(crdStoreInjectable), + ...props, + }), +}); diff --git a/src/renderer/components/kube-object-list-layout/kube-object-list-layout.tsx b/src/renderer/components/kube-object-list-layout/kube-object-list-layout.tsx index e02a069c25..e7b4873241 100644 --- a/src/renderer/components/kube-object-list-layout/kube-object-list-layout.tsx +++ b/src/renderer/components/kube-object-list-layout/kube-object-list-layout.tsx @@ -19,10 +19,9 @@ import { kubeSelectedUrlParam, toggleDetails } from "../kube-detail-params"; import { Icon } from "../icon"; import { TooltipPosition } from "../tooltip"; import { withInjectables } from "@ogre-tools/injectable-react"; -import type { ClusterFrameContext } from "../../cluster-frame-context/cluster-frame-context"; +import type { FrameContext } from "../../cluster-frame-context/cluster-frame-context"; import clusterFrameContextInjectable from "../../cluster-frame-context/cluster-frame-context.injectable"; -import kubeWatchApiInjectable - from "../../kube-watch-api/kube-watch-api.injectable"; +import kubeWatchApiInjectable from "../../kube-watch-api/kube-watch-api.injectable"; import type { KubeWatchSubscribeStoreOptions } from "../../kube-watch-api/kube-watch-api"; export interface KubeObjectListLayoutProps extends ItemListLayoutProps { @@ -37,7 +36,7 @@ const defaultProps: Partial> = { }; interface Dependencies { - clusterFrameContext: ClusterFrameContext + clusterFrameContext: FrameContext subscribeToStores: (stores: KubeObjectStore[], options: KubeWatchSubscribeStoreOptions) => Disposer } diff --git a/src/renderer/components/kube-object-menu/__snapshots__/kube-object-menu.test.tsx.snap b/src/renderer/components/kube-object-menu/__snapshots__/kube-object-menu.test.tsx.snap index 0d1d591668..b6d1dbcb37 100644 --- a/src/renderer/components/kube-object-menu/__snapshots__/kube-object-menu.test.tsx.snap +++ b/src/renderer/components/kube-object-menu/__snapshots__/kube-object-menu.test.tsx.snap @@ -18,6 +18,7 @@ exports[`kube-object-menu given kube object renders 1`] = ` > @@ -38,9 +39,10 @@ exports[`kube-object-menu given kube object renders 1`] = `
    `; @@ -62,6 +64,7 @@ exports[`kube-object-menu given kube object when removing kube object renders 1` > @@ -81,6 +84,11 @@ exports[`kube-object-menu given kube object when removing kube object renders 1`
    +
    @@ -178,6 +185,11 @@ exports[`kube-object-menu given kube object with namespace when removing kube ob
    +
    +
    { - const cluster = di.inject(clusterInjectable); - - return cluster?.name; - }, - + instantiate: (di) => di.inject(activeClusterEntityInjectable)?.name, lifecycle: lifecycleEnum.transient, }); diff --git a/src/renderer/components/kube-object-menu/dependencies/cluster.injectable.ts b/src/renderer/components/kube-object-menu/dependencies/cluster.injectable.ts deleted file mode 100644 index 07a82434ff..0000000000 --- a/src/renderer/components/kube-object-menu/dependencies/cluster.injectable.ts +++ /dev/null @@ -1,14 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ -import { getActiveClusterEntity } from "../../../api/catalog-entity-registry"; - -import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; - -const clusterInjectable = getInjectable({ - instantiate: () => getActiveClusterEntity(), - lifecycle: lifecycleEnum.transient, -}); - -export default clusterInjectable; diff --git a/src/renderer/components/kube-object-menu/kube-object-menu.test.tsx b/src/renderer/components/kube-object-menu/kube-object-menu.test.tsx index 935c4b9208..a05a1ca691 100644 --- a/src/renderer/components/kube-object-menu/kube-object-menu.test.tsx +++ b/src/renderer/components/kube-object-menu/kube-object-menu.test.tsx @@ -9,22 +9,23 @@ import "@testing-library/jest-dom/extend-expect"; import { KubeObject } from "../../../common/k8s-api/kube-object"; import userEvent from "@testing-library/user-event"; import type { ConfigurableDependencyInjectionContainer } from "@ogre-tools/injectable"; -import type { KubeObjectMenuRegistration } from "../../../extensions/registries"; import { KubeObjectMenuRegistry } from "../../../extensions/registries"; import { ConfirmDialog } from "../confirm-dialog"; import asyncFn, { AsyncFnMock } from "@async-fn/jest"; import { getDiForUnitTesting } from "../../getDiForUnitTesting"; -import clusterInjectable from "./dependencies/cluster.injectable"; import hideDetailsInjectable from "./dependencies/hide-details.injectable"; -import editResourceTabInjectable from "../dock/edit-resource-tab/edit-resource-tab.injectable"; -import { TabKind } from "../dock/dock-store/dock.store"; +import { TabKind } from "../dock/store"; import kubeObjectMenuRegistryInjectable from "./dependencies/kube-object-menu-items/kube-object-menu-registry.injectable"; import { DiRender, renderFor } from "../test-utils/renderFor"; -import type { Cluster } from "../../../common/cluster/cluster"; import type { ApiManager } from "../../../common/k8s-api/api-manager"; -import apiManagerInjectable from "./dependencies/api-manager.injectable"; import { KubeObjectMenu } from "./index"; +import apiManagerInjectable from "../../../common/k8s-api/api-manager.injectable"; +import newEditResourceTabInjectable from "../dock/edit-resource/create-tab.injectable"; +import uniqueIdInjectable from "../../../common/utils/unique-id.injectable"; +import clusterNameInjectable from "./dependencies/cluster-name.injectable"; + +jest.mock("lodash/uniqueId", () => (val?: string) => val); // TODO: Make tooltips free of side effects by making it deterministic jest.mock("../tooltip"); @@ -37,24 +38,21 @@ describe("kube-object-menu", () => { di = getDiForUnitTesting({ doGeneralOverrides: true }); await di.runSetups(); + render = renderFor(di); // TODO: Remove global shared state KubeObjectMenuRegistry.resetInstance(); KubeObjectMenuRegistry.createInstance(); - render = renderFor(di); - - di.override(clusterInjectable, () => ({ - name: "Some name", - }) as Cluster); - + di.override(clusterNameInjectable, () => "Some name"); di.override(apiManagerInjectable, () => ({ getStore: api => void api, }) as ApiManager); - di.override(hideDetailsInjectable, () => () => {}); + di.override(uniqueIdInjectable, () => val => val); + di.override(hideDetailsInjectable, () => () => { }); - di.override(editResourceTabInjectable, () => () => ({ + di.override(newEditResourceTabInjectable, () => () => ({ id: "irrelevant", kind: TabKind.TERMINAL, pinned: false, @@ -80,14 +78,6 @@ describe("kube-object-menu", () => { }); }); - it("given no cluster, does not crash", () => { - di.override(clusterInjectable, () => null); - - expect(() => { - render(); - }).not.toThrow(); - }); - it("given no kube object, renders", () => { const { baseElement } = render( , @@ -100,7 +90,7 @@ describe("kube-object-menu", () => { let baseElement: Element; let removeActionMock: AsyncFnMock<() => void>; - beforeEach(async () => { + beforeEach(() => { const objectStub = KubeObject.create({ apiVersion: "some-api-version", kind: "some-kind", @@ -136,33 +126,29 @@ describe("kube-object-menu", () => { }); describe("when removing kube object", () => { - beforeEach(() => { - const menuItem = screen.getByTestId("menu-action-remove"); - - userEvent.click(menuItem); + beforeEach(async () => { + userEvent.click(await screen.findByTestId("menu-action-remove")); }); it("renders", () => { expect(baseElement).toMatchSnapshot(); }); - it("opens a confirmation dialog", () => { - screen.getByTestId("confirmation-dialog"); + it("opens a confirmation dialog", async () => { + await screen.findByTestId("confirmation-dialog"); }); describe("when remove is confirmed", () => { - beforeEach(() => { - const confirmRemovalButton = screen.getByTestId("confirm"); - - userEvent.click(confirmRemovalButton); + beforeEach(async () => { + userEvent.click(await screen.findByTestId("confirm")); }); it("calls for removal of the kube object", () => { expect(removeActionMock).toHaveBeenCalledWith(); }); - it("does not close the confirmation dialog yet", () => { - screen.getByTestId("confirmation-dialog"); + it("does not close the confirmation dialog yet", async () => { + await screen.findByTestId("confirmation-dialog"); }); it("when removal resolves, closes the confirmation dialog", async () => { @@ -177,7 +163,7 @@ describe("kube-object-menu", () => { describe("given kube object with namespace", () => { let baseElement: Element; - beforeEach(async () => { + beforeEach(() => { const objectStub = KubeObject.create({ apiVersion: "some-api-version", kind: "some-kind", @@ -196,16 +182,14 @@ describe("kube-object-menu", () => { {}} + removeAction={() => { }} />
    , )); }); - it("when removing kube object, renders confirmation dialog with namespace", () => { - const menuItem = screen.getByTestId("menu-action-remove"); - - userEvent.click(menuItem); + it("when removing kube object, renders confirmation dialog with namespace", async () => { + userEvent.click(await screen.findByTestId("menu-action-remove")); expect(baseElement).toMatchSnapshot(); }); @@ -214,7 +198,7 @@ describe("kube-object-menu", () => { describe("given kube object without namespace", () => { let baseElement: Element; - beforeEach(async () => { + beforeEach(() => { const objectStub = KubeObject.create({ apiVersion: "some-api-version", kind: "some-kind", @@ -233,40 +217,32 @@ describe("kube-object-menu", () => { {}} + removeAction={() => { }} />
    , )); }); - it("when removing kube object, renders confirmation dialog without namespace", () => { - const menuItem = screen.getByTestId("menu-action-remove"); - - userEvent.click(menuItem); + it("when removing kube object, renders confirmation dialog without namespace", async () => { + userEvent.click(await screen.findByTestId("menu-action-remove")); expect(baseElement).toMatchSnapshot(); }); }); }); -const addDynamicMenuItem = ({ - di, - apiVersions, - kind, +function addDynamicMenuItem({ + di, apiVersions, kind, }: { di: ConfigurableDependencyInjectionContainer; apiVersions: string[]; kind: string; -}) => { - const MenuItemComponent: React.FC = () =>
  • Some menu item
  • ; - - const dynamicMenuItemStub: KubeObjectMenuRegistration = { +}) { + di.inject(kubeObjectMenuRegistryInjectable).add({ apiVersions, kind, - components: { MenuItem: MenuItemComponent }, - }; - - const kubeObjectMenuRegistry = di.inject(kubeObjectMenuRegistryInjectable); - - kubeObjectMenuRegistry.add([dynamicMenuItemStub]); -}; + components: { + MenuItem: () =>
  • Some menu item
  • , + }, + }); +} diff --git a/src/renderer/components/kube-object-menu/kube-object-menu.tsx b/src/renderer/components/kube-object-menu/kube-object-menu.tsx index f4f3a81b58..5b1297e26d 100644 --- a/src/renderer/components/kube-object-menu/kube-object-menu.tsx +++ b/src/renderer/components/kube-object-menu/kube-object-menu.tsx @@ -11,10 +11,10 @@ import identity from "lodash/identity"; import type { ApiManager } from "../../../common/k8s-api/api-manager"; import { withInjectables } from "@ogre-tools/injectable-react"; import clusterNameInjectable from "./dependencies/cluster-name.injectable"; -import editResourceTabInjectable from "../dock/edit-resource-tab/edit-resource-tab.injectable"; import hideDetailsInjectable from "./dependencies/hide-details.injectable"; import kubeObjectMenuItemsInjectable from "./dependencies/kube-object-menu-items/kube-object-menu-items.injectable"; -import apiManagerInjectable from "./dependencies/api-manager.injectable"; +import apiManagerInjectable from "../../../common/k8s-api/api-manager.injectable"; +import newEditResourceTabInjectable from "../dock/edit-resource/create-tab.injectable"; export interface KubeObjectMenuProps extends MenuActionsProps { object: TKubeObject | null | undefined; @@ -27,17 +27,14 @@ interface Dependencies { kubeObjectMenuItems: React.ElementType[]; clusterName: string; hideDetails: () => void; - editResourceTab: (kubeObject: KubeObject) => void; + newEditResourceTab: (kubeObject: KubeObject) => void; } class NonInjectedKubeObjectMenu extends React.Component & Dependencies> { - get store() { - const { object } = this.props; + const { object, apiManager } = this.props; - if (!object) return null; - - return this.props.apiManager.getStore(object.selfLink); + return apiManager.getStore(object?.selfLink); } get isEditable() { @@ -49,9 +46,9 @@ class NonInjectedKubeObjectMenu extends React.Co } @boundMethod - async update() { + update() { this.props.hideDetails(); - this.props.editResourceTab(this.props.object); + this.props.newEditResourceTab(this.props.object); } @boundMethod @@ -59,8 +56,11 @@ class NonInjectedKubeObjectMenu extends React.Co this.props.hideDetails(); const { object, removeAction } = this.props; - if (removeAction) await removeAction(); - else await this.store.remove(object); + if (removeAction) { + await removeAction(); + } else { + await this.store.remove(object); + } } @boundMethod @@ -83,11 +83,16 @@ class NonInjectedKubeObjectMenu extends React.Co } getMenuItems(): React.ReactChild[] { - const { object, toolbar } = this.props; + const { object, toolbar, kubeObjectMenuItems } = this.props; - return this.props.kubeObjectMenuItems.map((MenuItem, index) => ( - - )); + return kubeObjectMenuItems + .map((MenuItem, index) => ( + + )); } render() { @@ -108,25 +113,19 @@ class NonInjectedKubeObjectMenu extends React.Co } } -export function KubeObjectMenu( - props: KubeObjectMenuProps, -) { - const InjectedKubeObjectMenu = withInjectables>( - NonInjectedKubeObjectMenu, - { - getProps: (di, props) => ({ - clusterName: di.inject(clusterNameInjectable), - apiManager: di.inject(apiManagerInjectable), - editResourceTab: di.inject(editResourceTabInjectable), - hideDetails: di.inject(hideDetailsInjectable), - - kubeObjectMenuItems: di.inject(kubeObjectMenuItemsInjectable, { - kubeObject: props.object, - }), - ...props, - }), - }, - ); +const InjectedKubeObjectMenu = withInjectables>(NonInjectedKubeObjectMenu, { + getProps: (di, props) => ({ + clusterName: di.inject(clusterNameInjectable), + apiManager: di.inject(apiManagerInjectable), + newEditResourceTab: di.inject(newEditResourceTabInjectable), + hideDetails: di.inject(hideDetailsInjectable), + kubeObjectMenuItems: di.inject(kubeObjectMenuItemsInjectable, { + kubeObject: props.object, + }), + ...props, + }), +}); +export function KubeObjectMenu(props: KubeObjectMenuProps) { return ; } diff --git a/src/renderer/components/kube-object-meta/kube-object-meta.tsx b/src/renderer/components/kube-object-meta/kube-object-meta.tsx index 36064ee933..b92c637a79 100644 --- a/src/renderer/components/kube-object-meta/kube-object-meta.tsx +++ b/src/renderer/components/kube-object-meta/kube-object-meta.tsx @@ -6,101 +6,108 @@ import React from "react"; import { KubeMetaField, KubeObject } from "../../../common/k8s-api/kube-object"; import { DrawerItem, DrawerItemLabels } from "../drawer"; -import { apiManager } from "../../../common/k8s-api/api-manager"; import { Link } from "react-router-dom"; import { KubeObjectStatusIcon } from "../kube-object-status-icon"; import { LocaleDate } from "../locale-date"; import { getDetailsUrl } from "../kube-detail-params"; import logger from "../../../common/logger"; +import { withInjectables } from "@ogre-tools/injectable-react"; +import { observer } from "mobx-react"; +import type { IKubeObjectRef } from "../../../common/k8s-api/kube-api-parse"; +import lookupApiLinkInjectable from "../../../common/k8s-api/lookup-api-link.injectable"; export interface KubeObjectMetaProps { object: KubeObject; hideFields?: KubeMetaField[]; } -export class KubeObjectMeta extends React.Component { - static defaultHiddenFields: KubeMetaField[] = [ - "uid", "resourceVersion", "selfLink", - ]; +interface Dependencies { + lookupApiLink: (ref: IKubeObjectRef, parentObject?: KubeObject) => string; +} - isHidden(field: KubeMetaField): boolean { - const { hideFields = KubeObjectMeta.defaultHiddenFields } = this.props; +const defaultHiddenFields: KubeMetaField[] = [ + "uid", + "resourceVersion", + "selfLink", +]; - return hideFields.includes(field); +const NonInjectedKubeObjectMeta = observer(({ lookupApiLink, object, hideFields = defaultHiddenFields }: Dependencies & KubeObjectMetaProps) => { + const hiddenFields = new Set(hideFields); + + if (!object) { + return null; } - render() { - const { object } = this.props; + if (!(object instanceof KubeObject)) { + logger.error("[KubeObjectMeta]: passed object that is not an instanceof KubeObject", object); - if (!object) { - return null; - } + return null; + } - if (!(object instanceof KubeObject)) { - logger.error("[KubeObjectMeta]: passed object that is not an instanceof KubeObject", object); + const { + getNs, getLabels, getResourceVersion, selfLink, getAnnotations, + getFinalizers, getId, getAge, getName, metadata: { creationTimestamp }, + } = object; + const ownerRefs = object.getOwnerRefs(); - return null; - } - - const { - getNs, getLabels, getResourceVersion, selfLink, getAnnotations, - getFinalizers, getId, getAge, getName, metadata: { creationTimestamp }, - } = object; - const ownerRefs = object.getOwnerRefs(); - - return ( - <> - - - - - - -
    - ), - }} - /> - ); - } +interface Dependencies { + getStatusItemsForKubeObject: (src: KubeObject) => KubeObjectStatus[]; } + +const NonInjectedKubeObjectStatusIcon = observer(({ getStatusItemsForKubeObject, object }: Dependencies & KubeObjectStatusIconProps) => { + const statuses = getStatusItemsForKubeObject(object); + + if (statuses.length === 0) { + return null; + } + + const { maxLevel, criticals, warnings, infos } = splitByLevel(statuses); + + return ( + + {renderStatuses(criticals, KubeObjectStatusLevel.CRITICAL)} + {renderStatuses(warnings, KubeObjectStatusLevel.WARNING)} + {renderStatuses(infos, KubeObjectStatusLevel.INFO)} + + ), + }} + /> + ); +}); + +export const KubeObjectStatusIcon = withInjectables(NonInjectedKubeObjectStatusIcon, { + getProps: (di, props) => ({ + getStatusItemsForKubeObject: di.inject(getStatusItemsForKubeObjectInjectable), + ...props, + }), +}); + diff --git a/src/renderer/components/kube-object-status-icon/kube-object-status.ts b/src/renderer/components/kube-object-status-icon/kube-object-status.ts new file mode 100644 index 0000000000..07fe45df9a --- /dev/null +++ b/src/renderer/components/kube-object-status-icon/kube-object-status.ts @@ -0,0 +1,25 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { KubeObject } from "../../../common/k8s-api/kube-object"; +import type { KubeObjectStatus } from "../../../extensions/renderer-api/kube-object-status"; + +/** + * Type for extension API + */ +export interface KubeObjectStatusRegistration { + kind: string; + apiVersions: string[]; + resolve: (object: KubeObject) => KubeObjectStatus; +} + +/** + * Internal type + */ +export interface RegisteredKubeObjectStatus { + kind: string; + apiVersions: Set; + resolve: (object: KubeObject) => KubeObjectStatus; +} diff --git a/src/renderer/components/kube-object-status-icon/kube-object-statuses.injectable.ts b/src/renderer/components/kube-object-status-icon/kube-object-statuses.injectable.ts new file mode 100644 index 0000000000..5bda714723 --- /dev/null +++ b/src/renderer/components/kube-object-status-icon/kube-object-statuses.injectable.ts @@ -0,0 +1,25 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { computed } from "mobx"; +import rendererExtensionsInjectable from "../../../extensions/renderer-extensions.injectable"; +import type { KubeObjectStatusRegistration, RegisteredKubeObjectStatus } from "./kube-object-status"; + +const kubeObjectStatusesInjectable = getInjectable({ + instantiate: (di) => ( + computed(() => ( + di.inject(rendererExtensionsInjectable) + .get() + .flatMap(ext => ext.kubeObjectStatusTexts) + .map(({ apiVersions, ...rest }: KubeObjectStatusRegistration): RegisteredKubeObjectStatus => ({ + apiVersions: new Set(apiVersions), + ...rest, + })) + )) + ), + lifecycle: lifecycleEnum.singleton, +}); + +export default kubeObjectStatusesInjectable; diff --git a/src/renderer/components/kube-object-status-icon/status-items-for-object.injectable.ts b/src/renderer/components/kube-object-status-icon/status-items-for-object.injectable.ts new file mode 100644 index 0000000000..089366e999 --- /dev/null +++ b/src/renderer/components/kube-object-status-icon/status-items-for-object.injectable.ts @@ -0,0 +1,41 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import type { IComputedValue } from "mobx"; +import type { KubeObject } from "../../../common/k8s-api/kube-object"; +import type { KubeObjectStatus } from "../../../extensions/renderer-api/kube-object-status"; +import { bind } from "../../utils"; +import type { RegisteredKubeObjectStatus } from "./kube-object-status"; +import kubeObjectStatusesInjectable from "./kube-object-statuses.injectable"; + +interface Dependencies { + statuses: IComputedValue; +} + +function getStatusItemsForKubeObject({ statuses }: Dependencies, src: KubeObject): KubeObjectStatus[] { + const res: KubeObjectStatus[] = []; + + for (const registration of statuses.get()) { + if (registration.kind === src.kind && registration.apiVersions.has(src.apiVersion)) { + const resolved = registration.resolve(src); + + if (resolved) { + res.push(resolved); + } + } + } + + return res; +} + +const getStatusItemsForKubeObjectInjectable = getInjectable({ + instantiate: (di) => bind(getStatusItemsForKubeObject, null, { + statuses: di.inject(kubeObjectStatusesInjectable), + }), + lifecycle: lifecycleEnum.singleton, +}); + +export default getStatusItemsForKubeObjectInjectable; diff --git a/src/renderer/components/kubeconfig-dialog/kubeconfig-dialog.tsx b/src/renderer/components/kubeconfig-dialog/kubeconfig-dialog.tsx index 62793d9a5b..3d9808f763 100644 --- a/src/renderer/components/kubeconfig-dialog/kubeconfig-dialog.tsx +++ b/src/renderer/components/kubeconfig-dialog/kubeconfig-dialog.tsx @@ -82,37 +82,35 @@ export class KubeConfigDialog extends React.Component { }; render() { - const { ...dialogProps } = this.props; - const yamlConfig = this.config; - const header =
    {this.data?.title || "Kubeconfig File"}
    ; - const buttons = ( -
    - - - -
    - ); - return ( - - + {this.data?.title || "Kubeconfig File"}}> + + + + + + )} + prev={this.close} + > diff --git a/src/renderer/components/layout/__tests__/sidebar-cluster.test.tsx b/src/renderer/components/layout/__tests__/sidebar-cluster.test.tsx index 242f231774..dabd67b3a0 100644 --- a/src/renderer/components/layout/__tests__/sidebar-cluster.test.tsx +++ b/src/renderer/components/layout/__tests__/sidebar-cluster.test.tsx @@ -5,17 +5,16 @@ import React from "react"; import "@testing-library/jest-dom/extend-expect"; -import { render, fireEvent } from "@testing-library/react"; +import { fireEvent } from "@testing-library/react"; import { SidebarCluster } from "../sidebar-cluster"; import { KubernetesCluster } from "../../../../common/catalog-entities"; - -jest.mock("../../../../common/hotbar-store", () => ({ - HotbarStore: { - getInstance: () => ({ - isAddedToActive: jest.fn(), - }), - }, -})); +import type { ConfigurableDependencyInjectionContainer } from "@ogre-tools/injectable"; +import { noop } from "lodash"; +import addToActiveHotbarInjectable from "../../../../common/hotbar-store/add-to-active-hotbar.injectable"; +import removeByIdFromActiveHotbarInjectable from "../../../../common/hotbar-store/remove-from-active-hotbar.injectable"; +import { getDiForUnitTesting } from "../../../getDiForUnitTesting"; +import { type DiRender, renderFor } from "../../test-utils/renderFor"; +import isItemInActiveHotbarInjectable from "../../../../common/hotbar-store/is-added-to-active-hotbar.injectable"; const clusterEntity = new KubernetesCluster({ metadata: { @@ -34,18 +33,30 @@ const clusterEntity = new KubernetesCluster({ }); describe("", () => { + let render: DiRender; + let di: ConfigurableDependencyInjectionContainer; + + beforeAll(() => { + di = getDiForUnitTesting(); + render = renderFor(di); + + di.override(addToActiveHotbarInjectable, () => noop); + di.override(removeByIdFromActiveHotbarInjectable, () => noop); + di.override(isItemInActiveHotbarInjectable, () => () => false); + }); + it("renders w/o errors", () => { const { container } = render(); expect(container).toBeInstanceOf(HTMLElement); }); - it("renders cluster avatar and name", () => { - const { getByText, getAllByText } = render(); + it("renders cluster avatar and name", async () => { + const { findByText, findAllByText } = render(); - expect(getByText("tc")).toBeInTheDocument(); + expect(await findByText("tc")).toBeInTheDocument(); - const v = getAllByText("test-cluster"); + const v = await findAllByText("test-cluster"); expect(v.length).toBeGreaterThan(0); @@ -54,12 +65,11 @@ describe("", () => { } }); - it("renders cluster menu", () => { - const { getByTestId, getByText } = render(); - const link = getByTestId("sidebar-cluster-dropdown"); + it("renders cluster menu", async () => { + const { findByTestId, findByText } = render(); - fireEvent.click(link); - expect(getByText("Add to Hotbar")).toBeInTheDocument(); + fireEvent.click(await findByTestId("sidebar-cluster-dropdown")); + expect(await findByText("Add to Hotbar")).toBeInTheDocument(); }); }); diff --git a/src/renderer/components/layout/main-layout.tsx b/src/renderer/components/layout/main-layout.tsx index 27b8a30687..9ae579bab6 100755 --- a/src/renderer/components/layout/main-layout.tsx +++ b/src/renderer/components/layout/main-layout.tsx @@ -7,77 +7,64 @@ import styles from "./main-layout.module.scss"; import React from "react"; import { observer } from "mobx-react"; -import { cssNames, StorageHelper } from "../../utils"; +import { cssNames, StorageLayer } from "../../utils"; import { ErrorBoundary } from "../error-boundary"; import { ResizeDirection, ResizeGrowthDirection, ResizeSide, ResizingAnchor } from "../resizing-anchor"; +import sidebarStorageInjectable, { defaultSidebarWidth, SidebarStorageState } from "./sidebar-storage.injectable"; import { withInjectables } from "@ogre-tools/injectable-react"; -import sidebarStorageInjectable, { - defaultSidebarWidth, - SidebarStorageState, -} from "./sidebar-storage/sidebar-storage.injectable"; -interface Props { +export interface MainLayoutProps { sidebar: React.ReactNode; className?: string; footer?: React.ReactNode; + children?: React.ReactChild | React.ReactChild[]; } +interface Dependencies { + sidebarStorage: StorageLayer; +} + +const NonInjectedMainLayout = observer(({ sidebarStorage, sidebar, className, footer, children }: Dependencies & MainLayoutProps) => { + const onSidebarResize = (width: number) => { + sidebarStorage.merge({ width }); + }; + + const { width: sidebarWidth } = sidebarStorage.get(); + const style = { "--sidebar-width": `${sidebarWidth}px` } as React.CSSProperties; + + return ( +
    +
    + {sidebar} + sidebarWidth} + onDrag={onSidebarResize} + onDoubleClick={() => onSidebarResize(defaultSidebarWidth)} + minExtent={120} + maxExtent={400} + /> +
    + +
    + {children} +
    + +
    {footer}
    +
    + ); +}); + /** * Main layout is commonly used as a wrapper for "global pages" * * @link https://api-docs.k8slens.dev/master/extensions/capabilities/common-capabilities/#global-pages */ - -interface Dependencies { - sidebarStorage: StorageHelper -} - -@observer -class NonInjectedMainLayout extends React.Component { - onSidebarResize = (width: number) => { - this.props.sidebarStorage.merge({ width }); - }; - - render() { - const { className, footer, children, sidebar } = this.props; - const { width: sidebarWidth } = this.props.sidebarStorage.get(); - const style = { "--sidebar-width": `${sidebarWidth}px` } as React.CSSProperties; - - return ( -
    -
    - {sidebar} - sidebarWidth} - onDrag={this.onSidebarResize} - onDoubleClick={() => this.onSidebarResize(defaultSidebarWidth)} - minExtent={120} - maxExtent={400} - /> -
    - -
    - {children} -
    - -
    {footer}
    -
    - ); - } -} - -export const MainLayout = withInjectables( - NonInjectedMainLayout, - - { - getProps: (di, props) => ({ - sidebarStorage: di.inject(sidebarStorageInjectable), - - ...props, - }), - }, -); - +export const MainLayout = withInjectables(NonInjectedMainLayout, { + getProps: (di, props) => ({ + sidebarStorage: di.inject(sidebarStorageInjectable), + ...props, + }), +}); diff --git a/src/renderer/components/layout/setting-layout.tsx b/src/renderer/components/layout/setting-layout.tsx index ade8625762..2b4f07d46e 100644 --- a/src/renderer/components/layout/setting-layout.tsx +++ b/src/renderer/components/layout/setting-layout.tsx @@ -40,7 +40,7 @@ const defaultProps: Partial = { export class SettingLayout extends React.Component { static defaultProps = defaultProps as object; - async componentDidMount() { + componentDidMount() { const { hash } = window.location; if (hash) { diff --git a/src/renderer/components/layout/sidebar-cluster.tsx b/src/renderer/components/layout/sidebar-cluster.tsx index 0126059468..7ae23272cf 100644 --- a/src/renderer/components/layout/sidebar-cluster.tsx +++ b/src/renderer/components/layout/sidebar-cluster.tsx @@ -6,79 +6,82 @@ import styles from "./sidebar-cluster.module.scss"; import { observable } from "mobx"; import React, { useState } from "react"; -import { HotbarStore } from "../../../common/hotbar-store"; import { broadcastMessage } from "../../../common/ipc"; -import type { CatalogEntity, CatalogEntityContextMenu, CatalogEntityContextMenuContext } from "../../api/catalog-entity"; import { IpcRendererNavigationEvents } from "../../navigation/events"; import { Avatar } from "../avatar"; import { Icon } from "../icon"; import { navigate } from "../../navigation"; -import { Menu, MenuItem } from "../menu"; -import { ConfirmDialog } from "../confirm-dialog"; +import { Menu } from "../menu"; import { Tooltip } from "../tooltip"; +import { withInjectables } from "@ogre-tools/injectable-react"; +import { observer } from "mobx-react"; +import type { CatalogEntity, CatalogEntityContextMenu, CatalogEntityContextMenuContext } from "../../../common/catalog"; +import renderEntityContextMenuItemInjectable, { RenderEntityContextMenuItem } from "../../catalog/render-context-menu-item.injectable"; +import onEntityContextMenuOpenInjectable from "../../catalog/on-entity-context-menu-open.injectable"; +import addToActiveHotbarInjectable from "../../../common/hotbar-store/add-to-active-hotbar.injectable"; +import removeByIdFromActiveHotbarInjectable from "../../../common/hotbar-store/remove-from-active-hotbar.injectable"; +import isItemInActiveHotbarInjectable from "../../../common/hotbar-store/is-added-to-active-hotbar.injectable"; -const contextMenu: CatalogEntityContextMenuContext = observable({ - menuItems: [], - navigate: (url: string, forceMainFrame = true) => { - if (forceMainFrame) { - broadcastMessage(IpcRendererNavigationEvents.NAVIGATE_IN_APP, url); - } else { - navigate(url); - } - }, -}); - -function onMenuItemClick(menuItem: CatalogEntityContextMenu) { - if (menuItem.confirm) { - ConfirmDialog.open({ - okButtonProps: { - primary: false, - accent: true, - }, - ok: () => { - menuItem.onClick(); - }, - message: menuItem.confirm.message, - }); - } else { - menuItem.onClick(); - } +export interface SidebarClusterProps { + clusterEntity: CatalogEntity | null | undefined; } -function renderLoadingSidebarCluster() { - return ( -
    - -
    -
    - ); +interface Dependencies { + renderEntityContextMenuItem: RenderEntityContextMenuItem; + onEntityContextMenuOpen: (entity: CatalogEntity, context: CatalogEntityContextMenuContext) => void; + isAddedToActiveHotbar: (entity: CatalogEntity) => boolean; + removeFromActiveHotbar: (entityId: string) => void; + addToActiveHotbar: (entity: CatalogEntity) => void; } -export function SidebarCluster({ clusterEntity }: { clusterEntity: CatalogEntity }) { +const NonInjectedSidebarCluster = observer(({ + clusterEntity, + renderEntityContextMenuItem, + onEntityContextMenuOpen, + isAddedToActiveHotbar, + removeFromActiveHotbar, + addToActiveHotbar, +}: Dependencies & SidebarClusterProps) => { const [opened, setOpened] = useState(false); + const [contextMenuItems] = useState(observable.array()); if (!clusterEntity) { - return renderLoadingSidebarCluster(); + return ( +
    + +
    +
    + ); } const onMenuOpen = () => { - const hotbarStore = HotbarStore.getInstance(); - const isAddedToActive = HotbarStore.getInstance().isAddedToActive(clusterEntity); - const title = isAddedToActive - ? "Remove from Hotbar" - : "Add to Hotbar"; - const onClick = isAddedToActive - ? () => hotbarStore.removeFromHotbar(metadata.uid) - : () => hotbarStore.addToHotbar(clusterEntity); - - contextMenu.menuItems = [{ title, onClick }]; - clusterEntity.onContextMenuOpen(contextMenu); + contextMenuItems.replace([ + isAddedToActiveHotbar(clusterEntity) + ? { + title: "Remove from Hotbar", + onClick: () => removeFromActiveHotbar(metadata.uid), + } + : { + title: "Add to Hotbar", + onClick: () => addToActiveHotbar(clusterEntity), + }, + ]); + onEntityContextMenuOpen(clusterEntity, { + menuItems: contextMenuItems, + navigate: (url: string, forceMainFrame = true) => { + if (forceMainFrame) { + broadcastMessage(IpcRendererNavigationEvents.NAVIGATE_IN_APP, url); + } else { + navigate(url); + } + }, + }); toggle(); }; @@ -129,14 +132,20 @@ export function SidebarCluster({ clusterEntity }: { clusterEntity: CatalogEntity close={toggle} className={styles.menu} > - { - contextMenu.menuItems.map((menuItem) => ( - onMenuItemClick(menuItem)}> - {menuItem.title} - - )) - } + {contextMenuItems.map(renderEntityContextMenuItem("title"))}
    ); -} +}); + +export const SidebarCluster = withInjectables(NonInjectedSidebarCluster, { + getProps: (di, props) => ({ + renderEntityContextMenuItem: di.inject(renderEntityContextMenuItemInjectable), + onEntityContextMenuOpen: di.inject(onEntityContextMenuOpenInjectable), + addToActiveHotbar: di.inject(addToActiveHotbarInjectable), + removeFromActiveHotbar: di.inject(removeByIdFromActiveHotbarInjectable), + isAddedToActiveHotbar: di.inject(isItemInActiveHotbarInjectable), + ...props, + }), +}); + diff --git a/src/renderer/components/layout/sidebar-item.tsx b/src/renderer/components/layout/sidebar-item.tsx index 9a09e6ea19..d0fb6ccf9d 100644 --- a/src/renderer/components/layout/sidebar-item.tsx +++ b/src/renderer/components/layout/sidebar-item.tsx @@ -6,16 +6,15 @@ import "./sidebar-item.scss"; import React from "react"; -import { computed, makeObservable } from "mobx"; -import { cssNames, prevDefault, StorageHelper } from "../../utils"; +import { cssNames, prevDefault, StorageLayer } from "../../utils"; import { observer } from "mobx-react"; import { NavLink } from "react-router-dom"; import { Icon } from "../icon"; import { isActiveRoute } from "../../navigation"; import { withInjectables } from "@ogre-tools/injectable-react"; -import sidebarStorageInjectable, { SidebarStorageState } from "./sidebar-storage/sidebar-storage.injectable"; +import sidebarStorageInjectable, { SidebarStorageState } from "./sidebar-storage.injectable"; -interface SidebarItemProps { +export interface SidebarItemProps { /** * Unique id, used in storage and integration tests */ @@ -32,95 +31,76 @@ interface SidebarItemProps { * this item should be shown as active */ isActive?: boolean; + children?: React.ReactNode | React.ReactChild | React.ReactChild[]; } interface Dependencies { - sidebarStorage: StorageHelper + sidebarStorage: StorageLayer; } -@observer -class NonInjectedSidebarItem extends React.Component { - static displayName = "SidebarItem"; +const NonInjectedSidebarItem = observer(({ + id, + url, + className, + text, + icon, + isHidden, + sidebarStorage, + isActive: forcedActive, + children, +}: Dependencies & SidebarItemProps) => { + const expanded = Boolean(sidebarStorage.get().expanded[id]); + const isActive = forcedActive || isActiveRoute({ + path: url, + exact: true, + }); + const isExpandable = Boolean(children); - constructor(props: SidebarItemProps & Dependencies) { - super(props); - makeObservable(this); + if (isHidden) { + return null; } - get id(): string { - return this.props.id; - } - - @computed get expanded(): boolean { - return Boolean(this.props.sidebarStorage.get().expanded[this.id]); - } - - @computed get isActive(): boolean { - return this.props.isActive ?? isActiveRoute({ - path: this.props.url, - exact: true, - }); - } - - @computed get isExpandable(): boolean { - return Boolean(this.props.children); - } - - toggleExpand = () => { - this.props.sidebarStorage.merge(draft => { - draft.expanded[this.id] = !draft.expanded[this.id]; + const toggleExpand = () => { + sidebarStorage.merge(draft => { + draft.expanded[id] = !draft.expanded[id]; }); }; - - renderSubMenu() { - const { isExpandable, expanded, isActive } = this; - + const renderSubMenu = () => { if (!isExpandable || !expanded) { return null; } return (
      - {this.props.children} + {children}
    ); - } + }; - render() { - const { isHidden, icon, text, url, className } = this.props; + return ( +
    + isActive} + className={cssNames("nav-item flex gaps align-center", { expandable: isExpandable })} + onClick={isExpandable ? prevDefault(toggleExpand) : undefined}> + {icon} + {text} + {isExpandable && } + + {renderSubMenu()} +
    + ); +}); - if (isHidden) return null; +export const SidebarItem = withInjectables(NonInjectedSidebarItem, { + getProps: (di, props) => ({ + sidebarStorage: di.inject(sidebarStorageInjectable), + ...props, + }), +}); - const { isActive, id, expanded, isExpandable, toggleExpand } = this; - const classNames = cssNames("SidebarItem", className); - - return ( -
    - isActive} - className={cssNames("nav-item flex gaps align-center", { expandable: isExpandable })} - onClick={isExpandable ? prevDefault(toggleExpand) : undefined}> - {icon} - {text} - {isExpandable && } - - {this.renderSubMenu()} -
    - ); - } -} - -export const SidebarItem = withInjectables( - NonInjectedSidebarItem, - - { - getProps: (di, props) => ({ - sidebarStorage: di.inject(sidebarStorageInjectable), - ...props, - }), - }, -); +SidebarItem.displayName = "SidebarItem"; diff --git a/src/renderer/components/layout/sidebar-storage.injectable.ts b/src/renderer/components/layout/sidebar-storage.injectable.ts new file mode 100644 index 0000000000..3e01f1b63b --- /dev/null +++ b/src/renderer/components/layout/sidebar-storage.injectable.ts @@ -0,0 +1,29 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import type { StorageLayer } from "../../utils"; +import createStorageInjectable from "../../utils/create-storage/create-storage.injectable"; + +export interface SidebarStorageState { + width: number; + expanded: Record; +} + +export const defaultSidebarWidth = 200; + +let storage: StorageLayer; + +const sidebarStorageInjectable = getInjectable({ + setup: async (di) => { + storage = await di.inject(createStorageInjectable)("sidebar", { + width: defaultSidebarWidth, + expanded: {}, + }); + }, + instantiate: () => storage, + lifecycle: lifecycleEnum.singleton, +}); + +export default sidebarStorageInjectable; diff --git a/src/renderer/components/layout/sidebar.tsx b/src/renderer/components/layout/sidebar.tsx index 3600eb482f..c9263ba1c1 100644 --- a/src/renderer/components/layout/sidebar.tsx +++ b/src/renderer/components/layout/sidebar.tsx @@ -7,107 +7,48 @@ import styles from "./sidebar.module.scss"; import type { TabLayoutRoute } from "./tab-layout"; import React from "react"; -import { disposeOnUnmount, observer } from "mobx-react"; -import { cssNames, Disposer } from "../../utils"; +import { observer } from "mobx-react"; +import { cssNames } from "../../utils"; import { Icon } from "../icon"; -import { Workloads } from "../+workloads"; -import { UserManagement } from "../+user-management"; -import { Storage } from "../+storage"; -import { Network } from "../+network"; -import { crdStore } from "../+custom-resources/crd.store"; -import { CustomResources } from "../+custom-resources/custom-resources"; import { isActiveRoute } from "../../navigation"; -import { isAllowedResource } from "../../../common/utils/allowed-resource"; -import { Spinner } from "../spinner"; import { ClusterPageMenuRegistration, ClusterPageMenuRegistry, ClusterPageRegistry, getExtensionPageUrl } from "../../../extensions/registries"; import { SidebarItem } from "./sidebar-item"; -import { Apps } from "../+apps"; import * as routes from "../../../common/routes"; -import { Config } from "../+config"; -import { catalogEntityRegistry } from "../../api/catalog-entity-registry"; import { SidebarCluster } from "./sidebar-cluster"; -import type { KubeObjectStore } from "../../../common/k8s-api/kube-object.store"; -import type { KubeObject } from "../../../common/k8s-api/kube-object"; import { withInjectables } from "@ogre-tools/injectable-react"; -import kubeWatchApiInjectable - from "../../kube-watch-api/kube-watch-api.injectable"; +import type { KubernetesCluster } from "../../../common/catalog-entities"; +import activeEntityInjectable from "../../catalog/active-entity.injectable"; +import type { IComputedValue } from "mobx"; +import type { KubeResource } from "../../../common/rbac"; +import { UserManagementSidebarItem } from "../+user-management/sidebar-item"; +import { ConfigSidebarItem } from "../+config/sidebar-item"; +import { NetworkSidebarItem } from "../+network/sidebar-item"; +import { TabRouteTree } from "./tab-route-tree"; +import isAllowedResourceInjectable from "../../utils/allowed-resource.injectable"; +import { StorageSidebarItem } from "../+storage/sidebar-item"; +import { WorkloadsSidebarItem } from "../+workloads/sidebar-item"; +import { HelmAppsSidebarItem } from "../+helm-apps/sidebar-item"; +import { CustomResourcesSidebarItem } from "../+custom-resource/sidebar-item"; -interface Props { +export interface SidebarProps { className?: string; } interface Dependencies { - subscribeStores: (stores: KubeObjectStore[]) => Disposer + clusterEntity: IComputedValue; + clusterPageMenuRegistry: ClusterPageMenuRegistry; + clusterPageRegistry: ClusterPageRegistry; + isAllowedResource: (resource: KubeResource | KubeResource[]) => boolean; } -@observer -class NonInjectedSidebar extends React.Component { - static displayName = "Sidebar"; - - componentDidMount() { - disposeOnUnmount(this, [ - this.props.subscribeStores([ - crdStore, - ]), - ]); - } - - renderCustomResources() { - if (crdStore.isLoading) { - return ( -
    - -
    - ); - } - - return Object.entries(crdStore.groups).map(([group, crds]) => { - const id = `crd-group:${group}`; - const crdGroupsPageUrl = routes.crdURL({ query: { groups: group }}); - - return ( - - {crds.map((crd) => ( - - ))} - - ); - }); - } - - renderTreeFromTabRoutes(tabRoutes: TabLayoutRoute[] = []): React.ReactNode { - if (!tabRoutes.length) { - return null; - } - - return tabRoutes.map(({ title, routePath, url = routePath, exact = true }) => { - const subMenuItemId = `tab-route-item-${url}`; - - return ( - - ); - }); - } - - getTabLayoutRoutes(menu: ClusterPageMenuRegistration): TabLayoutRoute[] { +const NonInjectedSidebar = observer(({ isAllowedResource, clusterEntity, className, clusterPageMenuRegistry, clusterPageRegistry }: Dependencies & SidebarProps) => { + const getTabLayoutRoutes = (menu: ClusterPageMenuRegistration): TabLayoutRoute[] => { if (!menu.id) { return []; } const routes: TabLayoutRoute[] = []; - const subMenus = ClusterPageMenuRegistry.getInstance().getSubItems(menu); - const clusterPageRegistry = ClusterPageRegistry.getInstance(); + const subMenus = clusterPageMenuRegistry.getSubItems(menu); for (const subMenu of subMenus) { const page = clusterPageRegistry.getByPageTarget(subMenu.target); @@ -138,12 +79,12 @@ class NonInjectedSidebar extends React.Component { } return routes; - } + }; - renderRegisteredMenus() { - return ClusterPageMenuRegistry.getInstance().getRootItems().map((menuItem, index) => { - const registeredPage = ClusterPageRegistry.getInstance().getByPageTarget(menuItem.target); - const tabRoutes = this.getTabLayoutRoutes(menuItem); + const renderRegisteredMenus = () => { + return clusterPageMenuRegistry.getRootItems().map((menuItem, index) => { + const registeredPage = clusterPageRegistry.getByPageTarget(menuItem.target); + const tabRoutes = getTabLayoutRoutes(menuItem); const id = `registered-item-${index}`; let pageUrl: string; let isActive = false; @@ -169,139 +110,67 @@ class NonInjectedSidebar extends React.Component { text={menuItem.title} icon={} > - {this.renderTreeFromTabRoutes(tabRoutes)} + ); }); - } + }; - get clusterEntity() { - return catalogEntityRegistry.activeEntity; - } - - render() { - const { className } = this.props; - - return ( -
    - -
    - } - /> - } - /> - } - > - {this.renderTreeFromTabRoutes(Workloads.tabRoutes)} - - } - > - {this.renderTreeFromTabRoutes(Config.tabRoutes)} - - } - > - {this.renderTreeFromTabRoutes(Network.tabRoutes)} - - } - > - {this.renderTreeFromTabRoutes(Storage.tabRoutes)} - - } - /> - } - /> - } - > - {this.renderTreeFromTabRoutes(Apps.tabRoutes)} - - } - > - {this.renderTreeFromTabRoutes(UserManagement.tabRoutes)} - - } - > - {this.renderTreeFromTabRoutes(CustomResources.tabRoutes)} - {this.renderCustomResources()} - - {this.renderRegisteredMenus()} -
    + return ( +
    + +
    + } + /> + } + /> + + + + + } + /> + } + /> + + + + {renderRegisteredMenus()}
    - ); - } -} +
    + ); +}); -export const Sidebar = withInjectables( - NonInjectedSidebar, - - { - getProps: (di, props) => ({ - subscribeStores: di.inject(kubeWatchApiInjectable).subscribeStores, - ...props, - }), - }, -); +export const Sidebar = withInjectables(NonInjectedSidebar, { + getProps: (di, props) => ({ + clusterEntity: di.inject(activeEntityInjectable) as IComputedValue, + clusterPageMenuRegistry: ClusterPageMenuRegistry.getInstance(), + clusterPageRegistry: ClusterPageRegistry.getInstance(), + isAllowedResource: di.inject(isAllowedResourceInjectable), + ...props, + }), +}); diff --git a/src/renderer/components/layout/tab-route-tree.tsx b/src/renderer/components/layout/tab-route-tree.tsx new file mode 100644 index 0000000000..4075637de2 --- /dev/null +++ b/src/renderer/components/layout/tab-route-tree.tsx @@ -0,0 +1,31 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import React from "react"; +import { isActiveRoute } from "../../navigation"; +import { SidebarItem } from "./sidebar-item"; +import type { TabLayoutRoute } from "./tab-layout"; + +export const TabRouteTree = ({ tabRoutes }: { tabRoutes: TabLayoutRoute[] }) => { + return ( + <> + { + tabRoutes.map(({ title, routePath, url = routePath, exact = true }) => { + const subMenuItemId = `tab-route-item-${url}`; + + return ( + + ); + }) + } + + ); +}; diff --git a/src/renderer/components/layout/top-bar/top-bar-win-linux.test.tsx b/src/renderer/components/layout/top-bar/top-bar-win-linux.test.tsx index 3cc3a75b3f..d08e499c81 100644 --- a/src/renderer/components/layout/top-bar/top-bar-win-linux.test.tsx +++ b/src/renderer/components/layout/top-bar/top-bar-win-linux.test.tsx @@ -7,14 +7,12 @@ import React from "react"; import { fireEvent } from "@testing-library/react"; import "@testing-library/jest-dom/extend-expect"; import { TopBar } from "./top-bar"; -import { IpcMainWindowEvents } from "../../../../main/window-manager"; +import { IpcMainWindowEvents } from "../../../../main/windows/manager"; import { broadcastMessage } from "../../../../common/ipc"; import * as vars from "../../../../common/vars"; import { getDiForUnitTesting } from "../../../getDiForUnitTesting"; import { DiRender, renderFor } from "../../test-utils/renderFor"; -import directoryForUserDataInjectable - from "../../../../common/app-paths/directory-for-user-data/directory-for-user-data.injectable"; -import mockFs from "mock-fs"; +import type { ConfigurableDependencyInjectionContainer } from "@ogre-tools/injectable"; const mockConfig = vars as { isWindows: boolean; isLinux: boolean }; @@ -52,23 +50,14 @@ jest.mock("@electron/remote", () => { describe(" in Windows and Linux", () => { let render: DiRender; + let di: ConfigurableDependencyInjectionContainer; beforeEach(async () => { - const di = getDiForUnitTesting({ doGeneralOverrides: true }); - - mockFs(); - - di.override(directoryForUserDataInjectable, () => "some-directory-for-user-data"); - + di = getDiForUnitTesting({ doGeneralOverrides: true }); await di.runSetups(); - render = renderFor(di); }); - afterEach(() => { - mockFs.restore(); - }); - it("shows window controls on Windows", () => { mockConfig.isWindows = true; mockConfig.isLinux = false; diff --git a/src/renderer/components/layout/top-bar/top-bar.test.tsx b/src/renderer/components/layout/top-bar/top-bar.test.tsx index 9c55b6ec7a..b27f51dc45 100644 --- a/src/renderer/components/layout/top-bar/top-bar.test.tsx +++ b/src/renderer/components/layout/top-bar/top-bar.test.tsx @@ -12,9 +12,6 @@ import type { ConfigurableDependencyInjectionContainer } from "@ogre-tools/injec import { DiRender, renderFor } from "../../test-utils/renderFor"; import topBarItemsInjectable from "./top-bar-items/top-bar-items.injectable"; import { computed } from "mobx"; -import directoryForUserDataInjectable - from "../../../../common/app-paths/directory-for-user-data/directory-for-user-data.injectable"; -import mockFs from "mock-fs"; jest.mock("../../../../common/vars", () => { const SemVer = require("semver").SemVer; @@ -74,20 +71,10 @@ describe("", () => { beforeEach(async () => { di = getDiForUnitTesting({ doGeneralOverrides: true }); - - mockFs(); - - di.override(directoryForUserDataInjectable, () => "some-directory-for-user-data"); - await di.runSetups(); - render = renderFor(di); }); - afterEach(() => { - mockFs.restore(); - }); - it("renders w/o errors", () => { const { container } = render(); @@ -95,30 +82,30 @@ describe("", () => { }); it("renders home button", async () => { - const { getByTestId } = render(); + const { findByTestId } = render(); - expect(await getByTestId("home-button")).toBeInTheDocument(); + expect(await findByTestId("home-button")).toBeInTheDocument(); }); it("renders history arrows", async () => { - const { getByTestId } = render(); + const { findByTestId } = render(); - expect(await getByTestId("history-back")).toBeInTheDocument(); - expect(await getByTestId("history-forward")).toBeInTheDocument(); + expect(await findByTestId("history-back")).toBeInTheDocument(); + expect(await findByTestId("history-forward")).toBeInTheDocument(); }); it("enables arrow by ipc event", async () => { - const { getByTestId } = render(); + const { findByTestId } = render(); - expect(await getByTestId("history-back")).not.toHaveClass("disabled"); - expect(await getByTestId("history-forward")).not.toHaveClass("disabled"); + expect(await findByTestId("history-back")).not.toHaveClass("disabled"); + expect(await findByTestId("history-forward")).not.toHaveClass("disabled"); }); it("triggers browser history back and forward", async () => { - const { getByTestId } = render(); + const { findByTestId } = render(); - const prevButton = await getByTestId("history-back"); - const nextButton = await getByTestId("history-forward"); + const prevButton = await findByTestId("history-back"); + const nextButton = await findByTestId("history-forward"); fireEvent.click(prevButton); @@ -141,9 +128,9 @@ describe("", () => { }, ])); - const { getByTestId } = render(); + const { findByTestId } = render(); - expect(await getByTestId(testId)).toHaveTextContent(text); + expect(await findByTestId(testId)).toHaveTextContent(text); }); it("doesn't show windows title buttons", () => { diff --git a/src/renderer/components/layout/top-bar/top-bar.tsx b/src/renderer/components/layout/top-bar/top-bar.tsx index 9a122250d4..913f98eb70 100644 --- a/src/renderer/components/layout/top-bar/top-bar.tsx +++ b/src/renderer/components/layout/top-bar/top-bar.tsx @@ -14,7 +14,7 @@ import { broadcastMessage, ipcRendererOn } from "../../../../common/ipc"; import { watchHistoryState } from "../../../remote-helpers/history-updater"; import { isActiveRoute, navigate } from "../../../navigation"; import { catalogRoute, catalogURL } from "../../../../common/routes"; -import { IpcMainWindowEvents } from "../../../../main/window-manager"; +import { IpcMainWindowEvents } from "../../../../main/windows/manager"; import { isLinux, isWindows } from "../../../../common/vars"; import { cssNames } from "../../../utils"; import topBarItemsInjectable from "./top-bar-items/top-bar-items.injectable"; diff --git a/src/renderer/components/locale-date/locale-date.tsx b/src/renderer/components/locale-date/locale-date.tsx index c15ad44845..45ff370f4f 100644 --- a/src/renderer/components/locale-date/locale-date.tsx +++ b/src/renderer/components/locale-date/locale-date.tsx @@ -6,17 +6,25 @@ import React from "react"; import { observer } from "mobx-react"; import moment from "moment-timezone"; -import { UserStore } from "../../../common/user-store"; +import { withInjectables } from "@ogre-tools/injectable-react"; +import localeTimezoneInjectable from "./locale-timezone.injectable"; +import type { IComputedValue } from "mobx"; -interface Props { +export interface LocaleDateProps { date: string } -@observer -export class LocaleDate extends React.Component { - render() { - const { date } = this.props; - - return moment.tz(date, UserStore.getInstance().localeTimezone).format(); - } +interface Dependencies { + localeTimezone: IComputedValue; } + +const NonInjectedLocaleDate = observer(({ localeTimezone, date }: Dependencies & LocaleDateProps) => ( + <>{moment.tz(date, localeTimezone.get()).format()} +)); + +export const LocaleDate = withInjectables(NonInjectedLocaleDate, { + getProps: (di, props) => ({ + localeTimezone: di.inject(localeTimezoneInjectable), + ...props, + }), +}); diff --git a/src/renderer/components/locale-date/locale-timezone.injectable.ts b/src/renderer/components/locale-date/locale-timezone.injectable.ts new file mode 100644 index 0000000000..a90dbedb6b --- /dev/null +++ b/src/renderer/components/locale-date/locale-timezone.injectable.ts @@ -0,0 +1,18 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { computed } from "mobx"; +import userPreferencesStoreInjectable from "../../../common/user-preferences/store.injectable"; + +const localeTimezoneInjectable = getInjectable({ + instantiate: (di) => { + const userStore = di.inject(userPreferencesStoreInjectable); + + return computed(() => userStore.localeTimezone); + }, + lifecycle: lifecycleEnum.singleton, +}); + +export default localeTimezoneInjectable; diff --git a/src/renderer/components/menu/menu-actions.tsx b/src/renderer/components/menu/menu-actions.tsx index e7e270f126..a79d4667cb 100644 --- a/src/renderer/components/menu/menu-actions.tsx +++ b/src/renderer/components/menu/menu-actions.tsx @@ -5,15 +5,16 @@ import "./menu-actions.scss"; -import React, { isValidElement } from "react"; -import { observable, makeObservable } from "mobx"; +import React, { isValidElement, useState } from "react"; import { observer } from "mobx-react"; -import { boundMethod, cssNames } from "../../utils"; -import { ConfirmDialog } from "../confirm-dialog"; +import { cssNames, noop } from "../../utils"; +import type { ConfirmDialogParams } from "../confirm-dialog"; import { Icon, IconProps } from "../icon"; import { Menu, MenuItem, MenuProps } from "./menu"; -import uniqueId from "lodash/uniqueId"; import isString from "lodash/isString"; +import { withInjectables } from "@ogre-tools/injectable-react"; +import openConfirmDialogInjectable from "../confirm-dialog/dialog-open.injectable"; +import uniqueIdInjectable from "../../../common/utils/unique-id.injectable"; export interface MenuActionsProps extends Partial { className?: string; @@ -21,119 +22,120 @@ export interface MenuActionsProps extends Partial { autoCloseOnSelect?: boolean; triggerIcon?: string | IconProps | React.ReactNode; removeConfirmationMessage?: React.ReactNode | (() => React.ReactNode); - updateAction?(): void; - removeAction?(): void; + updateAction?: () => void | Promise; + removeAction?: () => void | Promise; onOpen?(): void; } -@observer -export class MenuActions extends React.Component { - static defaultProps: MenuActionsProps = { - get removeConfirmationMessage() { - return `Remove item?`; - }, +interface Dependencies { + openConfirmDialog: (params: ConfirmDialogParams) => void; + uniqueId: (prefix: string) => string; +} + +const NonInjectedMenuActions = observer(({ openConfirmDialog, uniqueId, ...props }: Dependencies & MenuActionsProps) => { + const { + className, + autoCloseOnSelect = false, + triggerIcon = "more_vert", + removeConfirmationMessage = "Remove item?", + updateAction, + removeAction, + onOpen = noop, + toolbar, + children, + ...menuProps + } = props; + const autoClose = !toolbar; + const [id] = useState(uniqueId("menu_actions_")); + const [isOpen, setIsOpen] = useState(toolbar); + + const toggle = () => { + setIsOpen(toolbar ||!isOpen); }; - public id = uniqueId("menu_actions_"); + const remove = () => { + const { removeAction } = props; + const message = typeof removeConfirmationMessage === "function" + ? removeConfirmationMessage() + : removeConfirmationMessage; - @observable isOpen = !!this.props.toolbar; - - toggle = () => { - if (this.props.toolbar) return; - this.isOpen = !this.isOpen; - }; - - constructor(props: MenuActionsProps) { - super(props); - makeObservable(this); - } - - @boundMethod - remove() { - const { removeAction } = this.props; - let { removeConfirmationMessage } = this.props; - - if (typeof removeConfirmationMessage === "function") { - removeConfirmationMessage = removeConfirmationMessage(); - } - ConfirmDialog.open({ + openConfirmDialog({ ok: removeAction, - labelOk: `Remove`, - message:
    {removeConfirmationMessage}
    , + labelOk: "Remove", + message, }); - } + }; - renderTriggerIcon() { - if (this.props.toolbar) return null; - const { triggerIcon = "more_vert" } = this.props; + const renderTriggerIcon = () => { let className: string; if (isValidElement(triggerIcon)) { - className = cssNames(triggerIcon.props.className, { active: this.isOpen }); + className = cssNames(triggerIcon.props.className, { active: isOpen }); - return React.cloneElement(triggerIcon, { id: this.id, className } as any); + return React.cloneElement(triggerIcon, { id, className } as any); } const iconProps: Partial = { - id: this.id, + id, interactive: true, material: isString(triggerIcon) ? triggerIcon : undefined, - active: this.isOpen, + active: isOpen, ...(typeof triggerIcon === "object" ? triggerIcon : {}), }; - if (this.props.onOpen) { - iconProps.onClick = this.props.onOpen; + if (onOpen) { + iconProps.onClick = onOpen; } - if (iconProps.tooltip && this.isOpen) { + if (iconProps.tooltip && isOpen) { delete iconProps.tooltip; // don't show tooltip for icon when menu is open } return ( ); - } + }; - render() { - const { - className, toolbar, autoCloseOnSelect, children, updateAction, removeAction, triggerIcon, removeConfirmationMessage, - ...menuProps - } = this.props; - const menuClassName = cssNames("MenuActions flex", className, { - toolbar, - gaps: toolbar, // add spacing for .flex - }); - const autoClose = !toolbar; + return ( + <> + {!toolbar && renderTriggerIcon()} - return ( - <> - {this.renderTriggerIcon()} + + {children} + {updateAction && ( + + + Edit + + )} + {removeAction && ( + + + Delete + + )} + + + ); +}); - - {children} - {updateAction && ( - - - Edit - - )} - {removeAction && ( - - - Delete - - )} - - - ); - } -} +export const MenuActions = withInjectables(NonInjectedMenuActions, { + getProps: (di, props) => ({ + openConfirmDialog: di.inject(openConfirmDialogInjectable), + uniqueId: di.inject(uniqueIdInjectable), + ...props, + }), +}); diff --git a/src/renderer/components/menu/menu.tsx b/src/renderer/components/menu/menu.tsx index 259b0b167d..49807dae3a 100644 --- a/src/renderer/components/menu/menu.tsx +++ b/src/renderer/components/menu/menu.tsx @@ -69,6 +69,7 @@ export class Menu extends React.Component { public elem: HTMLUListElement; protected items: { [index: number]: MenuItem } = {}; public state: State = {}; + protected willUnmount = false; get isOpen() { return !!this.props.isOpen; @@ -110,6 +111,7 @@ export class Menu extends React.Component { window.removeEventListener("resize", this.onWindowResize); window.removeEventListener("click", this.onClickOutside, true); window.removeEventListener("scroll", this.onScrollOutside, true); + this.willUnmount = true; } componentDidUpdate(prevProps: MenuProps) { @@ -203,7 +205,7 @@ export class Menu extends React.Component { } close() { - if (this.isClosed) { + if (this.isClosed || this.willUnmount) { return; } diff --git a/src/renderer/components/mixins.scss b/src/renderer/components/mixins.scss index 4581f0437b..60a48ae3f6 100755 --- a/src/renderer/components/mixins.scss +++ b/src/renderer/components/mixins.scss @@ -4,12 +4,12 @@ */ //-- Mixins -@import "+workloads/workloads-mixins"; -@import "+storage/storage-mixins"; -@import "+nodes/nodes-mixins"; -@import "+namespaces/namespaces-mixins"; -@import "table/table.mixins"; -@import "+network/network-mixins"; +@import "+workloads/mixins"; +@import "+storage/mixins"; +@import "+nodes/mixins"; +@import "+namespaces/mixins"; +@import "table/mixins"; +@import "+network/mixins"; // Hide scrollbar but keep the element scrollable @mixin hidden-scrollbar { @@ -22,8 +22,19 @@ } } -@mixin stripeLinesAnimation($color1: #ccc, $color2: transparent, $spacing: 1rem) { - background: repeating-linear-gradient(-45deg, $color2, $color2, $spacing, $color1 $spacing, $color1 $spacing * 2); +@mixin stripeLinesAnimation( + $color1: #ccc, + $color2: transparent, + $spacing: 1rem +) { + background: repeating-linear-gradient( + -45deg, + $color2, + $color2, + $spacing, + $color1 $spacing, + $color1 $spacing * 2 + ); background-size: 200% 200%; animation: stripeLines 10s linear infinite; diff --git a/src/renderer/components/monaco-editor/monaco-editor.tsx b/src/renderer/components/monaco-editor/monaco-editor.tsx index 788d6d941e..df1d57ea79 100644 --- a/src/renderer/components/monaco-editor/monaco-editor.tsx +++ b/src/renderer/components/monaco-editor/monaco-editor.tsx @@ -4,16 +4,20 @@ */ import styles from "./monaco-editor.module.scss"; -import React from "react"; +import React, { forwardRef, useEffect, useImperativeHandle, useRef, useState } from "react"; import { observer } from "mobx-react"; -import { action, computed, makeObservable, observable, reaction } from "mobx"; +import { action, IComputedValue, reaction } from "mobx"; import { editor, Uri } from "monaco-editor"; import type { MonacoTheme } from "./monaco-themes"; import { MonacoValidator, monacoValidators } from "./monaco-validators"; import { debounce, merge } from "lodash"; import { cssNames, disposer } from "../../utils"; -import { UserStore } from "../../../common/user-store"; -import { ThemeStore } from "../../theme.store"; +import type { UserPreferencesStore } from "../../../common/user-preferences"; +import type { Theme } from "../../themes/store"; +import { withInjectables } from "@ogre-tools/injectable-react"; +import activeThemeInjectable from "../../themes/active-theme.injectable"; +import userPreferencesStoreInjectable from "../../../common/user-preferences/store.injectable"; +import type { LensLogger } from "../../../common/logger"; export type MonacoEditorId = string; @@ -33,106 +37,105 @@ export interface MonacoEditorProps { onDidContentSizeChange?(evt: editor.IContentSizeChangedEvent): void; onModelChange?(model: editor.ITextModel, prev?: editor.ITextModel): void; } +interface Dependencies { + readonly activeTheme: IComputedValue; + readonly userStore: UserPreferencesStore; + readonly logger: LensLogger; +} -export const defaultEditorProps: Partial = { - language: "yaml", - get theme(): MonacoTheme { - // theme for monaco-editor defined in `src/renderer/themes/lens-*.json` - return ThemeStore.getInstance().activeTheme.monacoTheme; - }, -}; +function createUri(id: MonacoEditorId): Uri { + return Uri.file(`/monaco-editor/${id}`); +} -@observer -export class MonacoEditor extends React.Component { - static defaultProps = defaultEditorProps as object; - static viewStates = new WeakMap(); +const viewStates = new WeakMap(); - static createUri(id: MonacoEditorId): Uri { - return Uri.file(`/monaco-editor/${id}`); - } +export interface MonacoEditorRef { + focus: () => void; +} - public staticId = `editor-id#${Math.round(1e7 * Math.random())}`; - public dispose = disposer(); +const NonInjectedMonacoEditor = observer(forwardRef(({ + language = "yaml", + activeTheme, + userStore, + id: propsId, + className, + style, + autoFocus, + readOnly, + theme = activeTheme.get().monacoTheme, + options: propsOptions, + value: defaultValue, + onChange, + onError, + onDidLayoutChange, + onDidContentSizeChange, + onModelChange: propsOnModelChange, + logger, +}, ref) => { + const [staticId] = useState(`editor-id#${Math.round(1e7 * Math.random())}`); + const containerElem = useRef(); + const [editorRef, setEditorRef] = useState(); + const [unmounting, setUnmounting] = useState(false); + const [dispose] = useState(() => disposer()); - // TODO: investigate how to replace with "common/logger" - // currently leads for stucking UI forever & infinite loop. - // e.g. happens on tab change/create, maybe some other cases too. - logger = console; + const id = propsId ?? staticId; + const uri = createUri(id); + const model = editor.getModel(uri) ?? editor.createModel(defaultValue, language, uri); + const options = merge( + {}, + userStore.editorConfiguration, + propsOptions, + ); + const logMetadata = { editorId: id, model }; - @observable.ref containerElem: HTMLElement; - @observable.ref editor: editor.IStandaloneCodeEditor; - @observable dimensions: { width?: number, height?: number } = {}; - @observable unmounting = false; + const validate = action((value = getValue()) => { + const validators: MonacoValidator[] = [ + monacoValidators[language], // parsing syntax check + ].filter(Boolean); - constructor(props: MonacoEditorProps) { - super(props); - makeObservable(this); - } - - @computed get id() { - return this.props.id ?? this.staticId; - } - - @computed get model(): editor.ITextModel { - const uri = MonacoEditor.createUri(this.id); - const model = editor.getModel(uri); - - if (model) { - return model; // already exists + for (const validate of validators) { + try { + validate(value); + } catch (error) { + onError?.(error); // emit error outside + } } + }); - const { language, value } = this.props; - - return editor.createModel(value, language, uri); - } - - @computed get options(): editor.IStandaloneEditorConstructionOptions { - return merge({}, - UserStore.getInstance().editorConfiguration, - this.props.options, - ); - } - - @computed get logMetadata() { - return { - editorId: this.id, - model: this.model, - }; - } + const [validateLazy] = useState(() => debounce(validate, 250)); /** * Monitor editor's dom container element box-size and sync with monaco's dimensions - * @private */ - private bindResizeObserver() { + const bindResizeObserver = () => { const resizeObserver = new ResizeObserver(entries => { for (const entry of entries) { const { width, height } = entry.contentRect; - this.setDimensions(width, height); + setDimensions(width, height); } }); - const containerElem = this.editor.getContainerDomNode(); + const containerElem = editorRef.getContainerDomNode(); resizeObserver.observe(containerElem); return () => resizeObserver.unobserve(containerElem); - } + }; - onModelChange = (model: editor.ITextModel, oldModel?: editor.ITextModel) => { - this.logger?.info("[MONACO]: model change", { model, oldModel }, this.logMetadata); + const onModelChange = (model: editor.ITextModel, oldModel?: editor.ITextModel) => { + logger?.info("[MONACO]: model change", { model, oldModel }, logMetadata); if (oldModel) { - this.saveViewState(oldModel); + saveViewState(oldModel); } - this.editor.setModel(model); - this.restoreViewState(model); - this.editor.layout(); - this.editor.focus(); // keep focus in editor, e.g. when clicking between dock-tabs - this.props.onModelChange?.(model, oldModel); - this.validateLazy(); + editorRef.setModel(model); + restoreViewState(model); + editorRef.layout(); + editorRef.focus(); // keep focus in editor, e.g. when clicking between dock-tabs + propsOnModelChange?.(model, oldModel); + validateLazy(); }; /** @@ -140,145 +143,135 @@ export class MonacoEditor extends React.Component { * This will allow restore cursor position, selected text, etc. * @param {editor.ITextModel} model */ - private saveViewState(model: editor.ITextModel) { - MonacoEditor.viewStates.set(model.uri, this.editor.saveViewState()); - } + const saveViewState = (model: editor.ITextModel) => { + viewStates.set(model.uri, editorRef.saveViewState()); + }; - private restoreViewState(model: editor.ITextModel) { - const viewState = MonacoEditor.viewStates.get(model.uri); + const restoreViewState = (model: editor.ITextModel) => { + const viewState = viewStates.get(model.uri); if (viewState) { - this.editor.restoreViewState(viewState); + editorRef.restoreViewState(viewState); } - } + }; - componentDidMount() { - try { - this.createEditor(); - this.logger?.info(`[MONACO]: editor did mount`, this.logMetadata); - } catch (error) { - this.logger?.error(`[MONACO]: mounting failed: ${error}`, this.logMetadata); - } - } - - componentWillUnmount() { - this.unmounting = true; - this.saveViewState(this.model); - this.destroy(); - } - - private createEditor() { - if (!this.containerElem || this.editor || this.unmounting) { + const createEditor = () => { + if (!containerElem.current || editorRef || unmounting) { return; } - const { language, theme, readOnly, value: defaultValue } = this.props; - this.editor = editor.create(this.containerElem, { - model: this.model, + const _editor = editor.create(containerElem.current, { + model, detectIndentation: false, // allow `option.tabSize` to use custom number of spaces for [Tab] value: defaultValue, language, theme, readOnly, - ...this.options, + ...options, }); - this.logger?.info(`[MONACO]: editor created for language=${language}, theme=${theme}`, this.logMetadata); - this.validateLazy(); // validate initial value - this.restoreViewState(this.model); // restore previous state if any + setEditorRef(_editor); - if (this.props.autoFocus) { - this.editor.focus(); + logger?.info(`[MONACO]: editor created for language=${language}, theme=${theme}`, logMetadata); + validateLazy(); // validate initial value + restoreViewState(model); // restore previous state if any + + if (autoFocus) { + editorRef.focus(); } - const onDidLayoutChangeDisposer = this.editor.onDidLayoutChange(layoutInfo => { - this.props.onDidLayoutChange?.(layoutInfo); + const onDidLayoutChangeDisposer = editorRef.onDidLayoutChange(layoutInfo => { + onDidLayoutChange?.(layoutInfo); }); - const onValueChangeDisposer = this.editor.onDidChangeModelContent(event => { - const value = this.editor.getValue(); + const onValueChangeDisposer = editorRef.onDidChangeModelContent(event => { + const value = editorRef.getValue(); - this.props.onChange?.(value, event); - this.validateLazy(value); + onChange?.(value, event); + validateLazy(value); }); - const onContentSizeChangeDisposer = this.editor.onDidContentSizeChange((params) => { - this.props.onDidContentSizeChange?.(params); + const onContentSizeChangeDisposer = editorRef.onDidContentSizeChange((params) => { + onDidContentSizeChange?.(params); }); - this.dispose.push( - reaction(() => this.model, this.onModelChange), - reaction(() => this.props.theme, editor.setTheme), - reaction(() => this.props.value, value => this.setValue(value)), - reaction(() => this.options, opts => this.editor.updateOptions(opts)), + dispose.push( + reaction(() => model, onModelChange), + reaction(() => theme, editor.setTheme), + reaction(() => defaultValue, value => setValue(value)), + reaction(() => options, opts => editorRef.updateOptions(opts)), () => onDidLayoutChangeDisposer.dispose(), () => onValueChangeDisposer.dispose(), () => onContentSizeChangeDisposer.dispose(), - this.bindResizeObserver(), + bindResizeObserver(), ); - } - - destroy(): void { - if (!this.editor) return; - - this.dispose(); - this.editor.dispose(); - this.editor = null; - } - - @action - setDimensions(width: number, height: number) { - this.dimensions.width = width; - this.dimensions.height = height; - this.editor?.layout({ width, height }); - } - - setValue(value = ""): void { - if (value == this.getValue()) return; - - this.editor.setValue(value); - this.validate(value); - } - - getValue(opts?: { preserveBOM: boolean; lineEnding: string; }): string { - return this.editor?.getValue(opts) ?? ""; - } - - focus() { - this.editor?.focus(); - } - - @action - validate = (value = this.getValue()) => { - const validators: MonacoValidator[] = [ - monacoValidators[this.props.language], // parsing syntax check - ].filter(Boolean); - - for (const validate of validators) { - try { - validate(value); - } catch (error) { - this.props.onError?.(error); // emit error outside - } - } }; - // avoid excessive validations during typing - validateLazy = debounce(this.validate, 250); + const destroy = () => { + if (!editorRef) return; - bindRef = (elem: HTMLElement) => this.containerElem = elem; + dispose(); + editorRef.dispose(); + setEditorRef(undefined); + }; - render() { - const { className, style } = this.props; + const setDimensions = (width: number, height: number) => { + editorRef?.layout({ width, height }); + }; - return ( -
    - ); - } -} + const setValue = (value = "") => { + if (value == getValue()) return; + + editorRef.setValue(value); + validate(value); + }; + + const getValue = (opts?: { preserveBOM: boolean; lineEnding: string; }) => { + return editorRef?.getValue(opts) ?? ""; + }; + + const focus = () => { + editorRef?.focus(); + }; + + useImperativeHandle(ref, () => ({ + focus, + })); + + useEffect(() => { + try { + createEditor(); + logger?.info(`[MONACO]: editor did mount`, logMetadata); + } catch (error) { + logger?.error(`[MONACO]: mounting failed: ${error}`, logMetadata); + } + + return () => { + setUnmounting(true); + saveViewState(model); + destroy(); + }; + }, []); + + return ( +
    + ); +})); + +export const MonacoEditor = withInjectables(NonInjectedMonacoEditor, { + getProps: (di, props) => ({ + activeTheme: di.inject(activeThemeInjectable), + userStore: di.inject(userPreferencesStoreInjectable), + logger: { + ...console, + silly: console.debug, + }, + ...props, + }), +}); diff --git a/src/renderer/components/monaco-editor/monaco-validators.ts b/src/renderer/components/monaco-editor/monaco-validators.ts index 02c8281c4e..f2c1280d21 100644 --- a/src/renderer/components/monaco-editor/monaco-validators.ts +++ b/src/renderer/components/monaco-editor/monaco-validators.ts @@ -2,7 +2,7 @@ * Copyright (c) OpenLens Authors. All rights reserved. * Licensed under MIT License. See LICENSE in root directory for more information. */ -import yaml, { YAMLException } from "js-yaml"; +import yaml from "js-yaml"; export interface MonacoValidator { (value: string): void; @@ -10,9 +10,9 @@ export interface MonacoValidator { export function yamlValidator(value: string) { try { - yaml.load(value); + yaml.loadAll(value); } catch (error) { - throw String(error as YAMLException); + throw String(error); } } diff --git a/src/renderer/components/path-picker/path-picker.tsx b/src/renderer/components/path-picker/path-picker.tsx index 9a0d81f597..da4f649391 100644 --- a/src/renderer/components/path-picker/path-picker.tsx +++ b/src/renderer/components/path-picker/path-picker.tsx @@ -42,7 +42,7 @@ export class PathPicker extends React.Component { } } - async onClick() { + onClick() { const { className, disabled, ...pickOpts } = this.props; return PathPicker.pick(pickOpts); diff --git a/src/renderer/components/resource-metrics/resource-metrics.tsx b/src/renderer/components/resource-metrics/resource-metrics.tsx index 61cfdd00cf..946e349696 100644 --- a/src/renderer/components/resource-metrics/resource-metrics.tsx +++ b/src/renderer/components/resource-metrics/resource-metrics.tsx @@ -9,43 +9,42 @@ import React, { createContext, useEffect, useState } from "react"; import { Radio, RadioGroup } from "../radio"; import { useInterval } from "../../hooks"; import type { KubeObject } from "../../../common/k8s-api/kube-object"; -import { cssNames } from "../../utils"; +import { cssNames, noop } from "../../utils"; import { Spinner } from "../spinner"; +import type { IMetrics } from "../../../common/k8s-api/endpoints/metrics.api"; -interface Props extends React.HTMLProps { +export interface ResourceMetricsProps { tabs: React.ReactNode[]; object?: KubeObject; loader?: () => void; + /** + * The time (in seconds) between each call to `loader` + * + * @default 60 + */ interval?: number; className?: string; - params?: { - [key: string]: any; - }; + children?: React.ReactChildren | React.ReactChild; + metrics: Record | null; } -export type IResourceMetricsValue = { - object: T; +export interface IResourceMetricsValue { + object?: KubeObject; tabId: number; - params?: P; -}; + metrics: Record | null; +} export const ResourceMetricsContext = createContext(null); -const defaultProps: Partial = { - interval: 60, // 1 min -}; - -ResourceMetrics.defaultProps = defaultProps; - -export function ResourceMetrics({ object, loader, interval, tabs, children, className, params }: Props) { - const [tabId, setTabId] = useState(0); +export function ResourceMetrics({ object, loader = noop, interval = 60, tabs, children, metrics, className }: ResourceMetricsProps) { + const [tabId, setTabId] = useState(0); useEffect(() => { - if (loader) loader(); + loader(); }, [object]); useInterval(() => { - if (loader) loader(); + loader(); }, interval * 1000); const renderContents = () => { @@ -63,7 +62,7 @@ export function ResourceMetrics({ object, loader, interval, tabs, children, clas ))}
    - +
    {children}
    diff --git a/src/renderer/components/select/select.tsx b/src/renderer/components/select/select.tsx index 0f1e824605..624afb52a5 100644 --- a/src/renderer/components/select/select.tsx +++ b/src/renderer/components/select/select.tsx @@ -8,13 +8,14 @@ import "./select.scss"; import React, { ReactNode } from "react"; -import { computed, makeObservable } from "mobx"; +import { computed, IComputedValue, makeObservable } from "mobx"; import { observer } from "mobx-react"; import ReactSelect, { ActionMeta, components, OptionTypeBase, Props as ReactSelectProps, Styles } from "react-select"; import Creatable, { CreatableProps } from "react-select/creatable"; - -import { ThemeStore } from "../../theme.store"; +import type { Theme } from "../../themes/store"; import { boundMethod, cssNames } from "../../utils"; +import { withInjectables } from "@ogre-tools/injectable-react"; +import activeThemeInjectable from "../../themes/active-theme.injectable"; const { Menu } = components; @@ -37,21 +38,25 @@ export interface SelectProps extends ReactSelectProps, Crea onChange?(option: T, meta?: ActionMeta): void; } +interface Dependencies { + readonly activeTheme: IComputedValue; +} + @observer -export class Select extends React.Component { +class NonInjectedSelect extends React.Component { static defaultProps: SelectProps = { autoConvertOptions: true, menuPortalTarget: document.body, menuPlacement: "auto", }; - constructor(props: SelectProps) { + constructor(props: SelectProps & Dependencies) { super(props); makeObservable(this); } @computed get themeClass() { - const themeName = this.props.themeName || ThemeStore.getInstance().activeTheme.type; + const themeName = this.props.themeName || this.props.activeTheme.get().type; return `theme-${themeName}`; } @@ -142,3 +147,10 @@ export class Select extends React.Component { : ; } } + +export const Select = withInjectables(NonInjectedSelect, { + getProps: (di, props) => ({ + activeTheme: di.inject(activeThemeInjectable), + ...props, + }), +}); diff --git a/src/renderer/components/table/get-sort-params.injectable.ts b/src/renderer/components/table/get-sort-params.injectable.ts new file mode 100644 index 0000000000..88625385de --- /dev/null +++ b/src/renderer/components/table/get-sort-params.injectable.ts @@ -0,0 +1,26 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { bind, StorageLayer } from "../../utils"; +import tableSortStorageInjectable from "./storage.injectable"; +import type { TableStorageModel } from "./storage.model"; +import type { TableSortParams } from "./table"; + +interface Dependencies { + tableSortData: StorageLayer; +} + +function getTableSortParams({ tableSortData }: Dependencies, tableId: string): Partial { + return tableSortData.get().sortParams[tableId] ?? {}; +} + +const getTableSortParamsInjectable = getInjectable({ + instantiate: (di) => bind(getTableSortParams, null, { + tableSortData: di.inject(tableSortStorageInjectable), + }), + lifecycle: lifecycleEnum.singleton, +}); + +export default getTableSortParamsInjectable; diff --git a/src/renderer/components/table/table.mixins.scss b/src/renderer/components/table/mixins.scss similarity index 100% rename from src/renderer/components/table/table.mixins.scss rename to src/renderer/components/table/mixins.scss diff --git a/src/renderer/components/table/order-by-param.injectable.ts b/src/renderer/components/table/order-by-param.injectable.ts new file mode 100644 index 0000000000..2ef19808f8 --- /dev/null +++ b/src/renderer/components/table/order-by-param.injectable.ts @@ -0,0 +1,15 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { createPageParam } from "../../navigation"; + +const orderByUrlParamInjectable = getInjectable({ + instantiate: () => createPageParam({ + name: "order", + }), + lifecycle: lifecycleEnum.singleton, +}); + +export default orderByUrlParamInjectable; diff --git a/src/renderer/components/table/set-sort-params.injectable.ts b/src/renderer/components/table/set-sort-params.injectable.ts new file mode 100644 index 0000000000..4e42b9d243 --- /dev/null +++ b/src/renderer/components/table/set-sort-params.injectable.ts @@ -0,0 +1,28 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { bind, StorageLayer } from "../../utils"; +import tableSortStorageInjectable from "./storage.injectable"; +import type { TableStorageModel } from "./storage.model"; +import type { TableSortParams } from "./table"; + +interface Dependencies { + tableSortData: StorageLayer; +} + +function setTableSortParams({ tableSortData }: Dependencies, tableId: string, data: Partial): void { + tableSortData.merge(draft => { + draft.sortParams[tableId] = data; + }); +} + +const setTableSortParamsInjectable = getInjectable({ + instantiate: (di) => bind(setTableSortParams, null, { + tableSortData: di.inject(tableSortStorageInjectable), + }), + lifecycle: lifecycleEnum.singleton, +}); + +export default setTableSortParamsInjectable; diff --git a/src/renderer/components/table/sort-by-param.injectable.ts b/src/renderer/components/table/sort-by-param.injectable.ts new file mode 100644 index 0000000000..7ab7452e41 --- /dev/null +++ b/src/renderer/components/table/sort-by-param.injectable.ts @@ -0,0 +1,15 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { createPageParam } from "../../navigation"; + +const sortByUrlParamInjectable = getInjectable({ + instantiate: () => createPageParam({ + name: "sort", + }), + lifecycle: lifecycleEnum.singleton, +}); + +export default sortByUrlParamInjectable; diff --git a/src/renderer/components/table/storage.injectable.ts b/src/renderer/components/table/storage.injectable.ts new file mode 100644 index 0000000000..f2213b3da5 --- /dev/null +++ b/src/renderer/components/table/storage.injectable.ts @@ -0,0 +1,23 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import type { StorageLayer } from "../../utils"; +import createStorageInjectable from "../../utils/create-storage/create-storage.injectable"; +import type { TableStorageModel } from "./storage.model"; + +let storage: StorageLayer; + +const tableSortStorageInjectable = getInjectable({ + setup: async (di) => { + storage = await di.inject(createStorageInjectable)("table_settings", { + sortParams: {}, + }); + }, + instantiate: () => storage, + lifecycle: lifecycleEnum.singleton, +}); + +export default tableSortStorageInjectable; diff --git a/src/renderer/components/table/storage.model.ts b/src/renderer/components/table/storage.model.ts new file mode 100644 index 0000000000..e1321f6358 --- /dev/null +++ b/src/renderer/components/table/storage.model.ts @@ -0,0 +1,10 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { TableSortParams } from "./table"; + +export interface TableStorageModel { + sortParams: Record | undefined>; +} diff --git a/src/renderer/components/table/table-model/table-model.injectable.ts b/src/renderer/components/table/table-model/table-model.injectable.ts deleted file mode 100644 index af91a3ad1b..0000000000 --- a/src/renderer/components/table/table-model/table-model.injectable.ts +++ /dev/null @@ -1,25 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ -import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; -import createStorageInjectable from "../../../utils/create-storage/create-storage.injectable"; -import { TableModel, TableStorageModel } from "./table-model"; - -const tableModelInjectable = getInjectable({ - instantiate: (di) => { - const createStorage = di.inject(createStorageInjectable); - - const storage = createStorage("table_settings", { - sortParams: {}, - }); - - return new TableModel({ - storage, - }); - }, - - lifecycle: lifecycleEnum.singleton, -}); - -export default tableModelInjectable; diff --git a/src/renderer/components/table/table-model/table-model.ts b/src/renderer/components/table/table-model/table-model.ts deleted file mode 100644 index 341d7b8f26..0000000000 --- a/src/renderer/components/table/table-model/table-model.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ -import type { StorageHelper } from "../../../utils"; -import type { TableSortParams } from "../table"; - -export interface TableStorageModel { - sortParams: { - [tableId: string]: Partial; - }; -} - -interface Dependencies { - storage: StorageHelper; -} - -export class TableModel { - constructor(private dependencies: Dependencies) {} - - getSortParams = (tableId: string): Partial => - this.dependencies.storage.get().sortParams[tableId]; - - setSortParams = ( - tableId: string, - sortParams: Partial, - ): void => { - this.dependencies.storage.merge((draft) => { - draft.sortParams[tableId] = sortParams; - }); - }; -} diff --git a/src/renderer/components/table/table-row.tsx b/src/renderer/components/table/table-row.tsx index 75a13ef37a..9fcba06616 100644 --- a/src/renderer/components/table/table-row.tsx +++ b/src/renderer/components/table/table-row.tsx @@ -7,21 +7,20 @@ import "./table-row.scss"; import React, { CSSProperties } from "react"; import { cssNames } from "../../utils"; -import type { ItemObject } from "../../../common/item.store"; -export type TableRowElem = React.ReactElement; +export type TableRowElem = React.ReactElement>; -export interface TableRowProps extends React.DOMAttributes { +export interface TableRowProps extends React.DOMAttributes { className?: string; selected?: boolean; style?: CSSProperties; nowrap?: boolean; // white-space: nowrap, align inner in one line - sortItem?: ItemObject | any; // data for sorting callback in - searchItem?: ItemObject | any; // data for searching filters in
    + sortItem?: T; // data for sorting callback in
    + searchItem?: T; // data for searching filters in
    disabled?: boolean; } -export class TableRow extends React.Component { +export class TableRow extends React.Component> { render() { const { className, nowrap, selected, disabled, children, sortItem, searchItem, ...rowProps } = this.props; const classNames = cssNames("TableRow", className, { selected, nowrap, disabled }); diff --git a/src/renderer/components/table/table.tsx b/src/renderer/components/table/table.tsx index 4823626094..a7bda67038 100644 --- a/src/renderer/components/table/table.tsx +++ b/src/renderer/components/table/table.tsx @@ -7,20 +7,21 @@ import "./table.scss"; import React from "react"; import { observer } from "mobx-react"; -import { boundMethod, cssNames } from "../../utils"; +import { cssNames } from "../../utils"; import { TableRow, TableRowElem, TableRowProps } from "./table-row"; import { TableHead, TableHeadElem, TableHeadProps } from "./table-head"; import type { TableCellElem } from "./table-cell"; import { VirtualList } from "../virtual-list"; -import { createPageParam } from "../../navigation"; -import { computed, makeObservable } from "mobx"; -import { getSorted } from "./sorting"; -import type { TableModel } from "./table-model/table-model"; import { withInjectables } from "@ogre-tools/injectable-react"; -import tableModelInjectable from "./table-model/table-model.injectable"; +import type { PageParam } from "../../navigation"; +import getTableSortParamsInjectable from "./get-sort-params.injectable"; +import setTableSortParamsInjectable from "./set-sort-params.injectable"; +import orderByUrlParamInjectable from "./order-by-param.injectable"; +import sortByUrlParamInjectable from "./sort-by-param.injectable"; +import { getSorted } from "./sorting"; export type TableSortBy = string; -export type TableOrderBy = "asc" | "desc" | string; +export type TableOrderBy = "asc" | "desc"; export type TableSortParams = { sortBy: TableSortBy; orderBy: TableOrderBy }; export type TableSortCallback = (data: Item) => string | number | (string | number)[]; export type TableSortCallbacks = Record>; @@ -64,66 +65,57 @@ export interface TableProps extends React.DOMAttributes { */ rowLineHeight?: number; customRowHeights?: (item: Item, lineHeight: number, paddings: number) => number; - getTableRow?: (uid: string) => React.ReactElement; - renderRow?: (item: Item) => React.ReactElement; + getTableRow?: (uid: string) => React.ReactElement>; + renderRow?: (item: Item) => React.ReactElement>; + "data-testid"?: string; } -export const sortByUrlParam = createPageParam({ - name: "sort", -}); - -export const orderByUrlParam = createPageParam({ - name: "order", -}); - interface Dependencies { - model: TableModel + getSortParams: (tableId: string) => Partial; + setSortParams: (tableId: string, data: Partial) => void; + sortByUrlParam: PageParam; + orderByUrlParam: PageParam; } -@observer -class NonInjectedTable extends React.Component & Dependencies> { - static defaultProps: TableProps = { - scrollable: true, - autoSize: true, - rowPadding: 8, - rowLineHeight: 17, - sortSyncWithUrl: true, - customRowHeights: (item, lineHeight, paddings) => lineHeight + paddings, +const NonInjectedTable = observer(({ getSortParams, setSortParams, sortByUrlParam, orderByUrlParam, ...props }: Dependencies & TableProps) => { + const { + scrollable = true, + autoSize = true, + rowPadding = 8, + rowLineHeight = 17, + sortSyncWithUrl = true, + customRowHeights = (item, lineHeight, paddings) => lineHeight + paddings, + sortable, + tableId, + sortByDefault, + children, + onSort, + items, + renderRow, + noItems, + virtual, + getTableRow, + selectedItemId, + className, + virtualHeight, + selectable, + "data-testid": dataTestId, + } = props; + const isSortable = Boolean(sortable && tableId); + const sortParams = { + ...sortByDefault, + ...getSortParams(tableId), }; - constructor(props: TableProps & Dependencies) { - super(props); - makeObservable(this); - } - - componentDidMount() { - const { sortable, tableId } = this.props; - - if (sortable && !tableId) { - console.error("Table must have props.tableId if props.sortable is specified"); - } - } - - @computed get isSortable() { - const { sortable, tableId } = this.props; - - return Boolean(sortable && tableId); - } - - @computed get sortParams() { - return Object.assign({}, this.props.sortByDefault, this.props.model.getSortParams(this.props.tableId)); - } - - renderHead() { - const { children } = this.props; - const content = React.Children.toArray(children) as (TableRowElem | TableHeadElem)[]; + const renderHead = () => { + const content = React.Children.toArray(children) as (TableRowElem | TableHeadElem)[]; const headElem: React.ReactElement = content.find(elem => elem.type === TableHead); if (!headElem) { return null; } - if (this.isSortable) { + if (isSortable) { const columns = React.Children.toArray(headElem.props.children) as TableCellElem[]; return React.cloneElement(headElem, { @@ -139,8 +131,8 @@ class NonInjectedTable extends React.Component & Dependen return React.cloneElement(elem, { title, - _sort: this.sort, - _sorting: this.sortParams, + _sort: sort, + _sorting: sortParams, _nowrap: headElem.props.nowrap, }); }), @@ -148,17 +140,10 @@ class NonInjectedTable extends React.Component & Dependen } return headElem; - } + }; - getSorted(rawItems: Item[]) { - const { sortBy, orderBy: orderByRaw } = this.sortParams; - - return getSorted(rawItems, this.props.sortable[sortBy], orderByRaw); - } - - protected onSort({ sortBy, orderBy }: TableSortParams) { - this.props.model.setSortParams(this.props.tableId, { sortBy, orderBy }); - const { sortSyncWithUrl, onSort } = this.props; + const onSortWrapped = ({ sortBy, orderBy }: TableSortParams) => { + setSortParams(tableId, { sortBy, orderBy }); if (sortSyncWithUrl) { sortByUrlParam.set(sortBy); @@ -166,43 +151,39 @@ class NonInjectedTable extends React.Component & Dependen } onSort?.({ sortBy, orderBy }); - } + }; - @boundMethod - sort(colName: TableSortBy) { - const { sortBy, orderBy } = this.sortParams; + const sort = (colName: TableSortBy) => { + const { sortBy, orderBy } = sortParams; const sameColumn = sortBy == colName; const newSortBy: TableSortBy = colName; const newOrderBy: TableOrderBy = (!orderBy || !sameColumn || orderBy === "desc") ? "asc" : "desc"; - this.onSort({ + onSortWrapped({ sortBy: String(newSortBy), orderBy: newOrderBy, }); - } + }; - private getContent() { - const { items, renderRow, children } = this.props; - const content = React.Children.toArray(children) as (TableRowElem | TableHeadElem)[]; + const getContent = () => { + const content = React.Children.toArray(children) as (TableRowElem | TableHeadElem)[]; if (renderRow) { content.push(...items.map(renderRow)); } return content; - } + }; - renderRows() { - const { - noItems, virtual, customRowHeights, rowLineHeight, rowPadding, items, - getTableRow, selectedItemId, className, virtualHeight, - } = this.props; - const content = this.getContent(); - let rows: React.ReactElement[] = content.filter(elem => elem.type === TableRow); + const renderRows = () => { + const content = getContent(); + let rows: React.ReactElement>[] = content.filter(elem => elem.type === TableRow); let sortedItems = rows.length ? rows.map(row => row.props.sortItem) : [...items]; - if (this.isSortable) { - sortedItems = this.getSorted(sortedItems); + if (isSortable) { + const { sortBy, orderBy } = sortParams; + + sortedItems = getSorted(sortedItems, sortable[sortBy], orderBy); if (rows.length) { rows = sortedItems.map(item => rows.find(row => item == row.props.sortItem)); @@ -229,35 +210,31 @@ class NonInjectedTable extends React.Component & Dependen } return rows; - } + }; - render() { - const { selectable, scrollable, autoSize, virtual, className } = this.props; - const classNames = cssNames("Table flex column", className, { - selectable, scrollable, sortable: this.isSortable, autoSize, virtual, - }); + return ( +
    + {renderHead()} + {renderRows()} +
    + ); +}); - return ( -
    - {this.renderHead()} - {this.renderRows()} -
    - ); - } -} +const InjectedTable = withInjectables>(NonInjectedTable, { + getProps: (di, props) => ({ + getSortParams: di.inject(getTableSortParamsInjectable), + setSortParams: di.inject(setTableSortParamsInjectable), + sortByUrlParam: di.inject(sortByUrlParamInjectable), + orderByUrlParam: di.inject(orderByUrlParamInjectable), + ...props, + }), +}); export function Table(props: TableProps) { - const InjectedTable = withInjectables>( - NonInjectedTable, - - { - getProps: (di, props) => ({ - model: di.inject(tableModelInjectable), - ...props, - }), - }, - ); - return ; } - diff --git a/src/renderer/components/tabs/tabs.tsx b/src/renderer/components/tabs/tabs.tsx index 7c198ca08f..73185bcf2a 100644 --- a/src/renderer/components/tabs/tabs.tsx +++ b/src/renderer/components/tabs/tabs.tsx @@ -55,7 +55,7 @@ export class Tabs extends React.PureComponent { } } -export interface TabProps extends DOMAttributes { +export interface TabProps extends DOMAttributes { id?: string; className?: string; active?: boolean; @@ -65,7 +65,7 @@ export interface TabProps extends DOMAttributes { value: D; } -export class Tab extends React.PureComponent { +export class Tab extends React.PureComponent> { static contextType = TabsContext; declare context: TabsContextValue; public ref = React.createRef(); diff --git a/src/renderer/components/test-utils/renderFor.tsx b/src/renderer/components/test-utils/renderFor.tsx index d7b1b2c73b..645f9b5bdc 100644 --- a/src/renderer/components/test-utils/renderFor.tsx +++ b/src/renderer/components/test-utils/renderFor.tsx @@ -3,23 +3,17 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ import React from "react"; - -import { - render as testingLibraryRender, - RenderResult, -} from "@testing-library/react"; - +import { render, RenderResult } from "@testing-library/react"; import type { DependencyInjectionContainer } from "@ogre-tools/injectable"; - import { DiContextProvider } from "@ogre-tools/injectable-react"; export type DiRender = (ui: React.ReactElement) => RenderResult; -type DiRenderFor = (di: DependencyInjectionContainer) => DiRender; - -export const renderFor: DiRenderFor = (di) => (ui) => { - const result = testingLibraryRender( - {ui}, +export const renderFor = (di: DependencyInjectionContainer): DiRender => (ui) => { + const result = render( + + {ui} + , ); return { diff --git a/src/renderer/components/virtual-list/virtual-list.tsx b/src/renderer/components/virtual-list/virtual-list.tsx index 43fe8bc083..9a8dc4e596 100644 --- a/src/renderer/components/virtual-list/virtual-list.tsx +++ b/src/renderer/components/virtual-list/virtual-list.tsx @@ -132,17 +132,16 @@ export class VirtualList extends Component { } } -interface RowData { +interface RowData { items: ItemObject[]; - getRow?: (uid: string | number) => React.ReactElement; + getRow?: (uid: string | number) => React.ReactElement>; } -interface RowProps extends ListChildComponentProps { - data: RowData; +interface RowProps extends ListChildComponentProps { + data: RowData; } -const Row = observer((props: RowProps) => { - const { index, style, data } = props; +const NonGenericRow = observer(({ index, style, data }: RowProps) => { const { items, getRow } = data; const item = items[index]; const uid = typeof item == "string" ? index : items[index].getId(); @@ -154,3 +153,7 @@ const Row = observer((props: RowProps) => { style: Object.assign({}, row.props.style, style), }); }); + +function Row(props: RowProps) { + return ; +} diff --git a/src/renderer/create-cluster/create-cluster.injectable.ts b/src/renderer/create-cluster/create-cluster.injectable.ts index 30982a8b20..3d78600f5e 100644 --- a/src/renderer/create-cluster/create-cluster.injectable.ts +++ b/src/renderer/create-cluster/create-cluster.injectable.ts @@ -4,17 +4,19 @@ */ import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; import type { ClusterModel } from "../../common/cluster-types"; -import { Cluster } from "../../common/cluster/cluster"; -import directoryForKubeConfigsInjectable from "../../common/app-paths/directory-for-kube-configs/directory-for-kube-configs.injectable"; +import { Cluster, ClusterDependencies } from "../../common/cluster/cluster"; +import directoryForKubeConfigsInjectable from "../../common/app-paths/directory-for-kube-configs.injectable"; import { createClusterInjectionToken } from "../../common/cluster/create-cluster-injection-token"; const createClusterInjectable = getInjectable({ instantiate: (di) => { - const dependencies = { + const dependencies: ClusterDependencies = { directoryForKubeConfigs: di.inject(directoryForKubeConfigsInjectable), createKubeconfigManager: () => { throw new Error("Tried to access back-end feature in front-end."); }, createKubectl: () => { throw new Error("Tried to access back-end feature in front-end.");}, createContextHandler: () => { throw new Error("Tried to access back-end feature in front-end."); }, + detectMetadataForCluster: () => { throw new Error("Tried to access back-end feature in front-end."); }, + detectVersion: () => { throw new Error("Tried to access back-end feature in front-end."); }, }; return (model: ClusterModel) => new Cluster(dependencies, model); diff --git a/src/renderer/event-listeners/add-element-event-listener.injectable.ts b/src/renderer/event-listeners/add-element-event-listener.injectable.ts new file mode 100644 index 0000000000..f2ba3aa0c0 --- /dev/null +++ b/src/renderer/event-listeners/add-element-event-listener.injectable.ts @@ -0,0 +1,28 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import type { Disposer } from "../utils"; + +export type AddElementEventListener = ( + ( + element: HTMLElement, + type: EventName, + listener: (this: HTMLElement, event: HTMLElementEventMap[EventName]) => any, + options?: boolean | AddEventListenerOptions, + ) => Disposer +); + +const addElementEventListener: AddElementEventListener = (element, type, listener, options) => { + element.addEventListener(type, listener, options); + + return () => element.removeEventListener(type, listener); +}; + +const addElementEventListenerInjectable = getInjectable({ + instantiate: () => addElementEventListener, + lifecycle: lifecycleEnum.singleton, +}); + +export default addElementEventListenerInjectable; diff --git a/src/renderer/event-listeners/add-window-event-listener.injectable.ts b/src/renderer/event-listeners/add-window-event-listener.injectable.ts new file mode 100644 index 0000000000..7c307c32ec --- /dev/null +++ b/src/renderer/event-listeners/add-window-event-listener.injectable.ts @@ -0,0 +1,21 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import type { Disposer } from "../utils"; + +export type AddWindowEventListener = (type: K, listener: (this: Window, ev: WindowEventMap[K]) => any, options?: boolean | AddEventListenerOptions) => Disposer; + +const addWindowEventListener: AddWindowEventListener = (type, listener, options) => { + window.addEventListener(type, listener, options); + + return () => window.removeEventListener(type, listener); +}; +const addWindowEventListenerInjectable = getInjectable({ + instantiate: () => addWindowEventListener, + lifecycle: lifecycleEnum.singleton, +}); + +export default addWindowEventListenerInjectable; diff --git a/src/renderer/frames/cluster-frame/cluster-frame.tsx b/src/renderer/frames/cluster-frame/cluster-frame.tsx index 8cc1277e6c..2732d11c5f 100755 --- a/src/renderer/frames/cluster-frame/cluster-frame.tsx +++ b/src/renderer/frames/cluster-frame/cluster-frame.tsx @@ -6,19 +6,11 @@ import React from "react"; import { observable, makeObservable } from "mobx"; import { disposeOnUnmount, observer } from "mobx-react"; import { Redirect, Route, Router, Switch } from "react-router"; -import { history } from "../../navigation"; -import { UserManagement } from "../../components/+user-management/user-management"; -import { ConfirmDialog } from "../../components/confirm-dialog"; -import { ClusterOverview } from "../../components/+cluster/cluster-overview"; +import { UserManagementLayout } from "../../components/+user-management/layout"; +import { ClusterOverview } from "../../components/+cluster/overview"; import { Events } from "../../components/+events/events"; -import { DeploymentScaleDialog } from "../../components/+workloads-deployments/deployment-scale-dialog"; -import { CronJobTriggerDialog } from "../../components/+workloads-cronjobs/cronjob-trigger-dialog"; -import { CustomResources } from "../../components/+custom-resources/custom-resources"; -import { isAllowedResource } from "../../../common/utils/allowed-resource"; import { ClusterPageRegistry, getExtensionPageUrl } from "../../../extensions/registries/page-registry"; import { ClusterPageMenuRegistration, ClusterPageMenuRegistry } from "../../../extensions/registries"; -import { StatefulSetScaleDialog } from "../../components/+workloads-statefulsets/statefulset-scale-dialog"; -import { ReplicaSetScaleDialog } from "../../components/+workloads-replicasets/replicaset-scale-dialog"; import { CommandContainer } from "../../components/command-palette/command-container"; import * as routes from "../../../common/routes"; import { TabLayout, TabLayoutRoute } from "../../components/layout/tab-layout"; @@ -28,33 +20,42 @@ import { Notifications } from "../../components/notifications"; import { KubeObjectDetails } from "../../components/kube-object-details"; import { KubeConfigDialog } from "../../components/kubeconfig-dialog"; import { Sidebar } from "../../components/layout/sidebar"; -import { Dock } from "../../components/dock"; -import { Apps } from "../../components/+apps"; import { Namespaces } from "../../components/+namespaces"; -import { Network } from "../../components/+network"; import { Nodes } from "../../components/+nodes"; -import { Workloads } from "../../components/+workloads"; -import { Config } from "../../components/+config"; -import { Storage } from "../../components/+storage"; import { watchHistoryState } from "../../remote-helpers/history-updater"; import { PortForwardDialog } from "../../port-forward"; import { DeleteClusterDialog } from "../../components/delete-cluster-dialog"; -import type { NamespaceStore } from "../../components/+namespaces/namespace-store/namespace.store"; import { withInjectables } from "@ogre-tools/injectable-react"; -import namespaceStoreInjectable - from "../../components/+namespaces/namespace-store/namespace-store.injectable"; import type { ClusterId } from "../../../common/cluster-types"; -import hostedClusterInjectable - from "../../../common/cluster-store/hosted-cluster/hosted-cluster.injectable"; import type { KubeObjectStore } from "../../../common/k8s-api/kube-object.store"; import type { KubeObject } from "../../../common/k8s-api/kube-object"; import type { Disposer } from "../../../common/utils"; import kubeWatchApiInjectable from "../../kube-watch-api/kube-watch-api.injectable"; - +import type { NamespaceStore } from "../../components/+namespaces/store"; +import type { KubeResource } from "../../../common/rbac"; +import isAllowedResourceInjectable from "../../utils/allowed-resource.injectable"; +import hostedClusterInjectable from "../../../common/cluster-store/hosted-cluster/hosted-cluster.injectable"; +import { CronJobTriggerDialog } from "../../components/+cronjobs/trigger-dialog"; +import { DeploymentScaleDialog } from "../../components/+deployments/scale-dialog"; +import namespaceStoreInjectable from "../../components/+namespaces/store.injectable"; +import { ReplicaSetScaleDialog } from "../../components/+replica-sets/scale-dialog"; +import { StatefulSetScaleDialog } from "../../components/+stateful-sets/scale-dialog"; +import { WorkloadsLayout } from "../../components/+workloads/layout"; +import { ConfigLayout } from "../../components/+config/layout"; +import { NetworkLayout } from "../../components/+network/layout"; +import { StorageLayout } from "../../components/+storage/layout"; +import { CustomResourcesLayout } from "../../components/+custom-resource/layout"; +import { HelmAppsLayout } from "../../components/+helm-apps/layout"; +import { Dock } from "../../components/dock/dock"; +import historyInjectable from "../../navigation/history.injectable"; +import type { History } from "history"; +import { ConfirmDialog } from "../../components/confirm-dialog"; interface Dependencies { - namespaceStore: NamespaceStore - hostedClusterId: ClusterId - subscribeStores: (stores: KubeObjectStore[]) => Disposer + history: History; + namespaceStore: NamespaceStore; + hostedClusterId: ClusterId; + subscribeStores: (stores: KubeObjectStore[]) => Disposer; + isAllowedResource: (resource: KubeResource | KubeResource[]) => boolean; } @observer @@ -76,7 +77,7 @@ class NonInjectedClusterFrame extends React.Component { ]); } - @observable startUrl = isAllowedResource(["events", "nodes", "pods"]) ? routes.clusterURL() : routes.workloadsURL(); + @observable startUrl = this.props.isAllowedResource(["events", "nodes", "pods"]) ? routes.clusterURL() : routes.workloadsURL(); getTabLayoutRoutes(menuItem: ClusterPageMenuRegistration) { const routes: TabLayoutRoute[] = []; @@ -135,21 +136,22 @@ class NonInjectedClusterFrame extends React.Component { render() { return ( - + } footer={}> + - - - - + + + + - - - + + + {this.renderExtensionTabLayoutRoutes()} {this.renderExtensionRoutes()} @@ -181,8 +183,10 @@ class NonInjectedClusterFrame extends React.Component { export const ClusterFrame = withInjectables(NonInjectedClusterFrame, { getProps: di => ({ + history: di.inject(historyInjectable), namespaceStore: di.inject(namespaceStoreInjectable), hostedClusterId: di.inject(hostedClusterInjectable).id, subscribeStores: di.inject(kubeWatchApiInjectable).subscribeStores, + isAllowedResource: di.inject(isAllowedResourceInjectable), }), }); diff --git a/src/renderer/frames/cluster-frame/init-cluster-frame/init-cluster-frame.injectable.ts b/src/renderer/frames/cluster-frame/init-cluster-frame/init-cluster-frame.injectable.ts index 7553877515..a5b50583ae 100644 --- a/src/renderer/frames/cluster-frame/init-cluster-frame/init-cluster-frame.injectable.ts +++ b/src/renderer/frames/cluster-frame/init-cluster-frame/init-cluster-frame.injectable.ts @@ -5,7 +5,7 @@ import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; import { initClusterFrame } from "./init-cluster-frame"; import extensionLoaderInjectable from "../../../../extensions/extension-loader/extension-loader.injectable"; -import catalogEntityRegistryInjectable from "../../../api/catalog-entity-registry/catalog-entity-registry.injectable"; +import catalogEntityRegistryInjectable from "../../../catalog/entity-registry.injectable"; import frameRoutingIdInjectable from "./frame-routing-id/frame-routing-id.injectable"; import hostedClusterInjectable from "../../../../common/cluster-store/hosted-cluster/hosted-cluster.injectable"; import appEventBusInjectable from "../../../../common/app-event-bus/app-event-bus.injectable"; diff --git a/src/renderer/frames/cluster-frame/init-cluster-frame/init-cluster-frame.ts b/src/renderer/frames/cluster-frame/init-cluster-frame/init-cluster-frame.ts index 22acc43be5..20aa6bb00b 100644 --- a/src/renderer/frames/cluster-frame/init-cluster-frame/init-cluster-frame.ts +++ b/src/renderer/frames/cluster-frame/init-cluster-frame/init-cluster-frame.ts @@ -3,7 +3,7 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ import type { Cluster } from "../../../../common/cluster/cluster"; -import type { CatalogEntityRegistry } from "../../../api/catalog-entity-registry"; +import type { CatalogEntityRegistry } from "../../../catalog/entity-registry"; import logger from "../../../../main/logger"; import { Terminal } from "../../../components/dock/terminal/terminal"; import { requestMain } from "../../../../common/ipc"; @@ -14,7 +14,7 @@ import type { AppEvent } from "../../../../common/app-event-bus/event-bus"; import type { CatalogEntity } from "../../../../common/catalog"; import { when } from "mobx"; import { unmountComponentAtNode } from "react-dom"; -import type { ClusterFrameContext } from "../../../cluster-frame-context/cluster-frame-context"; +import type { FrameContext } from "../../../cluster-frame-context/cluster-frame-context"; import { KubeObjectStore } from "../../../../common/k8s-api/kube-object.store"; interface Dependencies { @@ -25,7 +25,7 @@ interface Dependencies { emitEvent: (event: AppEvent) => void; // TODO: This dependency belongs to KubeObjectStore - clusterFrameContext: ClusterFrameContext + clusterFrameContext: FrameContext } const logPrefix = "[CLUSTER-FRAME]:"; diff --git a/src/renderer/frames/root-frame/init-root-frame/init-root-frame.injectable.ts b/src/renderer/frames/root-frame/init-root-frame/init-root-frame.injectable.ts index 6827bb3008..cdf268fb8e 100644 --- a/src/renderer/frames/root-frame/init-root-frame/init-root-frame.injectable.ts +++ b/src/renderer/frames/root-frame/init-root-frame/init-root-frame.injectable.ts @@ -6,30 +6,21 @@ import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; import { initRootFrame } from "./init-root-frame"; import extensionLoaderInjectable from "../../../../extensions/extension-loader/extension-loader.injectable"; import ipcRendererInjectable from "../../../app-paths/get-value-from-registered-channel/ipc-renderer/ipc-renderer.injectable"; -import bindProtocolAddRouteHandlersInjectable from "../../../protocol-handler/bind-protocol-add-route-handlers/bind-protocol-add-route-handlers.injectable"; +import addInternalProtocolRouteHandlersInjectable from "../../../protocol-handler/bind-protocol-add-route-handlers/bind-protocol-add-route-handlers.injectable"; import lensProtocolRouterRendererInjectable from "../../../protocol-handler/lens-protocol-router-renderer/lens-protocol-router-renderer.injectable"; -import catalogEntityRegistryInjectable from "../../../api/catalog-entity-registry/catalog-entity-registry.injectable"; +import catalogEntityRegistryInjectable from "../../../catalog/entity-registry.injectable"; +import { bind } from "../../../utils"; +import getClusterByIdInjectable from "../../../../common/cluster-store/get-cluster-by-id.injectable"; const initRootFrameInjectable = getInjectable({ - instantiate: (di) => { - const extensionLoader = di.inject(extensionLoaderInjectable); - - return initRootFrame({ - loadExtensions: extensionLoader.loadOnClusterManagerRenderer, - - ipcRenderer: di.inject(ipcRendererInjectable), - - bindProtocolAddRouteHandlers: di.inject( - bindProtocolAddRouteHandlersInjectable, - ), - - lensProtocolRouterRenderer: di.inject( - lensProtocolRouterRendererInjectable, - ), - - catalogEntityRegistry: di.inject(catalogEntityRegistryInjectable), - }); - }, + instantiate: (di) => bind(initRootFrame, null, { + loadExtensions: di.inject(extensionLoaderInjectable).loadOnClusterManagerRenderer, + ipcRenderer: di.inject(ipcRendererInjectable), + bindProtocolAddRouteHandlers: di.inject(addInternalProtocolRouteHandlersInjectable), + lensProtocolRouterRenderer: di.inject(lensProtocolRouterRendererInjectable), + catalogEntityRegistry: di.inject(catalogEntityRegistryInjectable), + getClusterById: di.inject(getClusterByIdInjectable), + }), lifecycle: lifecycleEnum.singleton, }); diff --git a/src/renderer/frames/root-frame/init-root-frame/init-root-frame.ts b/src/renderer/frames/root-frame/init-root-frame/init-root-frame.ts index c166847e2c..5ca8ec4ca9 100644 --- a/src/renderer/frames/root-frame/init-root-frame/init-root-frame.ts +++ b/src/renderer/frames/root-frame/init-root-frame/init-root-frame.ts @@ -8,7 +8,9 @@ import { registerIpcListeners } from "../../../ipc"; import logger from "../../../../common/logger"; import { unmountComponentAtNode } from "react-dom"; import type { ExtensionLoading } from "../../../../extensions/extension-loader"; -import type { CatalogEntityRegistry } from "../../../api/catalog-entity-registry"; +import type { CatalogEntityRegistry } from "../../../catalog/entity-registry"; +import { injectSystemCAs } from "../../../../common/system-ca"; +import type { Cluster } from "../../../../common/cluster/cluster"; interface Dependencies { loadExtensions: () => Promise; @@ -20,54 +22,49 @@ interface Dependencies { bindProtocolAddRouteHandlers: () => void; lensProtocolRouterRenderer: { init: () => void }; catalogEntityRegistry: CatalogEntityRegistry; + getClusterById: (clusterId: string) => Cluster | null; } const logPrefix = "[ROOT-FRAME]:"; -export const initRootFrame = - ({ - loadExtensions, - bindProtocolAddRouteHandlers, - lensProtocolRouterRenderer, - ipcRenderer, +export async function initRootFrame( + { loadExtensions, bindProtocolAddRouteHandlers, lensProtocolRouterRenderer, ipcRenderer, catalogEntityRegistry, getClusterById }: Dependencies, + rootElem: HTMLElement, +) { + injectSystemCAs(); + catalogEntityRegistry.init(); - catalogEntityRegistry, - }: Dependencies) => - async (rootElem: HTMLElement) => { - catalogEntityRegistry.init(); + try { + // maximum time to let bundled extensions finish loading + const timeout = delay(10000); - try { - // maximum time to let bundled extensions finish loading - const timeout = delay(10000); + const loadingExtensions = await loadExtensions(); - const loadingExtensions = await loadExtensions(); + const loadingBundledExtensions = loadingExtensions + .filter((e) => e.isBundled) + .map((e) => e.loaded); - const loadingBundledExtensions = loadingExtensions - .filter((e) => e.isBundled) - .map((e) => e.loaded); + const bundledExtensionsFinished = Promise.all(loadingBundledExtensions); - const bundledExtensionsFinished = Promise.all(loadingBundledExtensions); + await Promise.race([bundledExtensionsFinished, timeout]); + } finally { + ipcRenderer.send(BundledExtensionsLoaded); + } - await Promise.race([bundledExtensionsFinished, timeout]); - } finally { - ipcRenderer.send(BundledExtensionsLoaded); - } + lensProtocolRouterRenderer.init(); - lensProtocolRouterRenderer.init(); + bindProtocolAddRouteHandlers(); - bindProtocolAddRouteHandlers(); + window.addEventListener("offline", () => broadcastMessage("network:offline"), + ); - window.addEventListener("offline", () => - broadcastMessage("network:offline"), - ); + window.addEventListener("online", () => broadcastMessage("network:online")); - window.addEventListener("online", () => broadcastMessage("network:online")); + registerIpcListeners({ getClusterById }); - registerIpcListeners(); + window.addEventListener("beforeunload", () => { + logger.info(`${logPrefix} Unload app`); - window.addEventListener("beforeunload", () => { - logger.info(`${logPrefix} Unload app`); - - unmountComponentAtNode(rootElem); - }); - }; + unmountComponentAtNode(rootElem); + }); +} diff --git a/src/renderer/frames/root-frame/root-frame.tsx b/src/renderer/frames/root-frame/root-frame.tsx index bc148f3a70..3b97f03e5d 100644 --- a/src/renderer/frames/root-frame/root-frame.tsx +++ b/src/renderer/frames/root-frame/root-frame.tsx @@ -3,11 +3,8 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import { injectSystemCAs } from "../../../common/system-ca"; -import React from "react"; +import React, { useEffect } from "react"; import { Route, Router, Switch } from "react-router"; -import { observer } from "mobx-react"; -import { history } from "../../navigation"; import { ClusterManager } from "../../components/cluster-manager"; import { ErrorBoundary } from "../../components/error-boundary"; import { Notifications } from "../../components/notifications"; @@ -15,36 +12,36 @@ import { ConfirmDialog } from "../../components/confirm-dialog"; import { CommandContainer } from "../../components/command-palette/command-container"; import { ipcRenderer } from "electron"; import { IpcRendererNavigationEvents } from "../../navigation/events"; -import { ClusterFrameHandler } from "../../components/cluster-manager/lens-views"; +import { observer } from "mobx-react"; +import historyInjectable from "../../navigation/history.injectable"; +import { withInjectables } from "@ogre-tools/injectable-react"; +import type { History } from "history"; -injectSystemCAs(); - -@observer -export class RootFrame extends React.Component { - static displayName = "RootFrame"; - - constructor(props: {}) { - super(props); - - ClusterFrameHandler.createInstance(); - } - - componentDidMount() { - ipcRenderer.send(IpcRendererNavigationEvents.LOADED); - } - - render() { - return ( - - - - - - - - - - - ); - } +interface Dependencies { + history: History } + +export const NonInjectedRootFrame = observer(({ history }: Dependencies) => { + useEffect(() => { + ipcRenderer.send(IpcRendererNavigationEvents.LOADED); + }, []); + + return ( + + + + + + + + + + + ); +}); + +export const RootFrame = withInjectables(NonInjectedRootFrame, { + getProps: (di) => ({ + history: di.inject(historyInjectable), + }), +}); diff --git a/src/renderer/getDi.tsx b/src/renderer/getDi.tsx index 6c3a9a82d7..b52e84718a 100644 --- a/src/renderer/getDi.tsx +++ b/src/renderer/getDi.tsx @@ -6,23 +6,14 @@ import { createContainer } from "@ogre-tools/injectable"; import { setLegacyGlobalDiForExtensionApi } from "../extensions/as-legacy-globals-for-extension-api/legacy-global-di-for-extension-api"; -export const getDi = () => { +export function getDi() { const di = createContainer( - getRequireContextForRendererCode, - getRequireContextForCommonExtensionCode, - getRequireContextForCommonCode, + () => require.context("./", true, /\.injectable\.(ts|tsx)$/), + () => require.context("../common", true, /\.injectable\.(ts|tsx)$/), + () => require.context("../extensions", true, /\.injectable\.(ts|tsx)$/), ); setLegacyGlobalDiForExtensionApi(di); return di; -}; - -const getRequireContextForRendererCode = () => - require.context("./", true, /\.injectable\.(ts|tsx)$/); - -const getRequireContextForCommonCode = () => - require.context("../common", true, /\.injectable\.(ts|tsx)$/); - -const getRequireContextForCommonExtensionCode = () => - require.context("../extensions", true, /\.injectable\.(ts|tsx)$/); +} diff --git a/src/renderer/initializers/catalog-category-registry.tsx b/src/renderer/initializers/catalog-category-registry.tsx deleted file mode 100644 index 577a3ecaed..0000000000 --- a/src/renderer/initializers/catalog-category-registry.tsx +++ /dev/null @@ -1,90 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import React from "react"; -import { kubernetesClusterCategory } from "../../common/catalog-entities"; -import { addClusterURL, kubernetesURL } from "../../common/routes"; -import { multiSet } from "../utils"; -import { UserStore } from "../../common/user-store"; -import { getAllEntries } from "../components/+preferences/kubeconfig-syncs"; -import { runInAction } from "mobx"; -import { isLinux, isWindows } from "../../common/vars"; -import { PathPicker } from "../components/path-picker"; -import { Notifications } from "../components/notifications"; -import { Link } from "react-router-dom"; - -async function addSyncEntries(filePaths: string[]) { - const entries = await getAllEntries(filePaths); - - runInAction(() => { - multiSet(UserStore.getInstance().syncKubeconfigEntries, entries); - }); - - Notifications.ok( -
    -

    Selected items has been added to Kubeconfig Sync.


    -

    Check the Preferences{" "} - to see full list.

    -
    , - ); -} - -export function initCatalogCategoryRegistryEntries() { - kubernetesClusterCategory.on("catalogAddMenu", ctx => { - ctx.menuItems.push( - { - icon: "text_snippet", - title: "Add from kubeconfig", - onClick: () => ctx.navigate(addClusterURL()), - }, - ); - - if (isWindows || isLinux) { - ctx.menuItems.push( - { - icon: "create_new_folder", - title: "Sync kubeconfig folder(s)", - defaultAction: true, - onClick: async () => { - await PathPicker.pick({ - label: "Sync folder(s)", - buttonLabel: "Sync", - properties: ["showHiddenFiles", "multiSelections", "openDirectory"], - onPick: addSyncEntries, - }); - }, - }, - { - icon: "note_add", - title: "Sync kubeconfig file(s)", - onClick: async () => { - await PathPicker.pick({ - label: "Sync file(s)", - buttonLabel: "Sync", - properties: ["showHiddenFiles", "multiSelections", "openFile"], - onPick: addSyncEntries, - }); - }, - }, - ); - } else { - ctx.menuItems.push( - { - icon: "create_new_folder", - title: "Sync kubeconfig(s)", - defaultAction: true, - onClick: async () => { - await PathPicker.pick({ - label: "Sync file(s)", - buttonLabel: "Sync", - properties: ["showHiddenFiles", "multiSelections", "openFile", "openDirectory"], - onPick: addSyncEntries, - }); - }, - }, - ); - } - }); -} diff --git a/src/renderer/initializers/catalog-entity-detail-registry.tsx b/src/renderer/initializers/catalog-entity-detail-registry.tsx deleted file mode 100644 index f04f4e7aa9..0000000000 --- a/src/renderer/initializers/catalog-entity-detail-registry.tsx +++ /dev/null @@ -1,48 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import React from "react"; -import { KubernetesCluster, WebLink } from "../../common/catalog-entities"; -import { CatalogEntityDetailRegistry, CatalogEntityDetailsProps } from "../../extensions/registries"; -import { DrawerItem, DrawerTitle } from "../components/drawer"; - -export function initCatalogEntityDetailRegistry() { - CatalogEntityDetailRegistry.getInstance() - .add([ - { - apiVersions: [KubernetesCluster.apiVersion], - kind: KubernetesCluster.kind, - components: { - Details: ({ entity }: CatalogEntityDetailsProps) => ( - <> - -
    - - {entity.metadata.distro || "unknown"} - - - {entity.metadata.kubeVersion || "unknown"} - -
    - - ), - }, - }, - { - apiVersions: [WebLink.apiVersion], - kind: WebLink.kind, - components: { - Details: ({ entity }: CatalogEntityDetailsProps) => ( - <> - - - {entity.spec.url} - - - ), - }, - }, - ]); -} diff --git a/src/renderer/initializers/catalog.tsx b/src/renderer/initializers/catalog.tsx deleted file mode 100644 index 3c4b0e87c4..0000000000 --- a/src/renderer/initializers/catalog.tsx +++ /dev/null @@ -1,57 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import React from "react"; -import fs from "fs"; -import "../../common/catalog-entities/kubernetes-cluster"; -import { ClusterStore } from "../../common/cluster-store/cluster-store"; -import { catalogCategoryRegistry } from "../api/catalog-category-registry"; -import { WeblinkAddCommand } from "../components/catalog-entities/weblink-add-command"; -import { loadConfigFromString } from "../../common/kube-helpers"; -import { DeleteClusterDialog } from "../components/delete-cluster-dialog"; - -async function onClusterDelete(clusterId: string) { - const cluster = ClusterStore.getInstance().getById(clusterId); - - if (!cluster) { - return console.warn("[KUBERNETES-CLUSTER]: cannot delete cluster, does not exist in store", { clusterId }); - } - - const { config, error } = loadConfigFromString(await fs.promises.readFile(cluster.kubeConfigPath, "utf-8")); - - if (error) { - throw error; - } - - DeleteClusterDialog.open({ cluster, config }); -} - -interface Dependencies { - openCommandDialog: (component: React.ReactElement) => void; -} - -export function initCatalog({ openCommandDialog }: Dependencies) { - catalogCategoryRegistry - .getForGroupKind("entity.k8slens.dev", "WebLink") - .on("catalogAddMenu", ctx => { - ctx.menuItems.push({ - title: "Add web link", - icon: "public", - onClick: () => openCommandDialog(), - }); - }); - - catalogCategoryRegistry - .getForGroupKind("entity.k8slens.dev", "KubernetesCluster") - .on("contextMenuOpen", (entity, context) => { - if (entity.metadata?.source == "local") { - context.menuItems.push({ - title: "Delete", - icon: "delete", - onClick: () => onClusterDelete(entity.metadata.uid), - }); - } - }); -} diff --git a/src/renderer/initializers/entity-settings-registry.ts b/src/renderer/initializers/entity-settings-registry.ts index 867368fafa..7ad2fdfab9 100644 --- a/src/renderer/initializers/entity-settings-registry.ts +++ b/src/renderer/initializers/entity-settings-registry.ts @@ -4,7 +4,12 @@ */ import { EntitySettingRegistry } from "../../extensions/registries"; -import * as clusterSettings from "../components/cluster-settings"; +import { ClusterSettingsGeneral } from "../components/cluster-settings/general"; +import { ClusterSettingsMetrics } from "../components/cluster-settings/metrics"; +import { ClusterSettingsNamespaces } from "../components/cluster-settings/namespaces"; +import { ClusterSettingsNodeShell } from "../components/cluster-settings/node-shell"; +import { ClusterSettingsProxy } from "../components/cluster-settings/proxy"; +import { ClusterSettingsTerminal } from "../components/cluster-settings/terminal"; export function initEntitySettingsRegistry() { EntitySettingRegistry.getInstance() @@ -16,7 +21,7 @@ export function initEntitySettingsRegistry() { title: "General", group: "Settings", components: { - View: clusterSettings.GeneralSettings, + View: ClusterSettingsGeneral, }, }, { @@ -25,7 +30,7 @@ export function initEntitySettingsRegistry() { title: "Proxy", group: "Settings", components: { - View: clusterSettings.ProxySettings, + View: ClusterSettingsProxy, }, }, { @@ -34,7 +39,7 @@ export function initEntitySettingsRegistry() { title: "Terminal", group: "Settings", components: { - View: clusterSettings.TerminalSettings, + View: ClusterSettingsTerminal, }, }, { @@ -43,7 +48,7 @@ export function initEntitySettingsRegistry() { title: "Namespaces", group: "Settings", components: { - View: clusterSettings.NamespacesSettings, + View: ClusterSettingsNamespaces, }, }, { @@ -52,7 +57,7 @@ export function initEntitySettingsRegistry() { title: "Metrics", group: "Settings", components: { - View: clusterSettings.MetricsSettings, + View: ClusterSettingsMetrics, }, }, { @@ -61,7 +66,7 @@ export function initEntitySettingsRegistry() { title: "Node Shell", group: "Settings", components: { - View: clusterSettings.NodeShellSettings, + View: ClusterSettingsNodeShell, }, }, ]); diff --git a/src/renderer/initializers/index.ts b/src/renderer/initializers/index.ts index 3965d45fcf..f351b6dead 100644 --- a/src/renderer/initializers/index.ts +++ b/src/renderer/initializers/index.ts @@ -3,12 +3,8 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -export * from "./catalog-entity-detail-registry"; -export * from "./catalog"; export * from "./entity-settings-registry"; export * from "./ipc"; -export * from "./kube-object-detail-registry"; export * from "./kube-object-menu-registry"; export * from "./registries"; export * from "./workloads-overview-detail-registry"; -export * from "./catalog-category-registry"; diff --git a/src/renderer/initializers/kube-object-detail-registry.tsx b/src/renderer/initializers/kube-object-detail-registry.tsx deleted file mode 100644 index eef56b16b5..0000000000 --- a/src/renderer/initializers/kube-object-detail-registry.tsx +++ /dev/null @@ -1,433 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import React from "react"; -import { KubeObjectDetailRegistry } from "../api/kube-object-detail-registry"; -import { HpaDetails, HpaDetailsProps } from "../components/+config-autoscalers"; -import { LimitRangeDetails } from "../components/+config-limit-ranges"; -import { ConfigMapDetails } from "../components/+config-maps"; -import { PodDisruptionBudgetDetails } from "../components/+config-pod-disruption-budgets"; -import { ResourceQuotaDetails } from "../components/+config-resource-quotas"; -import { SecretDetails } from "../components/+config-secrets"; -import { CRDDetails } from "../components/+custom-resources"; -import { EventDetails } from "../components/+events"; -import { KubeEventDetails } from "../components/+events/kube-event-details"; -import { NamespaceDetails } from "../components/+namespaces"; -import { EndpointDetails } from "../components/+network-endpoints"; -import { IngressDetails } from "../components/+network-ingresses"; -import { NetworkPolicyDetails } from "../components/+network-policies"; -import { ServiceDetails } from "../components/+network-services"; -import { NodeDetails } from "../components/+nodes"; -import { PodSecurityPolicyDetails } from "../components/+pod-security-policies"; -import { StorageClassDetails } from "../components/+storage-classes"; -import { PersistentVolumeClaimDetails } from "../components/+storage-volume-claims"; -import { PersistentVolumeDetails } from "../components/+storage-volumes"; -import { ClusterRoleDetails } from "../components/+user-management/+cluster-roles"; -import { ClusterRoleBindingDetails } from "../components/+user-management/+cluster-role-bindings"; -import { RoleDetails } from "../components/+user-management/+roles"; -import { RoleBindingDetails } from "../components/+user-management/+role-bindings"; -import { ServiceAccountsDetails } from "../components/+user-management/+service-accounts"; -import { CronJobDetails } from "../components/+workloads-cronjobs"; -import { DaemonSetDetails } from "../components/+workloads-daemonsets"; -import { DeploymentDetails } from "../components/+workloads-deployments"; -import { JobDetails } from "../components/+workloads-jobs"; -import { PodDetails } from "../components/+workloads-pods"; -import { ReplicaSetDetails } from "../components/+workloads-replicasets"; -import { StatefulSetDetails } from "../components/+workloads-statefulsets"; -import type { KubeObjectDetailsProps } from "../components/kube-object-details"; - -export function initKubeObjectDetailRegistry() { - KubeObjectDetailRegistry.getInstance() - .add([ - { - kind: "HorizontalPodAutoscaler", - apiVersions: ["autoscaling/v2beta1"], - components: { - // Note: this line is left in the long form as a validation that this usecase is valid - Details: (props: HpaDetailsProps) => , - }, - }, - { - kind: "HorizontalPodAutoscaler", - apiVersions: ["autoscaling/v2beta1"], - priority: 5, - components: { - // Note: this line is left in the long form as a validation that this usecase is valid - Details: (props: KubeObjectDetailsProps) => , - }, - }, - { - kind: "LimitRange", - apiVersions: ["v1"], - components: { - Details: LimitRangeDetails, - }, - }, - { - kind: "ConfigMap", - apiVersions: ["v1"], - components: { - Details: ConfigMapDetails, - }, - }, - { - kind: "ConfigMap", - apiVersions: ["v1"], - priority: 5, - components: { - Details: KubeEventDetails, - }, - }, - { - kind: "PodDisruptionBudget", - apiVersions: ["policy/v1beta1"], - components: { - Details: PodDisruptionBudgetDetails, - }, - }, - { - kind: "ResourceQuota", - apiVersions: ["v1"], - components: { - Details: ResourceQuotaDetails, - }, - }, - { - kind: "Secret", - apiVersions: ["v1"], - components: { - Details: SecretDetails, - }, - }, - { - kind: "CustomResourceDefinition", - apiVersions: ["apiextensions.k8s.io/v1", "apiextensions.k8s.io/v1beta1"], - components: { - Details: CRDDetails, - }, - }, - { - kind: "Event", - apiVersions: ["v1"], - components: { - Details: EventDetails, - }, - }, - { - kind: "Namespace", - apiVersions: ["v1"], - components: { - Details: NamespaceDetails, - }, - }, - { - kind: "Endpoints", - apiVersions: ["v1"], - components: { - Details: EndpointDetails, - }, - }, - { - kind: "Endpoints", - apiVersions: ["v1"], - priority: 5, - components: { - Details: KubeEventDetails, - }, - }, - { - kind: "Ingress", - apiVersions: ["networking.k8s.io/v1", "extensions/v1beta1"], - components: { - Details: IngressDetails, - }, - }, - { - kind: "Ingress", - apiVersions: ["networking.k8s.io/v1", "extensions/v1beta1"], - priority: 5, - components: { - Details: KubeEventDetails, - }, - }, - { - kind: "NetworkPolicy", - apiVersions: ["networking.k8s.io/v1"], - components: { - Details: NetworkPolicyDetails, - }, - }, - { - kind: "NetworkPolicy", - apiVersions: ["networking.k8s.io/v1"], - priority: 5, - components: { - Details: KubeEventDetails, - }, - }, - { - kind: "Service", - apiVersions: ["v1"], - components: { - Details: ServiceDetails, - }, - }, - { - kind: "Service", - apiVersions: ["v1"], - priority: 5, - components: { - Details: KubeEventDetails, - }, - }, - { - kind: "Node", - apiVersions: ["v1"], - components: { - Details: NodeDetails, - }, - }, - { - kind: "Node", - apiVersions: ["v1"], - priority: 5, - components: { - Details: KubeEventDetails, - }, - }, - { - kind: "PodSecurityPolicy", - apiVersions: ["policy/v1beta1"], - components: { - Details: PodSecurityPolicyDetails, - }, - }, - { - kind: "StorageClass", - apiVersions: ["storage.k8s.io/v1"], - components: { - Details: StorageClassDetails, - }, - }, - { - kind: "StorageClass", - apiVersions: ["storage.k8s.io/v1"], - priority: 5, - components: { - Details: KubeEventDetails, - }, - }, - { - kind: "PersistentVolumeClaim", - apiVersions: ["v1"], - components: { - Details: PersistentVolumeClaimDetails, - }, - }, - { - kind: "PersistentVolumeClaim", - apiVersions: ["v1"], - priority: 5, - components: { - Details: KubeEventDetails, - }, - }, - { - kind: "PersistentVolume", - apiVersions: ["v1"], - components: { - Details: PersistentVolumeDetails, - }, - }, - { - kind: "PersistentVolume", - apiVersions: ["v1"], - priority: 5, - components: { - Details: KubeEventDetails, - }, - }, - { - kind: "Role", - apiVersions: ["rbac.authorization.k8s.io/v1"], - components: { - Details: RoleDetails, - }, - }, - { - kind: "Role", - apiVersions: ["rbac.authorization.k8s.io/v1"], - priority: 5, - components: { - Details: KubeEventDetails, - }, - }, - { - kind: "ClusterRole", - apiVersions: ["rbac.authorization.k8s.io/v1"], - components: { - Details: ClusterRoleDetails, - }, - }, - { - kind: "ClusterRole", - apiVersions: ["rbac.authorization.k8s.io/v1"], - priority: 5, - components: { - Details: KubeEventDetails, - }, - }, - { - kind: "RoleBinding", - apiVersions: ["rbac.authorization.k8s.io/v1"], - components: { - Details: RoleBindingDetails, - }, - }, - { - kind: "RoleBinding", - apiVersions: ["rbac.authorization.k8s.io/v1"], - priority: 5, - components: { - Details: KubeEventDetails, - }, - }, - { - kind: "ClusterRoleBinding", - apiVersions: ["rbac.authorization.k8s.io/v1"], - components: { - Details: ClusterRoleBindingDetails, - }, - }, - { - kind: "ClusterRoleBinding", - apiVersions: ["rbac.authorization.k8s.io/v1"], - priority: 5, - components: { - Details: KubeEventDetails, - }, - }, - { - kind: "ServiceAccount", - apiVersions: ["v1"], - components: { - Details: ServiceAccountsDetails, - }, - }, - { - kind: "ServiceAccount", - apiVersions: ["v1"], - priority: 5, - components: { - Details: KubeEventDetails, - }, - }, - { - kind: "CronJob", - apiVersions: ["batch/v1beta1"], - components: { - Details: CronJobDetails, - }, - }, - { - kind: "CronJob", - apiVersions: ["batch/v1beta1"], - priority: 5, - components: { - Details: KubeEventDetails, - }, - }, - { - kind: "DaemonSet", - apiVersions: ["apps/v1"], - components: { - Details: DaemonSetDetails, - }, - }, - { - kind: "DaemonSet", - apiVersions: ["apps/v1"], - priority: 5, - components: { - Details: KubeEventDetails, - }, - }, - { - kind: "Deployment", - apiVersions: ["apps/v1"], - components: { - Details: DeploymentDetails, - }, - }, - { - kind: "Deployment", - apiVersions: ["apps/v1"], - priority: 5, - components: { - Details: KubeEventDetails, - }, - }, - { - kind: "Job", - apiVersions: ["batch/v1"], - components: { - Details: JobDetails, - }, - }, - { - kind: "Job", - apiVersions: ["batch/v1"], - priority: 5, - components: { - Details: KubeEventDetails, - }, - }, - { - kind: "Pod", - apiVersions: ["v1"], - components: { - Details: PodDetails, - }, - }, - { - kind: "Pod", - apiVersions: ["v1"], - priority: 5, - components: { - Details: KubeEventDetails, - }, - }, - { - kind: "ReplicaSet", - apiVersions: ["apps/v1"], - components: { - Details: ReplicaSetDetails, - }, - }, - { - kind: "ReplicaSet", - apiVersions: ["apps/v1"], - priority: 5, - components: { - Details: KubeEventDetails, - }, - }, - { - kind: "StatefulSet", - apiVersions: ["apps/v1"], - components: { - Details: StatefulSetDetails, - }, - }, - { - kind: "StatefulSet", - apiVersions: ["apps/v1"], - priority: 5, - components: { - Details: KubeEventDetails, - }, - }, - ]); -} diff --git a/src/renderer/initializers/kube-object-menu-registry.ts b/src/renderer/initializers/kube-object-menu-registry.ts index d25ef0aaf7..e5aab5a82a 100644 --- a/src/renderer/initializers/kube-object-menu-registry.ts +++ b/src/renderer/initializers/kube-object-menu-registry.ts @@ -4,11 +4,11 @@ */ import { KubeObjectMenuRegistry } from "../../extensions/registries"; -import { ServiceAccountMenu } from "../components/+user-management/+service-accounts"; -import { CronJobMenu } from "../components/+workloads-cronjobs"; -import { DeploymentMenu } from "../components/+workloads-deployments"; -import { ReplicaSetMenu } from "../components/+workloads-replicasets"; -import { StatefulSetMenu } from "../components/+workloads-statefulsets"; +import { ServiceAccountMenu } from "../components/+service-accounts/item-menu"; +import { CronJobMenu } from "../components/+cronjobs/item-menu"; +import { DeploymentMenu } from "../components/+deployments/item-menu"; +import { ReplicaSetMenu } from "../components/+replica-sets/item-menu"; +import { StatefulSetMenu } from "../components/+stateful-sets/item-menu"; export function initKubeObjectMenuRegistry() { KubeObjectMenuRegistry.getInstance() diff --git a/src/renderer/initializers/registries.ts b/src/renderer/initializers/registries.ts index 93caeee51e..da5c37eb02 100644 --- a/src/renderer/initializers/registries.ts +++ b/src/renderer/initializers/registries.ts @@ -6,14 +6,11 @@ import * as registries from "../../extensions/registries"; export function initRegistries() { - registries.CatalogEntityDetailRegistry.createInstance(); registries.ClusterPageMenuRegistry.createInstance(); registries.ClusterPageRegistry.createInstance(); registries.EntitySettingRegistry.createInstance(); registries.GlobalPageRegistry.createInstance(); - registries.KubeObjectDetailRegistry.createInstance(); registries.KubeObjectMenuRegistry.createInstance(); - registries.KubeObjectStatusRegistry.createInstance(); registries.StatusBarRegistry.createInstance(); registries.WorkloadsOverviewDetailRegistry.createInstance(); } diff --git a/src/renderer/initializers/workloads-overview-detail-registry.tsx b/src/renderer/initializers/workloads-overview-detail-registry.tsx index 9ab0e17ccb..8767413307 100644 --- a/src/renderer/initializers/workloads-overview-detail-registry.tsx +++ b/src/renderer/initializers/workloads-overview-detail-registry.tsx @@ -4,12 +4,12 @@ */ import React from "react"; -import { isAllowedResource } from "../../common/utils/allowed-resource"; import { WorkloadsOverviewDetailRegistry } from "../../extensions/registries"; import { Events } from "../components/+events"; -import { OverviewStatuses } from "../components/+workloads-overview/overview-statuses"; +import { OverviewStatuses } from "../components/+workloads-overview/statuses"; +import type { KubeResource } from "../../common/rbac"; -export function initWorkloadsOverviewDetailRegistry() { +export function initWorkloadsOverviewDetailRegistry(isAllowedResource: (resource: KubeResource) => boolean) { WorkloadsOverviewDetailRegistry.getInstance() .add([ { diff --git a/src/renderer/themes/lens-dark.json b/src/renderer/internal-themes/lens-dark.json similarity index 100% rename from src/renderer/themes/lens-dark.json rename to src/renderer/internal-themes/lens-dark.json diff --git a/src/renderer/themes/lens-light.json b/src/renderer/internal-themes/lens-light.json similarity index 100% rename from src/renderer/themes/lens-light.json rename to src/renderer/internal-themes/lens-light.json diff --git a/src/renderer/themes/theme-vars.css b/src/renderer/internal-themes/theme-vars.css similarity index 100% rename from src/renderer/themes/theme-vars.css rename to src/renderer/internal-themes/theme-vars.css diff --git a/src/renderer/ipc/index.tsx b/src/renderer/ipc/index.tsx index 3a4272c96f..33b5e917a1 100644 --- a/src/renderer/ipc/index.tsx +++ b/src/renderer/ipc/index.tsx @@ -9,10 +9,11 @@ import { areArgsUpdateAvailableFromMain, UpdateAvailableChannel, onCorrect, Upda import { Notifications, notificationsStore } from "../components/notifications"; import { Button } from "../components/button"; import { isMac } from "../../common/vars"; -import { ClusterStore } from "../../common/cluster-store/cluster-store"; import { navigate } from "../navigation"; import { entitySettingsURL } from "../../common/routes"; -import { defaultHotbarCells } from "../../common/hotbar-types"; +import { defaultHotbarCells } from "../../common/hotbar-store/hotbar-types"; +import type { Cluster } from "../../common/cluster/cluster"; +import { bind } from "../utils"; function sendToBackchannel(backchannel: string, notificationId: string, data: BackchannelArg): void { notificationsStore.remove(notificationId); @@ -64,7 +65,11 @@ function UpdateAvailableHandler(event: IpcRendererEvent, ...[backchannel, update const notificationLastDisplayedAt = new Map(); const intervalBetweenNotifications = 1000 * 60; // 60s -function ListNamespacesForbiddenHandler(event: IpcRendererEvent, ...[clusterId]: ListNamespaceForbiddenArgs): void { +interface Dependencies { + getClusterById: (clusterId: string) => Cluster | null; +} + +function ListNamespacesForbiddenHandler({ getClusterById }: Dependencies, event: IpcRendererEvent, ...[clusterId]: ListNamespaceForbiddenArgs): void { const lastDisplayedAt = notificationLastDisplayedAt.get(clusterId); const now = Date.now(); @@ -87,7 +92,7 @@ function ListNamespacesForbiddenHandler(event: IpcRendererEvent, ...[clusterId]:
    Add Accessible Namespaces

    - Cluster {ClusterStore.getInstance().getById(clusterId).name} does not have permissions to list namespaces.{" "} + Cluster {getClusterById(clusterId).name} does not have permissions to list namespaces.{" "} Please add the namespaces you have access to.

    @@ -118,7 +123,7 @@ function HotbarTooManyItemsHandler(): void { Notifications.error(`Cannot have more than ${defaultHotbarCells} items pinned to a hotbar`); } -export function registerIpcListeners() { +export function registerIpcListeners({ getClusterById }: Dependencies) { onCorrect({ source: ipcRenderer, channel: UpdateAvailableChannel, @@ -128,7 +133,9 @@ export function registerIpcListeners() { onCorrect({ source: ipcRenderer, channel: ClusterListNamespaceForbiddenChannel, - listener: ListNamespacesForbiddenHandler, + listener: bind(ListNamespacesForbiddenHandler, null, { + getClusterById, + }), verifier: isListNamespaceForbiddenArgs, }); onCorrect({ diff --git a/src/renderer/kube-watch-api/kube-watch-api.ts b/src/renderer/kube-watch-api/kube-watch-api.ts index 4b9a802723..f7d1001c90 100644 --- a/src/renderer/kube-watch-api/kube-watch-api.ts +++ b/src/renderer/kube-watch-api/kube-watch-api.ts @@ -7,7 +7,7 @@ import { disposer, Disposer, noop } from "../../common/utils"; import type { KubeObject } from "../../common/k8s-api/kube-object"; import AbortController from "abort-controller"; import { once } from "lodash"; -import type { ClusterFrameContext } from "../cluster-frame-context/cluster-frame-context"; +import type { FrameContext } from "../cluster-frame-context/cluster-frame-context"; import type { KubeObjectStore } from "../../common/k8s-api/kube-object.store"; import logger from "../../common/logger"; @@ -81,7 +81,7 @@ export interface KubeWatchSubscribeStoreOptions { } interface Dependencies { - clusterFrameContext: ClusterFrameContext + clusterFrameContext: FrameContext; } export class KubeWatchApi { diff --git a/src/renderer/components/+namespaces/add-namespace-dialog-model/add-namespace-dialog-model.injectable.ts b/src/renderer/kube-watch-api/subscribe-stores.injectable.ts similarity index 53% rename from src/renderer/components/+namespaces/add-namespace-dialog-model/add-namespace-dialog-model.injectable.ts rename to src/renderer/kube-watch-api/subscribe-stores.injectable.ts index 42cc213b95..35eb3a722e 100644 --- a/src/renderer/components/+namespaces/add-namespace-dialog-model/add-namespace-dialog-model.injectable.ts +++ b/src/renderer/kube-watch-api/subscribe-stores.injectable.ts @@ -3,11 +3,11 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; -import { AddNamespaceDialogModel } from "./add-namespace-dialog-model"; +import kubeWatchApiInjectable from "./kube-watch-api.injectable"; -const addNamespaceDialogModelInjectable = getInjectable({ - instantiate: () => new AddNamespaceDialogModel(), +const subscribeStoresInjectable = getInjectable({ + instantiate: (di) => di.inject(kubeWatchApiInjectable).subscribeStores, lifecycle: lifecycleEnum.singleton, }); -export default addNamespaceDialogModelInjectable; +export default subscribeStoresInjectable; diff --git a/src/renderer/migrations/cluster-store-migrations.injectable.ts b/src/renderer/migrations/cluster-store-migrations.injectable.ts new file mode 100644 index 0000000000..71af577a5e --- /dev/null +++ b/src/renderer/migrations/cluster-store-migrations.injectable.ts @@ -0,0 +1,14 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { clusterStoreMigrationsInjectionToken } from "../../common/cluster-store/migrations-injection-token"; + +const clusterStoreMigrationsInjectable = getInjectable({ + instantiate: () => undefined, + injectionToken: clusterStoreMigrationsInjectionToken, + lifecycle: lifecycleEnum.singleton, +}); + +export default clusterStoreMigrationsInjectable; diff --git a/src/renderer/migrations/hotbar-store-migrations.injectable.ts b/src/renderer/migrations/hotbar-store-migrations.injectable.ts new file mode 100644 index 0000000000..8b9c21b6a5 --- /dev/null +++ b/src/renderer/migrations/hotbar-store-migrations.injectable.ts @@ -0,0 +1,14 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { hotbarStoreMigrationsInjectionToken } from "../../common/hotbar-store/migrations-injectable-token"; + +const hotbarStoreMigrationsInjectable = getInjectable({ + instantiate: () => undefined, + injectionToken: hotbarStoreMigrationsInjectionToken, + lifecycle: lifecycleEnum.singleton, +}); + +export default hotbarStoreMigrationsInjectable; diff --git a/src/renderer/migrations/user-store-file-name-migration.injectable.ts b/src/renderer/migrations/user-store-file-name-migration.injectable.ts new file mode 100644 index 0000000000..25f9e791bb --- /dev/null +++ b/src/renderer/migrations/user-store-file-name-migration.injectable.ts @@ -0,0 +1,15 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { userStoreFileNameMigrationInjectionToken } from "../../common/user-preferences/file-name-migration-injection-token"; +import { noop } from "../utils"; + +const userStoreFileNameMigratiionInjectable = getInjectable({ + instantiate: () => noop, + injectionToken: userStoreFileNameMigrationInjectionToken, + lifecycle: lifecycleEnum.singleton, +}); + +export default userStoreFileNameMigratiionInjectable; diff --git a/src/renderer/migrations/user-store-migrations.injectable.ts b/src/renderer/migrations/user-store-migrations.injectable.ts new file mode 100644 index 0000000000..45b4aca93e --- /dev/null +++ b/src/renderer/migrations/user-store-migrations.injectable.ts @@ -0,0 +1,14 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { userPreferencesStoreMigrationsInjectionToken } from "../../common/user-preferences/migrations-injection-token"; + +const userPreferencesStoreFileNameMigrationInjectable = getInjectable({ + instantiate: () => undefined, + injectionToken: userPreferencesStoreMigrationsInjectionToken, + lifecycle: lifecycleEnum.singleton, +}); + +export default userPreferencesStoreFileNameMigrationInjectable; diff --git a/src/renderer/migrations/weblinks-store-migrations.injectable.ts b/src/renderer/migrations/weblinks-store-migrations.injectable.ts new file mode 100644 index 0000000000..61b44bd75d --- /dev/null +++ b/src/renderer/migrations/weblinks-store-migrations.injectable.ts @@ -0,0 +1,14 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { weblinksStoreMigrationsInjectionToken } from "../../common/weblinks/migrations-injection-token"; + +const weblinksStoreMigrationsInjectable = getInjectable({ + instantiate: () => undefined, + injectionToken: weblinksStoreMigrationsInjectionToken, + lifecycle: lifecycleEnum.singleton, +}); + +export default weblinksStoreMigrationsInjectable; diff --git a/src/renderer/navigation/history.injectable.ts b/src/renderer/navigation/history.injectable.ts new file mode 100644 index 0000000000..0f5acaa2d8 --- /dev/null +++ b/src/renderer/navigation/history.injectable.ts @@ -0,0 +1,13 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { history } from "./history"; + +const historyInjectable = getInjectable({ + instantiate: () => history, + lifecycle: lifecycleEnum.singleton, +}); + +export default historyInjectable; diff --git a/src/renderer/navigation/history.ts b/src/renderer/navigation/history.ts index 87c5625209..fa7df172b4 100644 --- a/src/renderer/navigation/history.ts +++ b/src/renderer/navigation/history.ts @@ -14,8 +14,14 @@ export const searchParamsOptions: ObservableSearchParamsOptions = { joinArraysWith: ",", // param values splitter, applicable only with {joinArrays:true} }; +/** + * @deprecated: Switch to using di.inject(historyInjectable) + */ export const history = ipcRenderer ? createBrowserHistory() : createMemoryHistory(); +/** + * @deprecated: Switch to using di.inject(observableHistoryInjectable) + */ export const navigation = createObservableHistory(history, { searchParams: searchParamsOptions, }); diff --git a/src/renderer/navigation/observable-history.injectable.ts b/src/renderer/navigation/observable-history.injectable.ts new file mode 100644 index 0000000000..db50db04f2 --- /dev/null +++ b/src/renderer/navigation/observable-history.injectable.ts @@ -0,0 +1,15 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; + +import { navigation as observableHistory } from "./history"; + +const observableHistoryInjectable = getInjectable({ + instantiate: () => observableHistory, + + lifecycle: lifecycleEnum.singleton, +}); + +export default observableHistoryInjectable; diff --git a/src/renderer/port-forward/add.injectable.ts b/src/renderer/port-forward/add.injectable.ts new file mode 100644 index 0000000000..2473540e9f --- /dev/null +++ b/src/renderer/port-forward/add.injectable.ts @@ -0,0 +1,13 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import portForwardStoreInjectable from "./store.injectable"; + +const addPortForwardInjectable = getInjectable({ + instantiate: (di) => di.inject(portForwardStoreInjectable).addPortForward, + lifecycle: lifecycleEnum.singleton, +}); + +export default addPortForwardInjectable; diff --git a/src/renderer/port-forward/close-dialog.injectable.ts b/src/renderer/port-forward/close-dialog.injectable.ts new file mode 100644 index 0000000000..b330505bda --- /dev/null +++ b/src/renderer/port-forward/close-dialog.injectable.ts @@ -0,0 +1,25 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { bind } from "../utils"; +import type { PortForwardDialogState } from "./dialog.state.injectable"; +import portForwardDialogStateInjectable from "./dialog.state.injectable"; + +interface Dependencies { + state: PortForwardDialogState; +} + +function closePortForwardDialog({ state }: Dependencies) { + state.isOpen = false; +} + +const closePortForwardDialogInjectable = getInjectable({ + instantiate: (di) => bind(closePortForwardDialog, null, { + state: di.inject(portForwardDialogStateInjectable), + }), + lifecycle: lifecycleEnum.singleton, +}); + +export default closePortForwardDialogInjectable; diff --git a/src/renderer/port-forward/port-forward-dialog.scss b/src/renderer/port-forward/dialog.scss similarity index 96% rename from src/renderer/port-forward/port-forward-dialog.scss rename to src/renderer/port-forward/dialog.scss index eb77ef4ae6..d5ce1bd065 100644 --- a/src/renderer/port-forward/port-forward-dialog.scss +++ b/src/renderer/port-forward/dialog.scss @@ -21,7 +21,7 @@ } .current-scale { - font-weight: bold + font-weight: bold; } .desired-scale { @@ -56,6 +56,6 @@ width: 70px; margin-left: 10px; margin-right: 10px; - } } - + } + } } diff --git a/src/renderer/port-forward/dialog.state.injectable.ts b/src/renderer/port-forward/dialog.state.injectable.ts new file mode 100644 index 0000000000..b73e1eff9d --- /dev/null +++ b/src/renderer/port-forward/dialog.state.injectable.ts @@ -0,0 +1,29 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { observable } from "mobx"; +import { noop } from "../utils"; +import type { ForwardedPort } from "./port-forward"; + +export interface PortForwardDialogState { + isOpen: boolean; + portForward: ForwardedPort | null; + useHttps: boolean; + openInBrowser: boolean; + onClose: () => void; +} + +const portForwardDialogStateInjectable = getInjectable({ + instantiate: () => observable.object({ + isOpen: false, + portForward: null, + useHttps: false, + openInBrowser: false, + onClose: noop, + }), + lifecycle: lifecycleEnum.singleton, +}); + +export default portForwardDialogStateInjectable; diff --git a/src/renderer/port-forward/dialog.tsx b/src/renderer/port-forward/dialog.tsx new file mode 100644 index 0000000000..b5935a15bd --- /dev/null +++ b/src/renderer/port-forward/dialog.tsx @@ -0,0 +1,155 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import "./dialog.scss"; + +import React, { useState } from "react"; +import { observer } from "mobx-react"; +import { Dialog, DialogProps } from "../components/dialog"; +import { Wizard, WizardStep } from "../components/wizard"; +import { Input } from "../components/input"; +import { cssNames } from "../utils"; +import { openPortForward } from "./utils"; +import { aboutPortForwarding, notifyErrorPortForwarding } from "./notify"; +import { Checkbox } from "../components/checkbox"; +import logger from "../../common/logger"; +import { withInjectables } from "@ogre-tools/injectable-react"; +import type { PortForwardDialogState } from "./dialog.state.injectable"; +import portForwardDialogStateInjectable from "./dialog.state.injectable"; +import closePortForwardDialogInjectable from "./close-dialog.injectable"; +import type { ForwardedPort, PortForwardItem } from "./port-forward"; +import type { IComputedValue } from "mobx"; +import portForwardsInjectable from "./port-forwards.injectable"; +import addPortForwardInjectable from "./add.injectable"; +import modifyPortForwardInjectable from "./modify.injectable"; + +export interface PortForwardDialogProps extends Partial { +} + +interface Dependencies { + state: PortForwardDialogState; + closePortForwardDialog: () => void; + modifyPortForward: (portForward: ForwardedPort, desiredPort: number) => Promise; + addPortForward: (portForward: ForwardedPort) => Promise; + portForwards: IComputedValue; +} + +const NonInjectedPortForwardDialog = observer(({ state, closePortForwardDialog, portForwards, modifyPortForward, addPortForward, className, ...dialogProps }: Dependencies & PortForwardDialogProps) => { + const [currentPort, setCurrentPort] = useState(0); + const [desiredPort, setDesiredPort] = useState(0); + const [openInBrowser, setOpenInBrowser] = useState(state.openInBrowser); + const [useHttps, setUseHttps] = useState(state.useHttps); + const { onClose, portForward, isOpen } = state; + + const onOpen = () => { + setCurrentPort(+portForward.forwardPort); + setDesiredPort(currentPort); + }; + + const changePort = (value: string) => { + setDesiredPort(+value); + }; + + const startPortForward = async () => { + try { + // determine how many port-forwards already exist + const length = portForwards.get().length; + let newPortForward: ForwardedPort; + + portForward.protocol = useHttps ? "https" : "http"; + + if (currentPort) { + const wasRunning = portForward.status === "Active"; + + newPortForward = await modifyPortForward(portForward, desiredPort); + + if (wasRunning && newPortForward.status === "Disabled") { + notifyErrorPortForwarding(`Error occurred starting port-forward, the local port ${newPortForward.forwardPort} may not be available or the ${newPortForward.kind} ${newPortForward.name} may not be reachable`); + } + } else { + portForward.forwardPort = desiredPort; + newPortForward = await addPortForward(portForward); + + if (newPortForward.status === "Disabled") { + notifyErrorPortForwarding(`Error occurred starting port-forward, the local port ${newPortForward.forwardPort} may not be available or the ${newPortForward.kind} ${newPortForward.name} may not be reachable`); + } else { + // if this is the first port-forward show the about notification + if (!length) { + aboutPortForwarding(); + } + } + } + + if (newPortForward.status === "Active" && openInBrowser) { + openPortForward(newPortForward); + } + } catch (error) { + logger.error(`[PORT-FORWARD-DIALOG]: ${error}`, portForward); + } finally { + closePortForwardDialog(); + } + }; + + const renderContents = () => ( + <> +
    +
    +
    + Local port to forward from: +
    + +
    + + +
    + + ); + + return ( + + Port Forwarding for {portForward?.name}} done={closePortForwardDialog}> + + {renderContents()} + + + + ); +}); + +export const PortForwardDialog = withInjectables(NonInjectedPortForwardDialog, { + getProps: (di, props) => ({ + state: di.inject(portForwardDialogStateInjectable), + closePortForwardDialog: di.inject(closePortForwardDialogInjectable), + portForwards: di.inject(portForwardsInjectable), + addPortForward: di.inject(addPortForwardInjectable), + modifyPortForward: di.inject(modifyPortForwardInjectable), + ...props, + }), +}); diff --git a/src/renderer/port-forward/get.injectable.ts b/src/renderer/port-forward/get.injectable.ts new file mode 100644 index 0000000000..2ed3fe0e47 --- /dev/null +++ b/src/renderer/port-forward/get.injectable.ts @@ -0,0 +1,13 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import portForwardStoreInjectable from "./store.injectable"; + +const getPortForwardInjectable = getInjectable({ + instantiate: (di) => di.inject(portForwardStoreInjectable).getPortForward, + lifecycle: lifecycleEnum.singleton, +}); + +export default getPortForwardInjectable; diff --git a/src/renderer/port-forward/index.ts b/src/renderer/port-forward/index.ts index e08773c9a6..25d4a567f7 100644 --- a/src/renderer/port-forward/index.ts +++ b/src/renderer/port-forward/index.ts @@ -3,8 +3,8 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -export * from "./port-forward-store/port-forward-store"; -export * from "./port-forward-item"; -export * from "./port-forward-dialog"; -export * from "./port-forward-notify"; -export * from "./port-forward-utils"; +export * from "./store"; +export * from "./port-forward"; +export * from "./dialog"; +export * from "./notify"; +export * from "./utils"; diff --git a/src/renderer/port-forward/modify.injectable.ts b/src/renderer/port-forward/modify.injectable.ts new file mode 100644 index 0000000000..e225d20365 --- /dev/null +++ b/src/renderer/port-forward/modify.injectable.ts @@ -0,0 +1,13 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import portForwardStoreInjectable from "./store.injectable"; + +const modifyPortForwardInjectable = getInjectable({ + instantiate: (di) => di.inject(portForwardStoreInjectable).modifyPortForward, + lifecycle: lifecycleEnum.singleton, +}); + +export default modifyPortForwardInjectable; diff --git a/src/renderer/port-forward/port-forward-notify.tsx b/src/renderer/port-forward/notify.tsx similarity index 100% rename from src/renderer/port-forward/port-forward-notify.tsx rename to src/renderer/port-forward/notify.tsx diff --git a/src/renderer/port-forward/open-dialog.injectable.ts b/src/renderer/port-forward/open-dialog.injectable.ts new file mode 100644 index 0000000000..d4a982105a --- /dev/null +++ b/src/renderer/port-forward/open-dialog.injectable.ts @@ -0,0 +1,38 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { runInAction } from "mobx"; +import { bind, noop } from "../utils"; +import type { PortForwardDialogState } from "./dialog.state.injectable"; +import portForwardDialogStateInjectable from "./dialog.state.injectable"; +import type { ForwardedPort } from "./port-forward"; + +interface Dependencies { + state: PortForwardDialogState; +} + +export interface PortForwardDialogOpenOptions { + openInBrowser: boolean; + onClose: () => void; +} + +function openPortForwardDialog({ state }: Dependencies, portForward: ForwardedPort, options: PortForwardDialogOpenOptions = { openInBrowser: false, onClose: noop }) { + runInAction(() => { + state.isOpen = true; + state.portForward = portForward; + state.useHttps = portForward.protocol === "https"; + state.openInBrowser = options.openInBrowser; + state.onClose = options.onClose; + }); +} + +const openPortForwardDialogInjectable = getInjectable({ + instantiate: (di) => bind(openPortForwardDialog, null, { + state: di.inject(portForwardDialogStateInjectable), + }), + lifecycle: lifecycleEnum.singleton, +}); + +export default openPortForwardDialogInjectable; diff --git a/src/renderer/port-forward/port-forward-dialog-model/port-forward-dialog-model.injectable.ts b/src/renderer/port-forward/port-forward-dialog-model/port-forward-dialog-model.injectable.ts deleted file mode 100644 index 92ddbbbfc0..0000000000 --- a/src/renderer/port-forward/port-forward-dialog-model/port-forward-dialog-model.injectable.ts +++ /dev/null @@ -1,13 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ -import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; -import { PortForwardDialogModel } from "./port-forward-dialog-model"; - -const portForwardDialogModelInjectable = getInjectable({ - instantiate: () => new PortForwardDialogModel(), - lifecycle: lifecycleEnum.singleton, -}); - -export default portForwardDialogModelInjectable; diff --git a/src/renderer/port-forward/port-forward-dialog-model/port-forward-dialog-model.ts b/src/renderer/port-forward/port-forward-dialog-model/port-forward-dialog-model.ts deleted file mode 100644 index 4d2caf7d3c..0000000000 --- a/src/renderer/port-forward/port-forward-dialog-model/port-forward-dialog-model.ts +++ /dev/null @@ -1,48 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ -import { noop } from "lodash/fp"; -import { action, computed, observable, makeObservable } from "mobx"; -import type { ForwardedPort } from "../port-forward-item"; - -interface PortForwardDialogOpenOptions { - openInBrowser: boolean - onClose: () => void -} - -export class PortForwardDialogModel { - portForward: ForwardedPort = null; - useHttps = false; - openInBrowser = false; - onClose = noop; - - constructor() { - makeObservable(this, { - isOpen: computed, - portForward: observable, - useHttps: observable, - openInBrowser: observable, - - open: action, - close: action, - }); - } - - get isOpen() { - return !!this.portForward; - } - - open = (portForward: ForwardedPort, options: PortForwardDialogOpenOptions = { openInBrowser: false, onClose: noop }) => { - this.portForward = portForward; - this.useHttps = portForward.protocol === "https"; - this.openInBrowser = options.openInBrowser; - this.onClose = options.onClose; - }; - - close = () => { - this.portForward = null; - this.useHttps = false; - this.openInBrowser = false; - }; -} diff --git a/src/renderer/port-forward/port-forward-dialog.tsx b/src/renderer/port-forward/port-forward-dialog.tsx deleted file mode 100644 index a36e6ede61..0000000000 --- a/src/renderer/port-forward/port-forward-dialog.tsx +++ /dev/null @@ -1,173 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import "./port-forward-dialog.scss"; - -import React, { Component } from "react"; -import { observable, makeObservable } from "mobx"; -import { observer } from "mobx-react"; -import { Dialog, DialogProps } from "../components/dialog"; -import { Wizard, WizardStep } from "../components/wizard"; -import { Input } from "../components/input"; -import { cssNames } from "../utils"; -import type { PortForwardStore } from "./port-forward-store/port-forward-store"; -import { openPortForward } from "./port-forward-utils"; -import { aboutPortForwarding, notifyErrorPortForwarding } from "./port-forward-notify"; -import { Checkbox } from "../components/checkbox"; -import { withInjectables } from "@ogre-tools/injectable-react"; -import type { PortForwardDialogModel } from "./port-forward-dialog-model/port-forward-dialog-model"; -import portForwardDialogModelInjectable from "./port-forward-dialog-model/port-forward-dialog-model.injectable"; -import logger from "../../common/logger"; -import portForwardStoreInjectable from "./port-forward-store/port-forward-store.injectable"; - -interface Props extends Partial {} - -interface Dependencies { - portForwardStore: PortForwardStore, - model: PortForwardDialogModel -} - -@observer -class NonInjectedPortForwardDialog extends Component { - @observable currentPort = 0; - @observable desiredPort = 0; - - constructor(props: Props & Dependencies) { - super(props); - makeObservable(this); - } - - get portForwardStore() { - return this.props.portForwardStore; - } - - onOpen = async () => { - this.currentPort = +this.props.model.portForward.forwardPort; - this.desiredPort = this.currentPort; - }; - - changePort = (value: string) => { - this.desiredPort = Number(value); - }; - - startPortForward = async () => { - let { portForward } = this.props.model; - const { currentPort, desiredPort } = this; - - try { - // determine how many port-forwards already exist - const { length } = this.portForwardStore.getPortForwards(); - - portForward.protocol = this.props.model.useHttps ? "https" : "http"; - - if (currentPort) { - const wasRunning = portForward.status === "Active"; - - portForward = await this.portForwardStore.modify(portForward, desiredPort); - - if (wasRunning && portForward.status === "Disabled") { - notifyErrorPortForwarding(`Error occurred starting port-forward, the local port ${portForward.forwardPort} may not be available or the ${portForward.kind} ${portForward.name} may not be reachable`); - } - } else { - portForward.forwardPort = desiredPort; - portForward = await this.portForwardStore.add(portForward); - - if (portForward.status === "Disabled") { - notifyErrorPortForwarding(`Error occurred starting port-forward, the local port ${portForward.forwardPort} may not be available or the ${portForward.kind} ${portForward.name} may not be reachable`); - } else { - // if this is the first port-forward show the about notification - if (!length) { - aboutPortForwarding(); - } - } - } - - if (portForward.status === "Active" && this.props.model.openInBrowser) { - openPortForward(portForward); - } - } catch (error) { - logger.error(`[PORT-FORWARD-DIALOG]: ${error}`, portForward); - } finally { - this.props.model.close(); - } - }; - - renderContents() { - return ( - <> -
    -
    -
    - Local port to forward from: -
    - -
    - this.props.model.useHttps = value} - /> - this.props.model.openInBrowser = value} - /> -
    - - ); - } - - render() { - const { className, portForwardStore, model, ...dialogProps } = this.props; - const resourceName = this.props.model.portForward?.name ?? ""; - const header = ( -
    - Port Forwarding for {resourceName} -
    - ); - - return ( - - - - {this.renderContents()} - - - - ); - } -} - -export const PortForwardDialog = withInjectables( - NonInjectedPortForwardDialog, - - { - getProps: (di, props) => ({ - portForwardStore: di.inject(portForwardStoreInjectable), - model: di.inject(portForwardDialogModelInjectable), - ...props, - }), - }, -); diff --git a/src/renderer/port-forward/port-forward-store/port-forward-store.injectable.ts b/src/renderer/port-forward/port-forward-store/port-forward-store.injectable.ts deleted file mode 100644 index a8c15104a8..0000000000 --- a/src/renderer/port-forward/port-forward-store/port-forward-store.injectable.ts +++ /dev/null @@ -1,25 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ -import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; -import { PortForwardStore } from "./port-forward-store"; -import type { ForwardedPort } from "../port-forward-item"; -import createStorageInjectable from "../../utils/create-storage/create-storage.injectable"; - -const portForwardStoreInjectable = getInjectable({ - instantiate: (di) => { - const createStorage = di.inject(createStorageInjectable); - - const storage = createStorage( - "port_forwards", - undefined, - ); - - return new PortForwardStore({ storage }); - }, - - lifecycle: lifecycleEnum.singleton, -}); - -export default portForwardStoreInjectable; diff --git a/src/renderer/port-forward/port-forward-item.ts b/src/renderer/port-forward/port-forward.ts similarity index 100% rename from src/renderer/port-forward/port-forward-item.ts rename to src/renderer/port-forward/port-forward.ts diff --git a/src/renderer/port-forward/port-forwards.injectable.ts b/src/renderer/port-forward/port-forwards.injectable.ts new file mode 100644 index 0000000000..c832ff1b10 --- /dev/null +++ b/src/renderer/port-forward/port-forwards.injectable.ts @@ -0,0 +1,13 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import portForwardStoreInjectable from "./store.injectable"; + +const portForwardsInjectable = getInjectable({ + instantiate: (di) => di.inject(portForwardStoreInjectable).computedItems, + lifecycle: lifecycleEnum.singleton, +}); + +export default portForwardsInjectable; diff --git a/src/renderer/port-forward/remove.injectable.ts b/src/renderer/port-forward/remove.injectable.ts new file mode 100644 index 0000000000..02ba7432f7 --- /dev/null +++ b/src/renderer/port-forward/remove.injectable.ts @@ -0,0 +1,13 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import portForwardStoreInjectable from "./store.injectable"; + +const removePortForwardInjectable = getInjectable({ + instantiate: (di) => di.inject(portForwardStoreInjectable).removePortForward, + lifecycle: lifecycleEnum.singleton, +}); + +export default removePortForwardInjectable; diff --git a/src/renderer/port-forward/start.injectable.ts b/src/renderer/port-forward/start.injectable.ts new file mode 100644 index 0000000000..256125c9e5 --- /dev/null +++ b/src/renderer/port-forward/start.injectable.ts @@ -0,0 +1,13 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import portForwardStoreInjectable from "./store.injectable"; + +const startPortForwardInjectable = getInjectable({ + instantiate: (di) => di.inject(portForwardStoreInjectable).startPortForward, + lifecycle: lifecycleEnum.singleton, +}); + +export default startPortForwardInjectable; diff --git a/src/renderer/port-forward/stop.injectable.ts b/src/renderer/port-forward/stop.injectable.ts new file mode 100644 index 0000000000..98cfbda75c --- /dev/null +++ b/src/renderer/port-forward/stop.injectable.ts @@ -0,0 +1,13 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import portForwardStoreInjectable from "./store.injectable"; + +const stopPortForwardInjectable = getInjectable({ + instantiate: (di) => di.inject(portForwardStoreInjectable).stopPortForward, + lifecycle: lifecycleEnum.singleton, +}); + +export default stopPortForwardInjectable; diff --git a/src/renderer/port-forward/storage.injectable.ts b/src/renderer/port-forward/storage.injectable.ts new file mode 100644 index 0000000000..2e3d4ca361 --- /dev/null +++ b/src/renderer/port-forward/storage.injectable.ts @@ -0,0 +1,20 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import type { StorageLayer } from "../utils"; +import createStorageInjectable from "../utils/create-storage/create-storage.injectable"; +import type { ForwardedPort } from "./port-forward"; + +let storage: StorageLayer; + +const portForwardStorageInjectable = getInjectable({ + setup: async (di) => { + storage = await di.inject(createStorageInjectable)("port_forwards", undefined); + }, + instantiate: () => storage, + lifecycle: lifecycleEnum.singleton, +}); + +export default portForwardStorageInjectable; diff --git a/src/renderer/port-forward/store.injectable.ts b/src/renderer/port-forward/store.injectable.ts new file mode 100644 index 0000000000..1be5d577d1 --- /dev/null +++ b/src/renderer/port-forward/store.injectable.ts @@ -0,0 +1,16 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import portForwardStorageInjectable from "./storage.injectable"; +import { PortForwardStore } from "./store"; + +const portForwardStoreInjectable = getInjectable({ + instantiate: (di) => new PortForwardStore({ + storage: di.inject(portForwardStorageInjectable), + }), + lifecycle: lifecycleEnum.singleton, +}); + +export default portForwardStoreInjectable; diff --git a/src/renderer/port-forward/port-forward-store/port-forward-store.ts b/src/renderer/port-forward/store.ts similarity index 54% rename from src/renderer/port-forward/port-forward-store/port-forward-store.ts rename to src/renderer/port-forward/store.ts index 070f6746b7..4b5edf863f 100644 --- a/src/renderer/port-forward/port-forward-store/port-forward-store.ts +++ b/src/renderer/port-forward/store.ts @@ -3,23 +3,45 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import { action, makeObservable, observable, reaction } from "mobx"; -import { ItemStore } from "../../../common/item.store"; -import { autoBind, disposer, StorageHelper } from "../../utils"; -import { ForwardedPort, PortForwardItem } from "../port-forward-item"; -import { notifyErrorPortForwarding } from "../port-forward-notify"; -import { apiBase } from "../../api"; +import { action, makeObservable, reaction } from "mobx"; +import { ItemStore } from "../../common/item.store"; +import { autoBind, disposer, StorageLayer } from "../utils"; +import { ForwardedPort, PortForwardItem } from "./port-forward"; +import { notifyErrorPortForwarding } from "./notify"; +import { apiBase } from "../api"; import { waitUntilFree } from "tcp-port-used"; -import logger from "../../../common/logger"; +import logger from "../../common/logger"; +import { portForwardsEqual } from "./utils"; -interface Dependencies { - storage: StorageHelper +export interface PortForwardStoreDependencies { + storage: StorageLayer; +} + +interface PortForwardResult { + port: number; +} + +async function getActivePortForward(portForward: ForwardedPort): Promise { + const { port, forwardPort } = portForward; + let response: PortForwardResult; + + try { + response = await apiBase.get( + `/pods/port-forward/${portForward.namespace}/${portForward.kind}/${portForward.name}`, + { query: { port, forwardPort }}, + ); + } catch (error) { + logger.warn(`[PORT-FORWARD-STORE] Error getting active port-forward: ${error}`, portForward); + } + + portForward.status = response?.port ? "Active" : "Disabled"; + portForward.forwardPort = response?.port; + + return portForward; } export class PortForwardStore extends ItemStore { - @observable portForwards: PortForwardItem[] = []; - - constructor(private dependencies: Dependencies) { + constructor(protected readonly dependencies: PortForwardStoreDependencies) { super(); makeObservable(this); autoBind(this); @@ -28,24 +50,20 @@ export class PortForwardStore extends ItemStore { } private async init() { - await this.dependencies.storage.whenReady; - const savedPortForwards = this.dependencies.storage.get(); // undefined on first load if (Array.isArray(savedPortForwards) && savedPortForwards.length > 0) { logger.info("[PORT-FORWARD-STORE] starting saved port-forwards"); // add the disabled ones - await Promise.all(savedPortForwards.filter(pf => pf.status === "Disabled").map(this.add)); + await Promise.all(savedPortForwards.filter(pf => pf.status === "Disabled").map(this.addPortForward)); - // add the active ones (assume active if the status is undefined, for backward compatibility) and check if they started successfully - const results = await Promise.allSettled(savedPortForwards.filter(pf => !pf.status || pf.status === "Active").map(this.add)); + // add the active ones (assume active if the status is undefined, for backward compatibilty) and check if they started successfully + const results = await Promise.allSettled(savedPortForwards.filter(pf => !pf.status || pf.status === "Active").map(this.addPortForward)); for (const result of results) { if (result.status === "rejected" || result.value.status === "Disabled") { - notifyErrorPortForwarding("One or more port-forwards could not be started"); - - return; + return notifyErrorPortForwarding("One or more port-forwards could not be started"); } } } @@ -53,28 +71,22 @@ export class PortForwardStore extends ItemStore { watch() { return disposer( - reaction( - () => this.portForwards.slice(), - () => this.loadAll(), - ), + reaction(() => this.items.slice(), () => this.loadAll()), ); } loadAll() { return this.loadItems(() => { - const portForwards = this.getPortForwards(); + const portForwards = this.items; this.dependencies.storage.set(portForwards); - this.portForwards = []; - portForwards.map((pf) => this.portForwards.push(new PortForwardItem(pf))); - - return this.portForwards; + return this.items.replace(portForwards.map(pf => new PortForwardItem(pf))); }); } - async removeSelectedItems() { - return Promise.all(this.selectedItems.map(this.remove)); + removeSelectedItems() { + return Promise.all(this.selectedItems.map(this.removePortForward)); } getById(id: string) { @@ -87,171 +99,21 @@ export class PortForwardStore extends ItemStore { return this.getItems()[index]; } - /** - * add a port-forward to the store and optionally start it - * @param portForward the port-forward to add. If the port-forward already exists in the store it will be - * returned with its current state. If the forwardPort field is 0 then an arbitrary port will be - * used. If the status field is "Active" or not present then an attempt is made to start the port-forward. - * - * @returns the port-forward with updated status ("Active" if successfully started, "Disabled" otherwise) and - * forwardPort - */ - add = action(async (portForward: ForwardedPort): Promise => { - const pf = this.findPortForward(portForward); - - if (pf) { - return pf; - } - - this.portForwards.push(new PortForwardItem(portForward)); - - if (!portForward.status) { - portForward.status = "Active"; - } - - if (portForward.status === "Active") { - portForward = await this.start(portForward); - } - - return portForward; - }); - - /** - * modifies a port-forward in the store, including the forwardPort and protocol - * @param portForward the port-forward to modify. - * - * @returns the port-forward after being modified. - */ - modify = action( - async ( - portForward: ForwardedPort, - desiredPort: number, - ): Promise => { - const pf = this.findPortForward(portForward); - - if (!pf) { - throw new Error("port-forward not found"); - } - - if (pf.status === "Active") { - try { - await this.stop(pf); - } catch { - // ignore, assume it is stopped and proceed to restart it - } - - pf.forwardPort = desiredPort; - pf.protocol = portForward.protocol ?? "http"; - this.setPortForward(pf); - - return await this.start(pf); - } - - pf.forwardPort = desiredPort; - this.setPortForward(pf); - - return pf as ForwardedPort; - }, - ); - - /** - * remove and stop an existing port-forward. - * @param portForward the port-forward to remove. - */ - remove = action(async (portForward: ForwardedPort) => { - const pf = this.findPortForward(portForward); - - if (!pf) { - const error = new Error("port-forward not found"); - - logger.warn( - `[PORT-FORWARD-STORE] Error getting port-forward: ${error}`, - portForward, - ); - - return; - } - - try { - await this.stop(portForward); - } catch (error) { - if (pf.status === "Active") { - logger.warn( - `[PORT-FORWARD-STORE] Error removing port-forward: ${error}`, - portForward, - ); - } - } - - const index = this.portForwards.findIndex(portForwardsEqual(portForward)); - - if (index >= 0) { - this.portForwards.splice(index, 1); - } - }); - - /** - * gets the list of port-forwards in the store - * - * @returns the port-forwards - */ - getPortForwards = (): ForwardedPort[] => { - return this.portForwards; - }; - - /** - * stop an existing port-forward. Its status is set to "Disabled" after successfully stopped. - * @param portForward the port-forward to stop. - * - * @throws if the port-forward could not be stopped. Its status is unchanged - */ - stop = action(async (portForward: ForwardedPort) => { - const pf = this.findPortForward(portForward); - - if (!pf) { - logger.warn( - "[PORT-FORWARD-STORE] Error getting port-forward: port-forward not found", - portForward, - ); - - return; - } - - const { port, forwardPort } = portForward; - - try { - await apiBase.del( - `/pods/port-forward/${portForward.namespace}/${portForward.kind}/${portForward.name}`, - { query: { port, forwardPort }}, - ); - await waitUntilFree(+forwardPort, 200, 1000); - } catch (error) { - logger.warn( - `[PORT-FORWARD-STORE] Error stopping active port-forward: ${error}`, - portForward, - ); - throw error; - } - - pf.status = "Disabled"; - - this.setPortForward(pf); - }); - private findPortForward = (portForward: ForwardedPort) => { - return this.portForwards.find(portForwardsEqual(portForward)); + return this.items.find(portForwardsEqual(portForward)); }; private setPortForward = action((portForward: ForwardedPort) => { - const index = this.portForwards.findIndex(portForwardsEqual(portForward)); + const index = this.items.findIndex(portForwardsEqual(portForward)); if (index < 0) { return; } - this.portForwards[index] = new PortForwardItem(portForward); + this.items[index] = new PortForwardItem(portForward); }); + /** * start an existing port-forward * @param portForward the port-forward to start. If the forwardPort field is 0 then an arbitrary port will be @@ -262,7 +124,7 @@ export class PortForwardStore extends ItemStore { * * @throws if the port-forward does not already exist in the store */ - start = action(async (portForward: ForwardedPort): Promise => { + startPortForward = action(async (portForward: ForwardedPort): Promise => { const pf = this.findPortForward(portForward); if (!pf) { @@ -270,38 +132,58 @@ export class PortForwardStore extends ItemStore { } const { port, forwardPort } = pf; - let response: PortForwardResult; try { - response = await apiBase.post( + const response = await apiBase.post( `/pods/port-forward/${pf.namespace}/${pf.kind}/${pf.name}`, { query: { port, forwardPort }}, ); // expecting the received port to be the specified port, unless the specified port is 0, which indicates any available port is suitable - if ( - pf.forwardPort && - response?.port && - response.port != +pf.forwardPort - ) { - logger.warn( - `[PORT-FORWARD-STORE] specified ${pf.forwardPort}, got ${response.port}`, - ); + if (pf.forwardPort && response?.port && response.port != +pf.forwardPort) { + logger.warn(`[PORT-FORWARD-STORE] specified ${pf.forwardPort}, got ${response.port}`); } pf.forwardPort = response.port; pf.status = "Active"; + } catch (error) { - logger.warn( - `[PORT-FORWARD-STORE] Error starting port-forward: ${error}`, - pf, - ); + logger.warn(`[PORT-FORWARD-STORE] Error starting port-forward: ${error}`, pf); pf.status = "Disabled"; } this.setPortForward(pf); - return pf as ForwardedPort; + return pf; + }); + + /** + * add a port-forward to the store and optionally start it + * @param portForward the port-forward to add. If the port-forward already exists in the store it will be + * returned with its current state. If the forwardPort field is 0 then an arbitrary port will be + * used. If the status field is "Active" or not present then an attempt is made to start the port-forward. + * + * @returns the port-forward with updated status ("Active" if successfully started, "Disabled" otherwise) and + * forwardPort + */ + addPortForward = action(async (portForward: ForwardedPort): Promise => { + const pf = this.findPortForward(portForward); + + if (pf) { + return pf; + } + + this.items.push(new PortForwardItem(portForward)); + + if (!portForward.status) { + portForward.status = "Active"; + } + + if (portForward.status === "Active") { + portForward = await this.startPortForward(portForward); + } + + return portForward; }); /** @@ -313,9 +195,7 @@ export class PortForwardStore extends ItemStore { * * @throws if the port-forward does not exist in the store */ - getPortForward = async ( - portForward: ForwardedPort, - ): Promise => { + getPortForward = async (portForward: ForwardedPort): Promise => { if (!this.findPortForward(portForward)) { throw new Error("port-forward not found"); } @@ -327,9 +207,7 @@ export class PortForwardStore extends ItemStore { pf = await getActivePortForward(portForward); if (pf.forwardPort && pf.forwardPort !== portForward.forwardPort) { - logger.warn( - `[PORT-FORWARD-STORE] local port, expected ${pf.forwardPort}, got ${portForward.forwardPort}`, - ); + logger.warn(`[PORT-FORWARD-STORE] local port, expected ${pf.forwardPort}, got ${portForward.forwardPort}`); } } catch (error) { // port is not active @@ -337,33 +215,90 @@ export class PortForwardStore extends ItemStore { return pf; }; -} - -interface PortForwardResult { - port: number; -} - -function portForwardsEqual(portForward: ForwardedPort) { - return (pf: ForwardedPort) => ( - pf.kind == portForward.kind && - pf.name == portForward.name && - pf.namespace == portForward.namespace && - pf.port == portForward.port - ); -} - -async function getActivePortForward(portForward: ForwardedPort): Promise { - const { port, forwardPort } = portForward; - let response: PortForwardResult; - - try { - response = await apiBase.get(`/pods/port-forward/${portForward.namespace}/${portForward.kind}/${portForward.name}`, { query: { port, forwardPort }}); - } catch (error) { - logger.warn(`[PORT-FORWARD-STORE] Error getting active port-forward: ${error}`, portForward); - } - - portForward.status = response?.port ? "Active" : "Disabled"; - portForward.forwardPort = response?.port; - - return portForward; + + /** + * modifies a port-forward in the store, including the forwardPort and protocol + * @param portForward the port-forward to modify. + * + * @returns the port-forward after being modified. + */ + modifyPortForward = action(async (portForward: ForwardedPort, desiredPort: number): Promise => { + const pf = this.findPortForward(portForward); + + if (!pf) { + throw new Error("port-forward not found"); + } + + if (pf.status === "Active") { + try { + await this.stopPortForward(pf); + } catch { + // ignore, assume it is stopped and proceed to restart it + } + + pf.forwardPort = desiredPort; + pf.protocol = portForward.protocol ?? "http"; + this.setPortForward(pf); + + return this.startPortForward(pf); + } + + pf.forwardPort = desiredPort; + this.setPortForward(pf); + + return pf; + }); + + /** + * stop an existing port-forward. Its status is set to "Disabled" after successfully stopped. + * @param portForward the port-forward to stop. + * + * @throws if the port-forward could not be stopped. Its status is unchanged + */ + stopPortForward = action(async (portForward: ForwardedPort) => { + const pf = this.findPortForward(portForward); + + if (!pf) { + return void logger.warn("[PORT-FORWARD-STORE] Error getting port-forward: port-forward not found", portForward); + } + + const { port, forwardPort } = portForward; + + try { + await apiBase.del(`/pods/port-forward/${portForward.namespace}/${portForward.kind}/${portForward.name}`, { query: { port, forwardPort }}); + await waitUntilFree(+forwardPort, 200, 1000); + } catch (error) { + logger.warn(`[PORT-FORWARD-STORE] Error stopping active port-forward: ${error}`, portForward); + throw (error); + } + + pf.status = "Disabled"; + this.setPortForward(pf); + }); + + /** + * remove and stop an existing port-forward. + * @param portForward the port-forward to remove. + */ + removePortForward = action(async (portForward: ForwardedPort) => { + const pf = this.findPortForward(portForward); + + if (!pf) { + return void logger.warn(`[PORT-FORWARD-STORE] Error getting port-forward: port-forward not found`, portForward); + } + + try { + await this.stopPortForward(portForward); + } catch (error) { + if (pf.status === "Active") { + logger.warn(`[PORT-FORWARD-STORE] Error removing port-forward: ${error}`, portForward); + } + } + + const index = this.items.findIndex(portForwardsEqual(portForward)); + + if (index >= 0 ) { + this.items.splice(index, 1); + } + }); } diff --git a/src/renderer/port-forward/port-forward-utils.ts b/src/renderer/port-forward/utils.ts similarity index 76% rename from src/renderer/port-forward/port-forward-utils.ts rename to src/renderer/port-forward/utils.ts index 31fd1f165b..f2d9162869 100644 --- a/src/renderer/port-forward/port-forward-utils.ts +++ b/src/renderer/port-forward/utils.ts @@ -3,10 +3,9 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ - import { openExternal } from "../utils"; import { Notifications } from "../components/notifications"; -import type { ForwardedPort } from "./port-forward-item"; +import type { ForwardedPort } from "./port-forward"; import logger from "../../common/logger"; export function portForwardAddress(portForward: ForwardedPort) { @@ -34,3 +33,12 @@ export function predictProtocol(name: string) { return name === "https" ? "https" : "http"; } +export function portForwardsEqual(portForward: ForwardedPort) { + return (pf: ForwardedPort) => ( + pf.kind == portForward.kind && + pf.name == portForward.name && + pf.namespace == portForward.namespace && + pf.port == portForward.port + ); +} + diff --git a/src/renderer/port-forward/watch-port-forwards.injectable.ts b/src/renderer/port-forward/watch-port-forwards.injectable.ts new file mode 100644 index 0000000000..3101245c02 --- /dev/null +++ b/src/renderer/port-forward/watch-port-forwards.injectable.ts @@ -0,0 +1,13 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import portForwardStoreInjectable from "./store.injectable"; + +const watchPortForwardsInjectable = getInjectable({ + instantiate: (di) => di.inject(portForwardStoreInjectable).watch, + lifecycle: lifecycleEnum.singleton, +}); + +export default watchPortForwardsInjectable; diff --git a/src/renderer/protocol-handler/bind-protocol-add-route-handlers/bind-protocol-add-route-handlers.injectable.ts b/src/renderer/protocol-handler/bind-protocol-add-route-handlers/bind-protocol-add-route-handlers.injectable.ts index de19da48eb..ce3c95b439 100644 --- a/src/renderer/protocol-handler/bind-protocol-add-route-handlers/bind-protocol-add-route-handlers.injectable.ts +++ b/src/renderer/protocol-handler/bind-protocol-add-route-handlers/bind-protocol-add-route-handlers.injectable.ts @@ -4,19 +4,21 @@ */ import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; import attemptInstallByInfoInjectable from "../../components/+extensions/attempt-install-by-info/attempt-install-by-info.injectable"; -import { bindProtocolAddRouteHandlers } from "./bind-protocol-add-route-handlers"; +import { addInternalProtocolRouteHandlers } from "./bind-protocol-add-route-handlers"; import lensProtocolRouterRendererInjectable from "../lens-protocol-router-renderer/lens-protocol-router-renderer.injectable"; +import getEntityByIdInjectable from "../../catalog/get-entity-by-id.injectable"; +import { bind } from "../../utils"; +import getClusterByIdInjectable from "../../../common/cluster-store/get-cluster-by-id.injectable"; -const bindProtocolAddRouteHandlersInjectable = getInjectable({ - instantiate: (di) => - bindProtocolAddRouteHandlers({ - attemptInstallByInfo: di.inject(attemptInstallByInfoInjectable), - lensProtocolRouterRenderer: di.inject( - lensProtocolRouterRendererInjectable, - ), - }), +const addInternalProtocolRouteHandlersInjectable = getInjectable({ + instantiate: (di) => bind(addInternalProtocolRouteHandlers, null, { + attemptInstallByInfo: di.inject(attemptInstallByInfoInjectable), + lensProtocolRouterRenderer: di.inject(lensProtocolRouterRendererInjectable), + getEntityById: di.inject(getEntityByIdInjectable), + getClusterById: di.inject(getClusterByIdInjectable), + }), lifecycle: lifecycleEnum.singleton, }); -export default bindProtocolAddRouteHandlersInjectable; +export default addInternalProtocolRouteHandlersInjectable; diff --git a/src/renderer/protocol-handler/bind-protocol-add-route-handlers/bind-protocol-add-route-handlers.tsx b/src/renderer/protocol-handler/bind-protocol-add-route-handlers/bind-protocol-add-route-handlers.tsx index 2e69506c20..0c0d4967ed 100644 --- a/src/renderer/protocol-handler/bind-protocol-add-route-handlers/bind-protocol-add-route-handlers.tsx +++ b/src/renderer/protocol-handler/bind-protocol-add-route-handlers/bind-protocol-add-route-handlers.tsx @@ -6,126 +6,120 @@ import React from "react"; import type { LensProtocolRouterRenderer } from "../lens-protocol-router-renderer/lens-protocol-router-renderer"; import { navigate } from "../../navigation/helpers"; -import { catalogEntityRegistry } from "../../api/catalog-entity-registry"; -import { ClusterStore } from "../../../common/cluster-store/cluster-store"; -import { - EXTENSION_NAME_MATCH, - EXTENSION_PUBLISHER_MATCH, - LensProtocolRouter, -} from "../../../common/protocol-handler"; +import { EXTENSION_NAME_MATCH, EXTENSION_PUBLISHER_MATCH, LensProtocolRouter } from "../../../common/protocol-handler"; import { Notifications } from "../../components/notifications"; import * as routes from "../../../common/routes"; import type { ExtensionInfo } from "../../components/+extensions/attempt-install-by-info/attempt-install-by-info"; +import type { CatalogEntity } from "../../../common/catalog"; +import type { Cluster } from "../../../common/cluster/cluster"; interface Dependencies { attemptInstallByInfo: (extensionInfo: ExtensionInfo) => Promise; lensProtocolRouterRenderer: LensProtocolRouterRenderer; + getEntityById: (id: string) => CatalogEntity | undefined; + getClusterById: (id: string) => Cluster | null; } -export const bindProtocolAddRouteHandlers = - ({ attemptInstallByInfo, lensProtocolRouterRenderer }: Dependencies) => - () => { - lensProtocolRouterRenderer - .addInternalHandler("/preferences", ({ search: { highlight }}) => { - navigate(routes.preferencesURL({ fragment: highlight })); - }) - .addInternalHandler("/", ({ tail }) => { - if (tail) { - Notifications.shortInfo( -

    - Unknown Action for lens://app/{tail}. Are you on the - latest version? -

    , - ); - } - - navigate(routes.catalogURL()); - }) - .addInternalHandler("/landing", () => { - navigate(routes.catalogURL()); - }) - .addInternalHandler( - "/landing/view/:group/:kind", - ({ pathname: { group, kind }}) => { - navigate( - routes.catalogURL({ - params: { - group, - kind, - }, - }), - ); - }, - ) - .addInternalHandler("/cluster", () => { - navigate(routes.addClusterURL()); - }) - .addInternalHandler( - "/entity/:entityId/settings", - ({ pathname: { entityId }}) => { - const entity = catalogEntityRegistry.getById(entityId); - - if (entity) { - navigate(routes.entitySettingsURL({ params: { entityId }})); - } else { - Notifications.shortInfo( -

    - Unknown catalog entity {entityId}. -

    , - ); - } - }, - ) - // Handlers below are deprecated and only kept for backward compact purposes - .addInternalHandler( - "/cluster/:clusterId", - ({ pathname: { clusterId }}) => { - const cluster = ClusterStore.getInstance().getById(clusterId); - - if (cluster) { - navigate(routes.clusterViewURL({ params: { clusterId }})); - } else { - Notifications.shortInfo( -

    - Unknown catalog entity {clusterId}. -

    , - ); - } - }, - ) - .addInternalHandler( - "/cluster/:clusterId/settings", - ({ pathname: { clusterId }}) => { - const cluster = ClusterStore.getInstance().getById(clusterId); - - if (cluster) { - navigate( - routes.entitySettingsURL({ params: { entityId: clusterId }}), - ); - } else { - Notifications.shortInfo( -

    - Unknown catalog entity {clusterId}. -

    , - ); - } - }, - ) - .addInternalHandler("/extensions", () => { - navigate(routes.extensionsURL()); - }) - .addInternalHandler( - `/extensions/install${LensProtocolRouter.ExtensionUrlSchema}`, - ({ pathname, search: { version }}) => { - const name = [ - pathname[EXTENSION_PUBLISHER_MATCH], - pathname[EXTENSION_NAME_MATCH], - ] - .filter(Boolean) - .join("/"); - - navigate(routes.extensionsURL()); - attemptInstallByInfo({ name, version, requireConfirmation: true }); - }, +export function addInternalProtocolRouteHandlers({ attemptInstallByInfo, lensProtocolRouterRenderer, getEntityById, getClusterById }: Dependencies) { + return lensProtocolRouterRenderer + .addInternalHandler("/preferences", ({ search: { highlight }}) => { + navigate(routes.preferencesURL({ fragment: highlight })); + }) + .addInternalHandler("/", ({ tail }) => { + if (tail) { + Notifications.shortInfo( +

    + Unknown Action for lens://app/{tail}. Are you on the + latest version? +

    , ); - }; + } + + navigate(routes.catalogURL()); + }) + .addInternalHandler("/landing", () => { + navigate(routes.catalogURL()); + }) + .addInternalHandler( + "/landing/view/:group/:kind", + ({ pathname: { group, kind }}) => { + navigate( + routes.catalogURL({ + params: { + group, + kind, + }, + }), + ); + }, + ) + .addInternalHandler("/cluster", () => { + navigate(routes.addClusterURL()); + }) + .addInternalHandler( + "/entity/:entityId/settings", + ({ pathname: { entityId }}) => { + if (getEntityById(entityId)) { + navigate(routes.entitySettingsURL({ params: { entityId }})); + } else { + Notifications.shortInfo( +

    + Unknown catalog entity {entityId}. +

    , + ); + } + }, + ) + // Handlers below are deprecated and only kept for backward compact purposes + .addInternalHandler( + "/cluster/:clusterId", + ({ pathname: { clusterId }}) => { + const cluster = getClusterById(clusterId); + + if (cluster) { + navigate(routes.clusterViewURL({ params: { clusterId }})); + } else { + Notifications.shortInfo( +

    + Unknown catalog entity {clusterId}. +

    , + ); + } + }, + ) + .addInternalHandler( + "/cluster/:clusterId/settings", + ({ pathname: { clusterId }}) => { + const cluster = getClusterById(clusterId); + + if (cluster) { + navigate( + routes.entitySettingsURL({ params: { entityId: clusterId }}), + ); + } else { + Notifications.shortInfo( +

    + Unknown catalog entity {clusterId}. +

    , + ); + } + }, + ) + .addInternalHandler("/extensions", () => { + navigate(routes.extensionsURL()); + }) + .addInternalHandler( + `/extensions/install${LensProtocolRouter.ExtensionUrlSchema}`, + ({ pathname, search: { version }}) => { + const name = [ + pathname[EXTENSION_PUBLISHER_MATCH], + pathname[EXTENSION_NAME_MATCH], + ] + .filter(Boolean) + .join("/"); + + navigate(routes.extensionsURL()); + attemptInstallByInfo({ name, version, requireConfirmation: true }); + }, + ); +} diff --git a/src/renderer/protocol-handler/index.ts b/src/renderer/protocol-handler/index.ts index c338f50114..ccaf88ab1e 100644 --- a/src/renderer/protocol-handler/index.ts +++ b/src/renderer/protocol-handler/index.ts @@ -4,4 +4,3 @@ */ export { LensProtocolRouterRenderer } from "./lens-protocol-router-renderer/lens-protocol-router-renderer"; -export { bindProtocolAddRouteHandlers } from "./bind-protocol-add-route-handlers/bind-protocol-add-route-handlers"; diff --git a/src/renderer/remote-helpers/dialog.ts b/src/renderer/remote-helpers/dialog.ts index 038409eb39..1e55f5c867 100644 --- a/src/renderer/remote-helpers/dialog.ts +++ b/src/renderer/remote-helpers/dialog.ts @@ -5,6 +5,6 @@ import { dialogShowOpenDialogHandler, requestMain } from "../../common/ipc"; -export async function showOpenDialog(options: Electron.OpenDialogOptions): Promise { +export function showOpenDialog(options: Electron.OpenDialogOptions): Promise { return requestMain(dialogShowOpenDialogHandler, options); } diff --git a/src/renderer/search-store/search-store.injectable.ts b/src/renderer/search-store/search-store.injectable.ts deleted file mode 100644 index 37fed767e9..0000000000 --- a/src/renderer/search-store/search-store.injectable.ts +++ /dev/null @@ -1,17 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ -import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; -import dockStoreInjectable from "../components/dock/dock-store/dock-store.injectable"; -import { SearchStore } from "./search-store"; - -const searchStoreInjectable = getInjectable({ - instantiate: (di) => new SearchStore({ - dockStore: di.inject(dockStoreInjectable), - }), - - lifecycle: lifecycleEnum.singleton, -}); - -export default searchStoreInjectable; diff --git a/src/renderer/search-store/search-store.test.ts b/src/renderer/search-store/search-store.test.ts deleted file mode 100644 index d2c8b5c9e8..0000000000 --- a/src/renderer/search-store/search-store.test.ts +++ /dev/null @@ -1,102 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import { SearchStore } from "./search-store"; -import { Console } from "console"; -import { stdout, stderr } from "process"; -import { getDiForUnitTesting } from "../getDiForUnitTesting"; -import searchStoreInjectable from "./search-store.injectable"; -import directoryForUserDataInjectable - from "../../common/app-paths/directory-for-user-data/directory-for-user-data.injectable"; - -jest.mock("electron", () => ({ - app: { - getPath: () => "/foo", - }, -})); - -console = new Console(stdout, stderr); - -const logs = [ - "1:M 30 Oct 2020 16:17:41.553 # Connection with replica 172.17.0.12:6379 lost", - "1:M 30 Oct 2020 16:17:41.623 * Replica 172.17.0.12:6379 asks for synchronization", - "1:M 30 Oct 2020 16:17:41.623 * Starting Partial resynchronization request from 172.17.0.12:6379 accepted. Sending 0 bytes of backlog starting from offset 14407.", -]; - -describe("search store tests", () => { - let searchStore: SearchStore; - - beforeEach(async () => { - const di = getDiForUnitTesting({ doGeneralOverrides: true }); - - di.override(directoryForUserDataInjectable, () => "some-directory-for-user-data"); - - await di.runSetups(); - - searchStore = di.inject(searchStoreInjectable); - }); - - it("does nothing with empty search query", () => { - searchStore.onSearch([], ""); - expect(searchStore.occurrences).toEqual([]); - }); - - it("doesn't break if no text provided", () => { - searchStore.onSearch(null, "replica"); - expect(searchStore.occurrences).toEqual([]); - - searchStore.onSearch([], "replica"); - expect(searchStore.occurrences).toEqual([]); - }); - - it("find 3 occurrences across 3 lines", () => { - searchStore.onSearch(logs, "172"); - expect(searchStore.occurrences).toEqual([0, 1, 2]); - }); - - it("find occurrences within 1 line (case-insensitive)", () => { - searchStore.onSearch(logs, "Starting"); - expect(searchStore.occurrences).toEqual([2, 2]); - }); - - it("sets overlay index equal to first occurrence", () => { - searchStore.onSearch(logs, "Replica"); - expect(searchStore.activeOverlayIndex).toBe(0); - }); - - it("set overlay index to next occurrence", () => { - searchStore.onSearch(logs, "172"); - searchStore.setNextOverlayActive(); - expect(searchStore.activeOverlayIndex).toBe(1); - }); - - it("sets overlay to last occurrence", () => { - searchStore.onSearch(logs, "172"); - searchStore.setPrevOverlayActive(); - expect(searchStore.activeOverlayIndex).toBe(2); - }); - - it("gets line index where overlay is located", () => { - searchStore.onSearch(logs, "synchronization"); - expect(searchStore.activeOverlayLine).toBe(1); - }); - - it("escapes string for using in regex", () => { - const regex = SearchStore.escapeRegex("some.interesting-query\\#?()[]"); - - expect(regex).toBe("some\\.interesting\\-query\\\\\\#\\?\\(\\)\\[\\]"); - }); - - it("gets active find number", () => { - searchStore.onSearch(logs, "172"); - searchStore.setNextOverlayActive(); - expect(searchStore.activeFind).toBe(2); - }); - - it("gets total finds number", () => { - searchStore.onSearch(logs, "Starting"); - expect(searchStore.totalFinds).toBe(2); - }); -}); diff --git a/src/renderer/themes/active-theme.injectable.ts b/src/renderer/themes/active-theme.injectable.ts new file mode 100644 index 0000000000..0eb11f9598 --- /dev/null +++ b/src/renderer/themes/active-theme.injectable.ts @@ -0,0 +1,18 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { computed } from "mobx"; +import themeStoreInjectable from "./store.injectable"; + +const activeThemeInjectable = getInjectable({ + instantiate: (di) => { + const store = di.inject(themeStoreInjectable); + + return computed(() => store.activeTheme); + }, + lifecycle: lifecycleEnum.singleton, +}); + +export default activeThemeInjectable; diff --git a/src/renderer/themes/store.injectable.ts b/src/renderer/themes/store.injectable.ts new file mode 100644 index 0000000000..6a1acc2b6b --- /dev/null +++ b/src/renderer/themes/store.injectable.ts @@ -0,0 +1,20 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import colorThemeIdInjectable from "../../common/user-preferences/color-theme-id.injectable"; +import resetThemeSettingsInjectable from "../../common/user-preferences/reset-theme-settings.injectable"; +import terminalThemeIdInjectable from "../../common/user-preferences/terminal-theme-id.injectable"; +import { ThemeStore } from "./store"; + +const themeStoreInjectable = getInjectable({ + instantiate: (di) => new ThemeStore({ + colorThemeId: di.inject(colorThemeIdInjectable), + terminalThemeId: di.inject(terminalThemeIdInjectable), + resetThemeSelection: di.inject(resetThemeSettingsInjectable), + }), + lifecycle: lifecycleEnum.singleton, +}); + +export default themeStoreInjectable; diff --git a/src/renderer/theme.store.ts b/src/renderer/themes/store.ts similarity index 64% rename from src/renderer/theme.store.ts rename to src/renderer/themes/store.ts index 19180285dd..ea935bb600 100644 --- a/src/renderer/theme.store.ts +++ b/src/renderer/themes/store.ts @@ -3,15 +3,14 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import { comparer, computed, makeObservable, observable, reaction } from "mobx"; -import { autoBind, Singleton } from "./utils"; -import { UserStore } from "../common/user-store"; -import logger from "../main/logger"; -import lensDarkThemeJson from "./themes/lens-dark.json"; -import lensLightThemeJson from "./themes/lens-light.json"; -import type { SelectOption } from "./components/select"; -import type { MonacoEditorProps } from "./components/monaco-editor"; -import { defaultTheme } from "../common/vars"; +import { comparer, computed, IComputedValue, makeObservable, observable, reaction } from "mobx"; +import { autoBind } from "../utils"; +import logger from "../../main/logger"; +import lensDarkThemeJson from "../internal-themes/lens-dark.json"; +import lensLightThemeJson from "../internal-themes/lens-light.json"; +import type { SelectOption } from "../components/select"; +import type { MonacoEditorProps } from "../components/monaco-editor"; +import { defaultTheme } from "../../common/vars"; import { camelCase } from "lodash"; export type ThemeId = string; @@ -25,7 +24,13 @@ export interface Theme { monacoTheme: MonacoEditorProps["theme"]; } -export class ThemeStore extends Singleton { +export interface ThemeStoreDependencies { + readonly colorThemeId: IComputedValue; + readonly terminalThemeId: IComputedValue; + resetThemeSelection: () => void; +} + +export class ThemeStore { private terminalColorPrefix = "terminal"; // bundled themes from `themes/${themeId}.json` @@ -34,20 +39,12 @@ export class ThemeStore extends Singleton { "lens-light": lensLightThemeJson as Theme, }); - @computed get activeThemeId(): ThemeId { - return UserStore.getInstance().colorTheme; - } - - @computed get terminalThemeId(): ThemeId { - return UserStore.getInstance().terminalTheme; - } - @computed get activeTheme(): Theme { - return this.themes.get(this.activeThemeId) ?? this.themes.get(defaultTheme); + return this.themes.get(this.dependencies.colorThemeId.get()) ?? this.themes.get(defaultTheme); } @computed get terminalColors(): [string, string][] { - const theme = this.themes.get(this.terminalThemeId) ?? this.activeTheme; + const theme = this.themes.get(this.dependencies.terminalThemeId.get()) ?? this.activeTheme; return Object .entries(theme.colors) @@ -56,14 +53,14 @@ export class ThemeStore extends Singleton { // Replacing keys stored in styles to format accepted by terminal // E.g. terminalBrightBlack -> brightBlack - @computed get xtermColors(): Record { - return Object.fromEntries( + readonly xtermColors = computed(() => ( + Object.fromEntries( this.terminalColors.map(([name, color]) => [ camelCase(name.replace(this.terminalColorPrefix, "")), color, ]), - ); - } + ) + )); @computed get themeOptions(): SelectOption[] { return Array.from(this.themes).map(([themeId, theme]) => ({ @@ -72,22 +69,20 @@ export class ThemeStore extends Singleton { })); } - constructor() { - super(); - + constructor(protected readonly dependencies: ThemeStoreDependencies) { makeObservable(this); autoBind(this); // auto-apply active theme reaction(() => ({ - themeId: this.activeThemeId, - terminalThemeId: this.terminalThemeId, + themeId: this.dependencies.colorThemeId.get(), + terminalThemeId: this.dependencies.terminalThemeId.get(), }), ({ themeId }) => { try { this.applyTheme(themeId); } catch (err) { logger.error(err); - UserStore.getInstance().resetTheme(); + this.dependencies.resetThemeSelection(); } }, { fireImmediately: true, diff --git a/src/renderer/themes/terminal-colors.injectable.ts b/src/renderer/themes/terminal-colors.injectable.ts new file mode 100644 index 0000000000..fab99499a0 --- /dev/null +++ b/src/renderer/themes/terminal-colors.injectable.ts @@ -0,0 +1,13 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import themeStoreInjectable from "./store.injectable"; + +const terminalColorsInjectable = getInjectable({ + instantiate: (di) => di.inject(themeStoreInjectable).xtermColors, + lifecycle: lifecycleEnum.singleton, +}); + +export default terminalColorsInjectable; diff --git a/src/renderer/utils/__mocks__/storage-helper.ts b/src/renderer/utils/__mocks__/storage-helper.ts new file mode 100644 index 0000000000..6f03791e26 --- /dev/null +++ b/src/renderer/utils/__mocks__/storage-helper.ts @@ -0,0 +1,61 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { deepStrictEqual } from "assert"; +import { Draft, isDraft, produce } from "immer"; +import { isPlainObject } from "lodash"; +import { IObservableValue, observable } from "mobx"; +import { toJS } from ".."; + +export interface MockedStorageLayer { + key: string; + defaultValue: T; + whenReady: Promise; + isDefaultValue: jest.MockedFunction<(value: T) => boolean>; + get: jest.MockedFunction<() => T>; + set: jest.MockedFunction<(value: T) => void>; + reset: jest.MockedFunction<() => void>; + merge: jest.MockedFunction<(value: Partial | ((draft: Draft) => Partial | void)) => void>; + toJSON: jest.MockedFunction<() => T>; + _box: IObservableValue; +} + +export function getStorageLayerMock(key: string, defaultValue: T): Promise> { + const _box = observable.box(defaultValue); + const set = jest.fn().mockImplementation((val) => _box.set(val)); + const toJSON = jest.fn().mockImplementation(() => toJS(_box)); + + return Promise.resolve({ + _box, + key, + defaultValue, + whenReady: Promise.resolve(), + isDefaultValue: jest.fn().mockImplementation(val => deepStrictEqual(val, defaultValue)), + get: jest.fn().mockImplementation(() => _box.get()), + set, + reset: jest.fn().mockImplementation(() => _box.set(defaultValue)), + merge: jest.fn().mockImplementation((value: Partial | ((draft: Draft) => Partial | void)) => { + const nextValue = produce(toJSON(), (draft: Draft) => { + + if (typeof value == "function") { + const newValue = value(draft); + + // merge returned plain objects from `value-as-callback` usage + // otherwise `draft` can be just modified inside a callback without returning any value (void) + if (newValue && !isDraft(newValue)) { + Object.assign(draft, newValue); + } + } else if (isPlainObject(value)) { + Object.assign(draft, value); + } + + return draft; + }); + + set(nextValue); + }), + toJSON, + }); +} diff --git a/src/renderer/utils/__tests__/storageHelper.test.ts b/src/renderer/utils/__tests__/storageHelper.test.ts index ca33014ce3..fca095b51e 100644 --- a/src/renderer/utils/__tests__/storageHelper.test.ts +++ b/src/renderer/utils/__tests__/storageHelper.test.ts @@ -25,6 +25,13 @@ describe("renderer/utils/StorageHelper", () => { message: "saved-before", // pretending as previously saved data }); + const getItem = (key: string): StorageModel => { + return Object.assign( + storageHelper.defaultValue, + remoteStorageMock.get(key), + ); + }; + storageHelper = new StorageHelper(storageKey, { autoInit: false, defaultValue: { @@ -32,12 +39,7 @@ describe("renderer/utils/StorageHelper", () => { description: "default", }, storage: { - getItem(key: string): StorageModel { - return Object.assign( - storageHelper.defaultValue, - remoteStorageMock.get(key), - ); - }, + getItem, setItem(key: string, value: StorageModel) { remoteStorageMock.set(key, value); }, @@ -51,17 +53,22 @@ describe("renderer/utils/StorageHelper", () => { autoInit: false, defaultValue: storageHelper.defaultValue, storage: { - ...storageHelper.storage, async getItem(key: string): Promise { await delay(500); // fake loading timeout - return storageHelper.storage.getItem(key); + return getItem(key); + }, + setItem(key: string, value: StorageModel) { + remoteStorageMock.set(key, value); + }, + removeItem(key: string) { + remoteStorageMock.delete(key); }, }, }); }); - it("initialized with default value", async () => { + it("initialized with default value", () => { storageHelper.init(); expect(storageHelper.key).toBe(storageKey); expect(storageHelper.get()).toEqual(storageHelper.defaultValue); diff --git a/src/renderer/utils/allowed-resource.injectable.ts b/src/renderer/utils/allowed-resource.injectable.ts new file mode 100644 index 0000000000..c9a2b505c3 --- /dev/null +++ b/src/renderer/utils/allowed-resource.injectable.ts @@ -0,0 +1,40 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { Cluster } from "../../common/cluster/cluster"; +import type { KubeResource } from "../../common/rbac"; +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { bind } from "../../common/utils"; +import hostedClusterInjectable from "../../common/cluster-store/hosted-cluster/hosted-cluster.injectable"; + +interface Dependencies { + activeCluster: Cluster; +} + +function isAllowedResource({ activeCluster }: Dependencies, resource: KubeResource | KubeResource[]) { + const resources = [resource].flat(); + + if (!activeCluster?.allowedResources) { + return false; + } + + if (resources.length === 0) { + return true; + } + + const allowedResources = new Set(activeCluster.allowedResources); + + return resources.every(resource => allowedResources.has(resource)); +} + +const isAllowedResourceInjectable = getInjectable({ + instantiate: (di) => bind(isAllowedResource, null, { + activeCluster: di.inject(hostedClusterInjectable), + }), + lifecycle: lifecycleEnum.singleton, +}); + +export default isAllowedResourceInjectable; + diff --git a/src/renderer/utils/create-storage/create-storage.injectable.ts b/src/renderer/utils/create-storage/create-storage.injectable.ts index 1f665bd002..55e57a2a07 100644 --- a/src/renderer/utils/create-storage/create-storage.injectable.ts +++ b/src/renderer/utils/create-storage/create-storage.injectable.ts @@ -7,17 +7,15 @@ import directoryForLensLocalStorageInjectable from "../../../common/directory-fo import { createStorage } from "./create-storage"; import readJsonFileInjectable from "../../../common/fs/read-json-file/read-json-file.injectable"; import writeJsonFileInjectable from "../../../common/fs/write-json-file/write-json-file.injectable"; +import { bind } from "../../../common/utils"; +import type { StorageLayer } from "../storageHelper"; const createStorageInjectable = getInjectable({ - instantiate: (di) => - createStorage({ - readJsonFile: di.inject(readJsonFileInjectable), - writeJsonFile: di.inject(writeJsonFileInjectable), - - directoryForLensLocalStorage: di.inject( - directoryForLensLocalStorageInjectable, - ), - }), + instantiate: (di) => bind(createStorage, null, { + readJsonFile: di.inject(readJsonFileInjectable), + writeJsonFile: di.inject(writeJsonFileInjectable), + directoryForLensLocalStorage: di.inject(directoryForLensLocalStorageInjectable), + }) as (key: string, defaultValue: T) => Promise>, lifecycle: lifecycleEnum.singleton, }); diff --git a/src/renderer/utils/create-storage/create-storage.ts b/src/renderer/utils/create-storage/create-storage.ts index 8382d5accf..4c6400fb16 100755 --- a/src/renderer/utils/create-storage/create-storage.ts +++ b/src/renderer/utils/create-storage/create-storage.ts @@ -6,13 +6,13 @@ // Keeps window.localStorage state in external JSON-files. // Because app creates random port between restarts => storage session wiped out each time. import path from "path"; -import { comparer, observable, reaction, toJS, when } from "mobx"; +import { comparer, observable, reaction, toJS } from "mobx"; import { StorageHelper } from "../storageHelper"; -import logger from "../../../main/logger"; -import { isTestEnv } from "../../../common/vars"; - import { getHostedClusterId } from "../../../common/utils"; -import type { JsonObject } from "type-fest"; +import type { JsonValue } from "type-fest"; +import { isTestEnv } from "../../../common/vars"; +import logger from "../../../common/logger"; +import type { StorageLayer } from ".."; const storage = observable({ initialized: false, @@ -22,65 +22,61 @@ const storage = observable({ interface Dependencies { directoryForLensLocalStorage: string; - readJsonFile: (filePath: string) => Promise; - writeJsonFile: (filePath: string, contentObject: JsonObject) => Promise; + readJsonFile: (filePath: string) => Promise; + writeJsonFile: (filePath: string, contentObject: JsonValue) => Promise; } /** * Creates a helper for saving data under the "key" intended for window.localStorage */ -export const createStorage = ({ directoryForLensLocalStorage, readJsonFile, writeJsonFile }: Dependencies) => (key: string, defaultValue: T) => { +export async function createStorage({ directoryForLensLocalStorage, readJsonFile, writeJsonFile }: Dependencies, key: string, defaultValue: T): Promise> { const { logPrefix } = StorageHelper; if (!storage.initialized) { storage.initialized = true; - (async () => { - const filePath = path.resolve(directoryForLensLocalStorage, `${getHostedClusterId() || "app"}.json`); + const filePath = path.resolve(directoryForLensLocalStorage, `${getHostedClusterId() || "app"}.json`); + + try { + const data = await readJsonFile(filePath); + + if (data && typeof data === "object") { + storage.data = data; + } + } catch { + // ignore error + } finally { + if (!isTestEnv) { + logger.info(`${logPrefix} loading finished for ${filePath}`); + } + + storage.loaded = true; + } + + const saveFile = async (state: Record = {}) => { + logger.info(`${logPrefix} saving ${filePath}`); try { - storage.data = await readJsonFile(filePath); + await writeJsonFile(filePath, state); + } catch (error) { + logger.error(`${logPrefix} saving failed: ${error}`, { + json: state, jsonFilePath: filePath, + }); } + }; - // eslint-disable-next-line no-empty - catch {} - - finally { - if (!isTestEnv) { - logger.info(`${logPrefix} loading finished for ${filePath}`); - } - - storage.loaded = true; - } - - // bind auto-saving data changes to %storage-file.json - reaction(() => toJS(storage.data), saveFile, { - delay: 250, // lazy, avoid excessive writes to fs - equals: comparer.structural, // save only when something really changed - }); - - async function saveFile(state: Record = {}) { - logger.info(`${logPrefix} saving ${filePath}`); - - try { - await writeJsonFile(filePath, state); - } catch (error) { - logger.error(`${logPrefix} saving failed: ${error}`, { - json: state, jsonFilePath: filePath, - }); - } - } - })() - .catch(error => logger.error(`${logPrefix} Failed to initialize storage: ${error}`)); + // bind auto-saving data changes to %storage-file.json + reaction(() => toJS(storage.data), saveFile, { + delay: 250, // lazy, avoid excessive writes to fs + equals: comparer.structural, // save only when something really changed + }); } return new StorageHelper(key, { autoInit: true, defaultValue, storage: { - async getItem(key: string) { - await when(() => storage.loaded); - + getItem(key: string) { return storage.data[key]; }, setItem(key: string, value: any) { @@ -91,4 +87,4 @@ export const createStorage = ({ directoryForLensLocalStorage, readJsonFile, writ }, }, }); -}; +} diff --git a/src/renderer/utils/interval.ts b/src/renderer/utils/interval.ts index de9b287998..3e70b15799 100644 --- a/src/renderer/utils/interval.ts +++ b/src/renderer/utils/interval.ts @@ -5,9 +5,14 @@ // Helper for working with time updates / data-polling callbacks -type IntervalCallback = (count: number) => void; +export interface IntervalFn { + start(runImmediately?: boolean): void; + stop(): void; + restart(runImmediately?: boolean): void; + readonly isRunning: boolean; +} -export function interval(timeSec = 1, callback: IntervalCallback, autoRun = false) { +export function interval(timeSec = 1, callback: (count: number) => void, autoRun = false): IntervalFn { let count = 0; let timer = -1; let isRunning = false; diff --git a/src/renderer/utils/is-metrics-hidden.injectable.ts b/src/renderer/utils/is-metrics-hidden.injectable.ts new file mode 100644 index 0000000000..3732b0b427 --- /dev/null +++ b/src/renderer/utils/is-metrics-hidden.injectable.ts @@ -0,0 +1,14 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import type { ClusterMetricsResourceType } from "../../common/cluster-types"; +import activeClusterEntityInjectable from "../catalog/active-cluster-entity.injectable"; + +const isMetricHiddenInjectable = getInjectable({ + instantiate: (di, { metricType }: { metricType: ClusterMetricsResourceType }) => Boolean(di.inject(activeClusterEntityInjectable)?.isMetricHidden(metricType)), + lifecycle: lifecycleEnum.transient, +}); + +export default isMetricHiddenInjectable; diff --git a/src/renderer/utils/save-file.injectable.ts b/src/renderer/utils/save-file.injectable.ts new file mode 100644 index 0000000000..593c6955dc --- /dev/null +++ b/src/renderer/utils/save-file.injectable.ts @@ -0,0 +1,13 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { saveFileDialog } from "./saveFile"; + +const openSaveFileDialogInjectable = getInjectable({ + instantiate: () => saveFileDialog, + lifecycle: lifecycleEnum.singleton, +}); + +export default openSaveFileDialogInjectable; diff --git a/src/renderer/utils/storageHelper.ts b/src/renderer/utils/storageHelper.ts index b4b1d3adf3..f391f79ade 100755 --- a/src/renderer/utils/storageHelper.ts +++ b/src/renderer/utils/storageHelper.ts @@ -4,14 +4,14 @@ */ // Helper for working with storages (e.g. window.localStorage, NodeJS/file-system, etc.) -import { action, comparer, computed, makeObservable, observable, toJS, when } from "mobx"; +import { action, comparer, computed, makeObservable, observable, observe, toJS, when } from "mobx"; import { produce, Draft, isDraft } from "immer"; import { isEqual, isPlainObject } from "lodash"; import logger from "../../main/logger"; export interface StorageAdapter { [metadata: string]: any; - getItem(key: string): T | Promise; + getItem(key: string): T; setItem(key: string, value: T): void; removeItem(key: string): void; onChange?(change: { key: string, value: T, oldValue?: T }): void; @@ -23,9 +23,19 @@ export interface StorageHelperOptions { defaultValue: T; } -export class StorageHelper { +export interface StorageLayer { + key: string; + defaultValue: T; + isDefaultValue: (value: T) => boolean; + get: () => T; + set: (value: T) => void; + reset: () => void; + merge: (value: Partial | ((draft: Draft) => Partial | void)) => void; + toJSON: () => T; +} + +export class StorageHelper implements StorageLayer { static logPrefix = "[StorageHelper]:"; - readonly storage: StorageAdapter; private data = observable.box(undefined, { deep: true, @@ -43,19 +53,18 @@ export class StorageHelper { return this.options.defaultValue; } - constructor(readonly key: string, private options: StorageHelperOptions) { + private get storage() { + return this.options.storage; + } + + constructor(readonly key: string, protected options: StorageHelperOptions) { makeObservable(this); - const { storage, autoInit = true } = options; - - this.storage = storage; - - // TODO: This code uses undocumented MobX internal to criminally permit exotic mutations without encapsulation. - this.data.observe_(({ newValue, oldValue }) => { + observe(this.data, ({ newValue, oldValue }) => { this.onChange(newValue as T, oldValue as T); }); - if (autoInit) { + if (options.autoInit ?? true) { this.init(); } } diff --git a/src/renderer/window/event-listener.injectable.ts b/src/renderer/window/event-listener.injectable.ts deleted file mode 100644 index cebb27d87e..0000000000 --- a/src/renderer/window/event-listener.injectable.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; -import type { Disposer } from "../utils"; - -function addWindowEventListener(type: K, listener: (this: Window, ev: WindowEventMap[K]) => any, options?: boolean | AddEventListenerOptions): Disposer { - window.addEventListener(type, listener, options); - - return () => void window.removeEventListener(type, listener); -} - -const windowAddEventListenerInjectable = getInjectable({ - instantiate: () => addWindowEventListener, - lifecycle: lifecycleEnum.singleton, -}); - -export default windowAddEventListenerInjectable; diff --git a/tsconfig.json b/tsconfig.json index f412e184e7..4b6cced62a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -42,7 +42,11 @@ }, "include": [ "src/**/*", - "types/*.d.ts" + "types/*.d.ts", + "build", + "extensions/**/*", + "integration/**/*", + "webpack.*.ts", ], "exclude": [ "node_modules", diff --git a/yarn.lock b/yarn.lock index 5d0e02d9ee..f1390aa0ee 100644 --- a/yarn.lock +++ b/yarn.lock @@ -979,19 +979,19 @@ dependencies: lodash "^4.17.21" -"@ogre-tools/injectable-react@3.1.1": - version "3.1.1" - resolved "https://registry.yarnpkg.com/@ogre-tools/injectable-react/-/injectable-react-3.1.1.tgz#d95ecec518ba798c36fa3a6f651fa52748e72b00" - integrity sha512-Fhb/51NzrLzkA3G5zCpNOshvm0el1gROWGHkBqq1d/8PEekcEijIL8HZ6B/ylCWjQTJ1MaYViJdzs2iNP1oQxw== +"@ogre-tools/injectable-react@3.2.0": + version "3.2.0" + resolved "https://registry.yarnpkg.com/@ogre-tools/injectable-react/-/injectable-react-3.2.0.tgz#468542e846952deb8e7a4f6757da4813ee8f11fa" + integrity sha512-VU5l0uKe86psVzEPbXl1TLlflnoL+uSeOaOCy/mAGzau4nqRb+eA4RzYgzUs/D9tDYzJ7Es+LZWD9uSyXmpyXg== dependencies: "@ogre-tools/fp" "^3.0.0" - "@ogre-tools/injectable" "^3.1.1" + "@ogre-tools/injectable" "^3.2.0" lodash "^4.17.21" -"@ogre-tools/injectable@3.1.1", "@ogre-tools/injectable@^3.1.1": - version "3.1.1" - resolved "https://registry.yarnpkg.com/@ogre-tools/injectable/-/injectable-3.1.1.tgz#2f293a90e4d3f730ebab2689fd609edc24ffc563" - integrity sha512-X7cDU2Mkcl2bP8JtR9l/Hx31jmKYEuCVJGjZIYxWlE1Nvd3HGq98oTV5uEGNP6+GjLHhXjzoscT9SKKzexyQWg== +"@ogre-tools/injectable@3.2.0", "@ogre-tools/injectable@^3.2.0": + version "3.2.0" + resolved "https://registry.yarnpkg.com/@ogre-tools/injectable/-/injectable-3.2.0.tgz#7d8f653cb3a2c0253a29422bcffd5123308600a9" + integrity sha512-aRlRdvLefJMBvFu1tRlTGNgpMbqE250lwMXT8Y6/ruC88rCL+TygCWcsJELad1OgqX1cfHkCgCYeQeohV+G3Zg== dependencies: "@ogre-tools/fp" "^3.0.0" lodash "^4.17.21"