diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml index 03d9549ea8e4..15223a349297 100644 --- a/.idea/inspectionProfiles/Project_Default.xml +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -1,6 +1,7 @@ \ No newline at end of file diff --git a/.idea/lens.iml b/.idea/lens.iml index fe491887dde3..88175e2aaafc 100644 --- a/.idea/lens.iml +++ b/.idea/lens.iml @@ -12,6 +12,9 @@ + + + diff --git a/__mocks__/electron-updater.ts b/__mocks__/electron-updater.ts new file mode 100644 index 000000000000..00c83a605c95 --- /dev/null +++ b/__mocks__/electron-updater.ts @@ -0,0 +1,23 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +// This mock exists because library causes criminal side-effect on import +export const autoUpdater = {}; diff --git a/__mocks__/node-pty.ts b/__mocks__/node-pty.ts new file mode 100644 index 000000000000..0750620e6f93 --- /dev/null +++ b/__mocks__/node-pty.ts @@ -0,0 +1,23 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +// This mock exists because library causes criminal side-effect on import +export default {}; diff --git a/integration/__tests__/cluster-pages.tests.ts b/integration/__tests__/cluster-pages.tests.ts index 64ca60380d50..d3038db1b0f9 100644 --- a/integration/__tests__/cluster-pages.tests.ts +++ b/integration/__tests__/cluster-pages.tests.ts @@ -370,9 +370,8 @@ utils.describeIf(minikubeReady(TEST_NAMESPACE))("Minikube based tests", () => { } }, 10*60*1000); - - - it("show logs and highlight the log search entries", async () => { + // TODO: Make re-rendering of KubeObjectListLayout not cause namespaceSelector to be closed + xit("show logs and highlight the log search entries", async () => { await frame.click(`a[href="/workloads"]`); await frame.click(`a[href="/pods"]`); @@ -417,7 +416,8 @@ utils.describeIf(minikubeReady(TEST_NAMESPACE))("Minikube based tests", () => { await frame.waitForSelector("div.TableCell >> text='kube-system'"); }, 10*60*1000); - it(`should create the ${TEST_NAMESPACE} and a pod in the namespace`, async () => { + // TODO: Make re-rendering of KubeObjectListLayout not cause namespaceSelector to be closed + xit(`should create the ${TEST_NAMESPACE} and a pod in the namespace`, async () => { await frame.click('a[href="/namespaces"]'); await frame.click("button.add-button"); await frame.waitForSelector("div.AddNamespaceDialog >> text='Create Namespace'"); diff --git a/package.json b/package.json index 43497e5edd53..36a971a3a1bc 100644 --- a/package.json +++ b/package.json @@ -195,8 +195,8 @@ "@hapi/call": "^8.0.1", "@hapi/subtext": "^7.0.3", "@kubernetes/client-node": "^0.16.1", - "@ogre-tools/injectable": "2.0.0", - "@ogre-tools/injectable-react": "2.0.0", + "@ogre-tools/injectable": "3.1.1", + "@ogre-tools/injectable-react": "3.1.1", "@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 0a4f81e8f35b..f45769cd85a9 100644 --- a/src/common/__tests__/base-store.test.ts +++ b/src/common/__tests__/base-store.test.ts @@ -20,29 +20,18 @@ */ import mockFs from "mock-fs"; -import { AppPaths } from "../app-paths"; import { BaseStore } from "../base-store"; import { action, comparer, makeObservable, observable, toJS } from "mobx"; import { readFileSync } from "fs"; +import { getDisForUnitTesting } from "../../test-utils/get-dis-for-unit-testing"; -AppPaths.init(); +import directoryForUserDataInjectable + from "../app-paths/directory-for-user-data/directory-for-user-data.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: { - handle: jest.fn(), on: jest.fn(), - removeAllListeners: jest.fn(), off: jest.fn(), - send: jest.fn(), }, })); @@ -105,10 +94,17 @@ describe("BaseStore", () => { let store: TestStore; beforeEach(async () => { + const dis = getDisForUnitTesting({ doGeneralOverrides: true }); + + dis.mainDi.override(directoryForUserDataInjectable, () => "some-user-data-directory"); + + await dis.runSetups(); + store = undefined; TestStore.resetInstance(); + const mockOpts = { - "tmp": { + "some-user-data-directory": { "test-store.json": JSON.stringify({}), }, }; @@ -130,7 +126,7 @@ describe("BaseStore", () => { a: "foo", b: "bar", c: "hello", }); - const data = JSON.parse(readFileSync("tmp/test-store.json").toString()); + const data = JSON.parse(readFileSync("some-user-data-directory/test-store.json").toString()); expect(data).toEqual({ a: "foo", b: "bar", c: "hello" }); }); @@ -153,7 +149,7 @@ describe("BaseStore", () => { expect(fileSpy).toHaveBeenCalledTimes(2); - const data = JSON.parse(readFileSync("tmp/test-store.json").toString()); + const data = JSON.parse(readFileSync("some-user-data-directory/test-store.json").toString()); expect(data).toEqual({ a: "a", b: "b", c: "" }); }); diff --git a/src/common/__tests__/cluster-store.test.ts b/src/common/__tests__/cluster-store.test.ts index 81923d93ea52..f22dc4be508a 100644 --- a/src/common/__tests__/cluster-store.test.ts +++ b/src/common/__tests__/cluster-store.test.ts @@ -24,17 +24,29 @@ import mockFs from "mock-fs"; import yaml from "js-yaml"; import path from "path"; import fse from "fs-extra"; -import { Cluster } from "../../main/cluster"; -import { ClusterStore } from "../cluster-store"; +import type { Cluster } from "../cluster/cluster"; +import { ClusterStore } from "../cluster-store/cluster-store"; import { Console } from "console"; import { stdout, stderr } from "process"; -import type { ClusterId } from "../cluster-types"; -import { getCustomKubeConfigPath } from "../utils"; -import { AppPaths } from "../app-paths"; +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 type { ClusterModel } from "../cluster-types"; +import type { + DependencyInjectionContainer, +} from "@ogre-tools/injectable"; + + +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"; console = new Console(stdout, stderr); -const testDataIcon = fs.readFileSync("test-data/cluster-store-migration-icon.png"); +const testDataIcon = fs.readFileSync( + "test-data/cluster-store-migration-icon.png", +); const kubeconfig = ` apiVersion: v1 clusters: @@ -59,25 +71,17 @@ users: token: kubeconfig-user-q4lm4:xxxyyyy `; -function embed(clusterId: ClusterId, contents: any): string { - const absPath = getCustomKubeConfigPath(clusterId); - - fse.ensureDirSync(path.dirname(absPath)); - fse.writeFileSync(absPath, contents, { encoding: "utf-8", mode: 0o600 }); +const embed = (directoryName: string, contents: any): string => { + fse.ensureDirSync(path.dirname(directoryName)); + fse.writeFileSync(directoryName, contents, { + encoding: "utf-8", + mode: 0o600, + }); - return absPath; -} + return directoryName; +}; jest.mock("electron", () => ({ - app: { - getVersion: () => "99.99.99", - getName: () => "lens", - setName: jest.fn(), - setPath: jest.fn(), - getPath: () => "tmp", - getLocale: () => "en", - setLoginItemSettings: jest.fn(), - }, ipcMain: { handle: jest.fn(), on: jest.fn(), @@ -87,157 +91,198 @@ jest.mock("electron", () => ({ }, })); -AppPaths.init(); +describe("cluster-store", () => { + let mainDi: DependencyInjectionContainer; + let clusterStore: ClusterStore; + let createCluster: (model: ClusterModel) => Cluster; -describe("empty config", () => { beforeEach(async () => { - ClusterStore.getInstance(false)?.unregisterIpcListener(); - ClusterStore.resetInstance(); - const mockOpts = { - "tmp": { - "lens-cluster-store.json": JSON.stringify({}), - }, - }; + const dis = getDisForUnitTesting({ doGeneralOverrides: true }); + + mockFs(); + + mainDi = dis.mainDi; - mockFs(mockOpts); + mainDi.override(directoryForUserDataInjectable, () => "some-directory-for-user-data"); - ClusterStore.createInstance(); + await dis.runSetups(); + + createCluster = mainDi.inject(createClusterInjectionToken); }); afterEach(() => { mockFs.restore(); }); - describe("with foo cluster added", () => { - beforeEach(() => { - ClusterStore.getInstance().addCluster( - new Cluster({ + describe("empty config", () => { + let getCustomKubeConfigDirectory: (directoryName: string) => string; + + beforeEach(async () => { + 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({}), + }, + }; + + mockFs(mockOpts); + + clusterStore = mainDi.inject(clusterStoreInjectable); + }); + + afterEach(() => { + mockFs.restore(); + }); + + describe("with foo cluster added", () => { + beforeEach(() => { + const cluster = createCluster({ id: "foo", contextName: "foo", preferences: { - terminalCWD: "/tmp", + terminalCWD: "/some-directory-for-user-data", icon: "data:image/jpeg;base64, iVBORw0KGgoAAAANSUhEUgAAA1wAAAKoCAYAAABjkf5", clusterName: "minikube", }, - kubeConfigPath: embed("foo", kubeconfig), - }), - ); - }); + kubeConfigPath: embed( + getCustomKubeConfigDirectory("foo"), + kubeconfig, + ), + }); - it("adds new cluster to store", async () => { - const storedCluster = ClusterStore.getInstance().getById("foo"); + clusterStore.addCluster(cluster); + }); + + it("adds new cluster to store", async () => { + const storedCluster = clusterStore.getById("foo"); - expect(storedCluster.id).toBe("foo"); - expect(storedCluster.preferences.terminalCWD).toBe("/tmp"); - expect(storedCluster.preferences.icon).toBe("data:image/jpeg;base64, iVBORw0KGgoAAAANSUhEUgAAA1wAAAKoCAYAAABjkf5"); + expect(storedCluster.id).toBe("foo"); + expect(storedCluster.preferences.terminalCWD).toBe("/some-directory-for-user-data"); + expect(storedCluster.preferences.icon).toBe( + "data:image/jpeg;base64, iVBORw0KGgoAAAANSUhEUgAAA1wAAAKoCAYAAABjkf5", + ); + }); }); - }); - describe("with prod and dev clusters added", () => { - beforeEach(() => { - const store = ClusterStore.getInstance(); + describe("with prod and dev clusters added", () => { + beforeEach(() => { + const store = clusterStore; - store.addCluster({ - id: "prod", - contextName: "foo", - preferences: { - clusterName: "prod", - }, - kubeConfigPath: embed("prod", kubeconfig), - }); - store.addCluster({ - id: "dev", - contextName: "foo2", - preferences: { - clusterName: "dev", - }, - kubeConfigPath: embed("dev", kubeconfig), + store.addCluster({ + id: "prod", + contextName: "foo", + preferences: { + clusterName: "prod", + }, + kubeConfigPath: embed( + getCustomKubeConfigDirectory("prod"), + kubeconfig, + ), + }); + store.addCluster({ + id: "dev", + contextName: "foo2", + preferences: { + clusterName: "dev", + }, + kubeConfigPath: embed( + getCustomKubeConfigDirectory("dev"), + kubeconfig, + ), + }); }); - }); - it("check if store can contain multiple clusters", () => { - expect(ClusterStore.getInstance().hasClusters()).toBeTruthy(); - expect(ClusterStore.getInstance().clusters.size).toBe(2); - }); + it("check if store can contain multiple clusters", () => { + expect(clusterStore.hasClusters()).toBeTruthy(); + expect(clusterStore.clusters.size).toBe(2); + }); - it("check if cluster's kubeconfig file saved", () => { - const file = embed("boo", "kubeconfig"); + it("check if cluster's kubeconfig file saved", () => { + const file = embed(getCustomKubeConfigDirectory("boo"), "kubeconfig"); - expect(fs.readFileSync(file, "utf8")).toBe("kubeconfig"); + expect(fs.readFileSync(file, "utf8")).toBe("kubeconfig"); + }); }); }); -}); -describe("config with existing clusters", () => { - beforeEach(() => { - ClusterStore.resetInstance(); - const mockOpts = { - "temp-kube-config": kubeconfig, - "tmp": { - "lens-cluster-store.json": JSON.stringify({ - __internal__: { - migrations: { - version: "99.99.99", - }, - }, - clusters: [ - { - id: "cluster1", - kubeConfigPath: "./temp-kube-config", - contextName: "foo", - preferences: { terminalCWD: "/foo" }, - workspace: "default", - }, - { - id: "cluster2", - kubeConfigPath: "./temp-kube-config", - contextName: "foo2", - preferences: { terminalCWD: "/foo2" }, - }, - { - id: "cluster3", - kubeConfigPath: "./temp-kube-config", - contextName: "foo", - preferences: { terminalCWD: "/foo" }, - workspace: "foo", - ownerRef: "foo", + describe("config with existing clusters", () => { + beforeEach(() => { + ClusterStore.resetInstance(); + + const mockOpts = { + "temp-kube-config": kubeconfig, + "some-directory-for-user-data": { + "lens-cluster-store.json": JSON.stringify({ + __internal__: { + migrations: { + version: "99.99.99", + }, }, - ], - }), - }, - }; + clusters: [ + { + id: "cluster1", + kubeConfigPath: "./temp-kube-config", + contextName: "foo", + preferences: { terminalCWD: "/foo" }, + workspace: "default", + }, + { + id: "cluster2", + kubeConfigPath: "./temp-kube-config", + contextName: "foo2", + preferences: { terminalCWD: "/foo2" }, + }, + { + id: "cluster3", + kubeConfigPath: "./temp-kube-config", + contextName: "foo", + preferences: { terminalCWD: "/foo" }, + workspace: "foo", + ownerRef: "foo", + }, + ], + }), + }, + }; - mockFs(mockOpts); + mockFs(mockOpts); - return ClusterStore.createInstance(); - }); + clusterStore = mainDi.inject(clusterStoreInjectable); + }); - afterEach(() => { - mockFs.restore(); - }); + afterEach(() => { + mockFs.restore(); + }); - it("allows to retrieve a cluster", () => { - const storedCluster = ClusterStore.getInstance().getById("cluster1"); + it("allows to retrieve a cluster", () => { + const storedCluster = clusterStore.getById("cluster1"); - expect(storedCluster.id).toBe("cluster1"); - expect(storedCluster.preferences.terminalCWD).toBe("/foo"); - }); + expect(storedCluster.id).toBe("cluster1"); + expect(storedCluster.preferences.terminalCWD).toBe("/foo"); + }); - it("allows getting all of the clusters", async () => { - const storedClusters = ClusterStore.getInstance().clustersList; + it("allows getting all of the clusters", async () => { + const storedClusters = clusterStore.clustersList; - expect(storedClusters.length).toBe(3); - expect(storedClusters[0].id).toBe("cluster1"); - expect(storedClusters[0].preferences.terminalCWD).toBe("/foo"); - expect(storedClusters[1].id).toBe("cluster2"); - expect(storedClusters[1].preferences.terminalCWD).toBe("/foo2"); - expect(storedClusters[2].id).toBe("cluster3"); + expect(storedClusters.length).toBe(3); + expect(storedClusters[0].id).toBe("cluster1"); + expect(storedClusters[0].preferences.terminalCWD).toBe("/foo"); + expect(storedClusters[1].id).toBe("cluster2"); + expect(storedClusters[1].preferences.terminalCWD).toBe("/foo2"); + expect(storedClusters[2].id).toBe("cluster3"); + }); }); -}); -describe("config with invalid cluster kubeconfig", () => { - beforeEach(() => { - const invalidKubeconfig = ` + describe("config with invalid cluster kubeconfig", () => { + beforeEach(() => { + const invalidKubeconfig = ` apiVersion: v1 clusters: - cluster: @@ -257,302 +302,285 @@ users: token: kubeconfig-user-q4lm4:xxxyyyy `; - ClusterStore.resetInstance(); - const mockOpts = { - "invalid-kube-config": invalidKubeconfig, - "valid-kube-config": kubeconfig, - "tmp": { - "lens-cluster-store.json": JSON.stringify({ - __internal__: { - migrations: { - version: "99.99.99", - }, - }, - clusters: [ - { - id: "cluster1", - kubeConfigPath: "./invalid-kube-config", - contextName: "test", - preferences: { terminalCWD: "/foo" }, - workspace: "foo", - }, - { - id: "cluster2", - kubeConfigPath: "./valid-kube-config", - contextName: "foo", - preferences: { terminalCWD: "/foo" }, - workspace: "default", - }, + ClusterStore.resetInstance(); - ], - }), - }, - }; + const mockOpts = { + "invalid-kube-config": invalidKubeconfig, + "valid-kube-config": kubeconfig, + "some-directory-for-user-data": { + "lens-cluster-store.json": JSON.stringify({ + __internal__: { + migrations: { + version: "99.99.99", + }, + }, + clusters: [ + { + id: "cluster1", + kubeConfigPath: "./invalid-kube-config", + contextName: "test", + preferences: { terminalCWD: "/foo" }, + workspace: "foo", + }, + { + id: "cluster2", + kubeConfigPath: "./valid-kube-config", + contextName: "foo", + preferences: { terminalCWD: "/foo" }, + workspace: "default", + }, + ], + }), + }, + }; - mockFs(mockOpts); + mockFs(mockOpts); - return ClusterStore.createInstance(); - }); + clusterStore = mainDi.inject(clusterStoreInjectable); + }); - afterEach(() => { - mockFs.restore(); - }); + afterEach(() => { + mockFs.restore(); + }); - it("does not enable clusters with invalid kubeconfig", () => { - const storedClusters = ClusterStore.getInstance().clustersList; + it("does not enable clusters with invalid kubeconfig", () => { + const storedClusters = clusterStore.clustersList; - expect(storedClusters.length).toBe(1); + expect(storedClusters.length).toBe(1); + }); }); -}); - -const minimalValidKubeConfig = JSON.stringify({ - apiVersion: "v1", - clusters: [{ - name: "minikube", - cluster: { - server: "https://192.168.64.3:8443", - }, - }], - "current-context": "minikube", - contexts: [{ - context: { - cluster: "minikube", - user: "minikube", - }, - name: "minikube", - }], - users: [{ - name: "minikube", - user: { - "client-certificate": "/Users/foo/.minikube/client.crt", - "client-key": "/Users/foo/.minikube/client.key", - }, - }], - kind: "Config", - preferences: {}, -}); -describe("pre 2.0 config with an existing cluster", () => { - beforeEach(() => { - ClusterStore.resetInstance(); - const mockOpts = { - "tmp": { - "lens-cluster-store.json": JSON.stringify({ - __internal__: { - migrations: { - version: "1.0.0", + describe("pre 2.0 config with an existing cluster", () => { + beforeEach(() => { + ClusterStore.resetInstance(); + + const mockOpts = { + "some-directory-for-user-data": { + "lens-cluster-store.json": JSON.stringify({ + __internal__: { + migrations: { + version: "1.0.0", + }, }, - }, - cluster1: minimalValidKubeConfig, - }), - }, - }; + cluster1: minimalValidKubeConfig, + }), + }, + }; - mockFs(mockOpts); + mockFs(mockOpts); - return ClusterStore.createInstance(); - }); + clusterStore = mainDi.inject(clusterStoreInjectable); + }); - afterEach(() => { - mockFs.restore(); - }); + afterEach(() => { + mockFs.restore(); + }); - it("migrates to modern format with kubeconfig in a file", async () => { - const config = ClusterStore.getInstance().clustersList[0].kubeConfigPath; + it("migrates to modern format with kubeconfig in a file", async () => { + const config = clusterStore.clustersList[0].kubeConfigPath; - expect(fs.readFileSync(config, "utf8")).toContain(`"contexts":[`); + expect(fs.readFileSync(config, "utf8")).toContain(`"contexts":[`); + }); }); -}); -describe("pre 2.6.0 config with a cluster that has arrays in auth config", () => { - beforeEach(() => { - ClusterStore.resetInstance(); - const mockOpts = { - "tmp": { - "lens-cluster-store.json": JSON.stringify({ - __internal__: { - migrations: { - version: "2.4.1", + describe("pre 2.6.0 config with a cluster that has arrays in auth config", () => { + beforeEach(() => { + ClusterStore.resetInstance(); + const mockOpts = { + "some-directory-for-user-data": { + "lens-cluster-store.json": JSON.stringify({ + __internal__: { + migrations: { + version: "2.4.1", + }, }, - }, - cluster1: { - kubeConfig: JSON.stringify({ - apiVersion: "v1", - clusters: [{ - cluster: { - server: "https://10.211.55.6:8443", - }, - name: "minikube", - }], - contexts: [{ - context: { - cluster: "minikube", - user: "minikube", - name: "minikube", - }, - name: "minikube", - }], - "current-context": "minikube", - kind: "Config", - preferences: {}, - users: [{ - name: "minikube", - user: { - "client-certificate": "/Users/foo/.minikube/client.crt", - "client-key": "/Users/foo/.minikube/client.key", - "auth-provider": { - config: { - "access-token": [ - "should be string", - ], - expiry: [ - "should be string", - ], + cluster1: { + kubeConfig: JSON.stringify({ + apiVersion: "v1", + clusters: [ + { + cluster: { + server: "https://10.211.55.6:8443", }, + name: "minikube", }, - }, - }], - }), - }, - }), - }, - }; + ], + contexts: [ + { + context: { + cluster: "minikube", + user: "minikube", + name: "minikube", + }, + name: "minikube", + }, + ], + "current-context": "minikube", + kind: "Config", + preferences: {}, + users: [ + { + name: "minikube", + user: { + "client-certificate": "/Users/foo/.minikube/client.crt", + "client-key": "/Users/foo/.minikube/client.key", + "auth-provider": { + config: { + "access-token": ["should be string"], + expiry: ["should be string"], + }, + }, + }, + }, + ], + }), + }, + }), + }, + }; - mockFs(mockOpts); + mockFs(mockOpts); - return ClusterStore.createInstance(); - }); + clusterStore = mainDi.inject(clusterStoreInjectable); + }); - afterEach(() => { - mockFs.restore(); - }); + afterEach(() => { + mockFs.restore(); + }); - it("replaces array format access token and expiry into string", async () => { - const file = ClusterStore.getInstance().clustersList[0].kubeConfigPath; - const config = fs.readFileSync(file, "utf8"); - const kc = yaml.load(config) as Record; + it("replaces array format access token and expiry into string", async () => { + const file = clusterStore.clustersList[0].kubeConfigPath; + const config = fs.readFileSync(file, "utf8"); + const kc = yaml.load(config) as Record; - expect(kc.users[0].user["auth-provider"].config["access-token"]).toBe("should be string"); - expect(kc.users[0].user["auth-provider"].config["expiry"]).toBe("should be string"); + expect(kc.users[0].user["auth-provider"].config["access-token"]).toBe( + "should be string", + ); + expect(kc.users[0].user["auth-provider"].config["expiry"]).toBe( + "should be string", + ); + }); }); -}); -describe("pre 2.6.0 config with a cluster icon", () => { - beforeEach(() => { - ClusterStore.resetInstance(); - const mockOpts = { - "tmp": { - "lens-cluster-store.json": JSON.stringify({ - __internal__: { - migrations: { - version: "2.4.1", + describe("pre 2.6.0 config with a cluster icon", () => { + beforeEach(() => { + ClusterStore.resetInstance(); + const mockOpts = { + "some-directory-for-user-data": { + "lens-cluster-store.json": JSON.stringify({ + __internal__: { + migrations: { + version: "2.4.1", + }, }, - }, - cluster1: { - kubeConfig: minimalValidKubeConfig, - icon: "icon_path", - preferences: { - terminalCWD: "/tmp", + cluster1: { + kubeConfig: minimalValidKubeConfig, + icon: "icon_path", + preferences: { + terminalCWD: "/some-directory-for-user-data", + }, }, - }, - }), - "icon_path": testDataIcon, - }, - }; - - mockFs(mockOpts); - - return ClusterStore.createInstance(); - }); - - afterEach(() => { - mockFs.restore(); - }); - - it("moves the icon into preferences", async () => { - const storedClusterData = ClusterStore.getInstance().clustersList[0]; + }), + icon_path: testDataIcon, + }, + }; - expect(Object.prototype.hasOwnProperty.call(storedClusterData, "icon")).toBe(false); - expect(Object.prototype.hasOwnProperty.call(storedClusterData.preferences, "icon")).toBe(true); - expect(storedClusterData.preferences.icon.startsWith("data:;base64,")).toBe(true); - }); -}); + mockFs(mockOpts); -describe("for a pre 2.7.0-beta.0 config without a workspace", () => { - beforeEach(() => { - ClusterStore.resetInstance(); - const mockOpts = { - "tmp": { - "lens-cluster-store.json": JSON.stringify({ - __internal__: { - migrations: { - version: "2.6.6", - }, - }, - cluster1: { - kubeConfig: minimalValidKubeConfig, - preferences: { - terminalCWD: "/tmp", - }, - }, - }), - }, - }; + clusterStore = mainDi.inject(clusterStoreInjectable); + }); - mockFs(mockOpts); + afterEach(() => { + mockFs.restore(); + }); - return ClusterStore.createInstance(); - }); + it("moves the icon into preferences", async () => { + const storedClusterData = clusterStore.clustersList[0]; - afterEach(() => { - mockFs.restore(); + expect(Object.prototype.hasOwnProperty.call(storedClusterData, "icon")).toBe(false); + expect(Object.prototype.hasOwnProperty.call(storedClusterData.preferences, "icon")).toBe(true); + expect(storedClusterData.preferences.icon.startsWith("data:;base64,")).toBe(true); + }); }); -}); -describe("pre 3.6.0-beta.1 config with an existing cluster", () => { - beforeEach(() => { - ClusterStore.resetInstance(); - const mockOpts = { - "tmp": { - "lens-cluster-store.json": JSON.stringify({ - __internal__: { - migrations: { - version: "3.5.0", - }, - }, - clusters: [ - { - id: "cluster1", - kubeConfig: minimalValidKubeConfig, - contextName: "cluster", - preferences: { - icon: "store://icon_path", + 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({ + __internal__: { + migrations: { + version: "3.5.0", }, }, - ], - }), - "icon_path": testDataIcon, - }, - }; + clusters: [ + { + id: "cluster1", + kubeConfig: minimalValidKubeConfig, + contextName: "cluster", + preferences: { + icon: "store://icon_path", + }, + }, + ], + }), + icon_path: testDataIcon, + }, + }; - mockFs(mockOpts); + mockFs(mockOpts); - return ClusterStore.createInstance(); - }); + clusterStore = mainDi.inject(clusterStoreInjectable); + }); - afterEach(() => { - mockFs.restore(); - }); + afterEach(() => { + mockFs.restore(); + }); - it("migrates to modern format with kubeconfig in a file", async () => { - const config = ClusterStore.getInstance().clustersList[0].kubeConfigPath; + it("migrates to modern format with kubeconfig in a file", async () => { + const config = clusterStore.clustersList[0].kubeConfigPath; - expect(fs.readFileSync(config, "utf8")).toBe(minimalValidKubeConfig); - }); + expect(fs.readFileSync(config, "utf8")).toBe(minimalValidKubeConfig); + }); - it("migrates to modern format with icon not in file", async () => { - const { icon } = ClusterStore.getInstance().clustersList[0].preferences; + it("migrates to modern format with icon not in file", async () => { + const { icon } = clusterStore.clustersList[0].preferences; - expect(icon.startsWith("data:;base64,")).toBe(true); + expect(icon.startsWith("data:;base64,")).toBe(true); + }); }); }); + +const minimalValidKubeConfig = JSON.stringify({ + apiVersion: "v1", + clusters: [ + { + name: "minikube", + cluster: { + server: "https://192.168.64.3:8443", + }, + }, + ], + "current-context": "minikube", + contexts: [ + { + context: { + cluster: "minikube", + user: "minikube", + }, + name: "minikube", + }, + ], + users: [ + { + name: "minikube", + user: { + "client-certificate": "/Users/foo/.minikube/client.crt", + "client-key": "/Users/foo/.minikube/client.key", + }, + }, + ], + kind: "Config", + preferences: {}, +}); diff --git a/src/common/__tests__/event-bus.test.ts b/src/common/__tests__/event-bus.test.ts index 92e4d0628c6a..3218bfd4202b 100644 --- a/src/common/__tests__/event-bus.test.ts +++ b/src/common/__tests__/event-bus.test.ts @@ -19,7 +19,7 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import { appEventBus, AppEvent } from "../event-bus"; +import { appEventBus, AppEvent } from "../app-event-bus/event-bus"; import { Console } from "console"; import { stdout, stderr } from "process"; diff --git a/src/common/__tests__/hotbar-store.test.ts b/src/common/__tests__/hotbar-store.test.ts index 95a244f3c103..e0fbd568331f 100644 --- a/src/common/__tests__/hotbar-store.test.ts +++ b/src/common/__tests__/hotbar-store.test.ts @@ -23,10 +23,11 @@ import { anyObject } from "jest-mock-extended"; import { merge } from "lodash"; import mockFs from "mock-fs"; import logger from "../../main/logger"; -import { AppPaths } from "../app-paths"; import type { CatalogEntity, CatalogEntityData, CatalogEntityKindData } from "../catalog"; -import { ClusterStore } from "../cluster-store"; 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: { @@ -109,37 +110,24 @@ const awsCluster = getMockCatalogEntity({ }, }); -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(), - }, -})); +describe("HotbarStore", () => { + beforeEach(async () => { + const di = getDiForUnitTesting({ doGeneralOverrides: true }); -AppPaths.init(); + di.override(directoryForUserDataInjectable, () => "some-directory-for-user-data"); + + await di.runSetups(); -describe("HotbarStore", () => { - beforeEach(() => { mockFs({ - "tmp": { + "some-directory-for-user-data": { "lens-hotbar-store.json": JSON.stringify({}), }, }); - ClusterStore.createInstance(); + HotbarStore.createInstance(); }); afterEach(() => { - ClusterStore.resetInstance(); HotbarStore.resetInstance(); mockFs.restore(); }); @@ -339,7 +327,7 @@ describe("HotbarStore", () => { beforeEach(() => { HotbarStore.resetInstance(); const mockOpts = { - "tmp": { + "some-directory-for-user-data": { "lens-hotbar-store.json": JSON.stringify({ __internal__: { migrations: { diff --git a/src/common/__tests__/user-store.test.ts b/src/common/__tests__/user-store.test.ts index f53e3286c27d..76bf138cb6a4 100644 --- a/src/common/__tests__/user-store.test.ts +++ b/src/common/__tests__/user-store.test.ts @@ -42,19 +42,40 @@ import { Console } from "console"; import { SemVer } from "semver"; import electron from "electron"; import { stdout, stderr } from "process"; -import type { ClusterStoreModel } from "../cluster-store"; -import { AppPaths } from "../app-paths"; +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); -AppPaths.init(); describe("user store tests", () => { + let userStore: UserStore; + 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(); + }); + + afterEach(() => { + mockFs.restore(); + }); + describe("for an empty config", () => { beforeEach(() => { - mockFs({ tmp: { "config.json": "{}", "kube_config": "{}" }}); + mockFs({ "some-directory-for-user-data": { "config.json": "{}", "kube_config": "{}" }}); - (UserStore.createInstance() as any).refreshNewContexts = jest.fn(() => Promise.resolve()); + userStore = mainDi.inject(userStoreInjectable); }); afterEach(() => { @@ -63,46 +84,38 @@ describe("user store tests", () => { }); it("allows setting and retrieving lastSeenAppVersion", () => { - const us = UserStore.getInstance(); - - us.lastSeenAppVersion = "1.2.3"; - expect(us.lastSeenAppVersion).toBe("1.2.3"); + userStore.lastSeenAppVersion = "1.2.3"; + expect(userStore.lastSeenAppVersion).toBe("1.2.3"); }); it("allows setting and getting preferences", () => { - const us = UserStore.getInstance(); + userStore.httpsProxy = "abcd://defg"; - us.httpsProxy = "abcd://defg"; + expect(userStore.httpsProxy).toBe("abcd://defg"); + expect(userStore.colorTheme).toBe(defaultTheme); - expect(us.httpsProxy).toBe("abcd://defg"); - expect(us.colorTheme).toBe(defaultTheme); - - us.colorTheme = "light"; - expect(us.colorTheme).toBe("light"); + userStore.colorTheme = "light"; + expect(userStore.colorTheme).toBe("light"); }); it("correctly resets theme to default value", async () => { - const us = UserStore.getInstance(); - - us.colorTheme = "some other theme"; - us.resetTheme(); - expect(us.colorTheme).toBe(defaultTheme); + userStore.colorTheme = "some other theme"; + userStore.resetTheme(); + expect(userStore.colorTheme).toBe(defaultTheme); }); it("correctly calculates if the last seen version is an old release", () => { - const us = UserStore.getInstance(); + expect(userStore.isNewVersion).toBe(true); - expect(us.isNewVersion).toBe(true); - - us.lastSeenAppVersion = (new SemVer(electron.app.getVersion())).inc("major").format(); - expect(us.isNewVersion).toBe(false); + userStore.lastSeenAppVersion = (new SemVer(electron.app.getVersion())).inc("major").format(); + expect(userStore.isNewVersion).toBe(false); }); }); describe("migrations", () => { beforeEach(() => { mockFs({ - "tmp": { + "some-directory-for-user-data": { "config.json": JSON.stringify({ user: { username: "foobar" }, preferences: { colorTheme: "light" }, @@ -112,7 +125,7 @@ describe("user store tests", () => { clusters: [ { id: "foobar", - kubeConfigPath: "tmp/extension_data/foo/bar", + kubeConfigPath: "some-directory-for-user-data/extension_data/foo/bar", }, { id: "barfoo", @@ -129,7 +142,7 @@ describe("user store tests", () => { }, }); - UserStore.createInstance(); + userStore = mainDi.inject(userStoreInjectable); }); afterEach(() => { @@ -138,16 +151,12 @@ describe("user store tests", () => { }); it("sets last seen app version to 0.0.0", () => { - const us = UserStore.getInstance(); - - expect(us.lastSeenAppVersion).toBe("0.0.0"); + expect(userStore.lastSeenAppVersion).toBe("0.0.0"); }); it.only("skips clusters for adding to kube-sync with files under extension_data/", () => { - const us = UserStore.getInstance(); - - expect(us.syncKubeconfigEntries.has("tmp/extension_data/foo/bar")).toBe(false); - expect(us.syncKubeconfigEntries.has("some/other/path")).toBe(true); + expect(userStore.syncKubeconfigEntries.has("some-directory-for-user-data/extension_data/foo/bar")).toBe(false); + expect(userStore.syncKubeconfigEntries.has("some/other/path")).toBe(true); }); }); }); diff --git a/src/common/app-event-bus/app-event-bus.injectable.ts b/src/common/app-event-bus/app-event-bus.injectable.ts new file mode 100644 index 000000000000..d8973ed30981 --- /dev/null +++ b/src/common/app-event-bus/app-event-bus.injectable.ts @@ -0,0 +1,29 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { appEventBus } from "./event-bus"; + +const appEventBusInjectable = getInjectable({ + instantiate: () => appEventBus, + lifecycle: lifecycleEnum.singleton, +}); + +export default appEventBusInjectable; diff --git a/src/common/event-bus.ts b/src/common/app-event-bus/event-bus.ts similarity index 96% rename from src/common/event-bus.ts rename to src/common/app-event-bus/event-bus.ts index 4c9d08702898..30c9896b19ac 100644 --- a/src/common/event-bus.ts +++ b/src/common/app-event-bus/event-bus.ts @@ -19,7 +19,7 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import { EventEmitter } from "./event-emitter"; +import { EventEmitter } from "../event-emitter"; export type AppEvent = { name: string; diff --git a/src/common/app-paths.ts b/src/common/app-paths.ts deleted file mode 100644 index 6802cd8f8850..000000000000 --- a/src/common/app-paths.ts +++ /dev/null @@ -1,128 +0,0 @@ -/** - * Copyright (c) 2021 OpenLens Authors - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to - * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of - * the Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER - * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN - * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ - -import { app, ipcMain, ipcRenderer } from "electron"; -import { observable, when } from "mobx"; -import path from "path"; -import logger from "./logger"; -import { fromEntries } from "./utils/objects"; -import { toJS } from "./utils/toJS"; -import { isWindows } from "./vars"; - -export type PathName = Parameters[0]; - -const pathNames: PathName[] = [ - "home", - "appData", - "userData", - "cache", - "temp", - "exe", - "module", - "desktop", - "documents", - "downloads", - "music", - "pictures", - "videos", - "logs", - "crashDumps", -]; - -if (isWindows) { - pathNames.push("recent"); -} - -export class AppPaths { - private static paths = observable.box | undefined>(); - private static readonly ipcChannel = "get-app-paths"; - - /** - * Initializes the local copy of the paths from electron. - */ - static async init(): Promise { - logger.info(`[APP-PATHS]: initializing`); - - if (AppPaths.paths.get()) { - return void logger.error("[APP-PATHS]: init called more than once"); - } - - if (ipcMain) { - AppPaths.initMain(); - } else { - await AppPaths.initRenderer(); - } - } - - private static initMain(): void { - if (process.env.CICD) { - app.setPath("appData", process.env.CICD); - } - - app.setPath("userData", path.join(app.getPath("appData"), app.getName())); - - const getPath = (pathName: PathName) => { - try { - return app.getPath(pathName); - } catch { - logger.debug(`[APP-PATHS] No path found for ${pathName}`); - - return ""; - } - }; - - AppPaths.paths.set(fromEntries(pathNames.map(pathName => [pathName, getPath(pathName)] as const).filter(([, path]) => path))); - ipcMain.handle(AppPaths.ipcChannel, () => toJS(AppPaths.paths.get())); - } - - private static async initRenderer(): Promise { - const paths = await ipcRenderer.invoke(AppPaths.ipcChannel); - - if (!paths || typeof paths !== "object") { - throw Object.assign(new Error("[APP-PATHS]: ipc handler returned unexpected data"), { data: paths }); - } - - AppPaths.paths.set(paths); - } - - /** - * An alternative to `app.getPath()` for use in renderer and common. - * This function throws if called before initialization. - * @param name The name of the path field - */ - static get(name: PathName): string { - if (!AppPaths.paths.get()) { - throw new Error("AppPaths.init() has not been called"); - } - - return AppPaths.paths.get()[name]; - } - - /** - * An async version of `AppPaths.get()` which waits for `AppPaths.init()` to - * be called before returning - */ - static async getAsync(name: PathName): Promise { - await when(() => Boolean(AppPaths.paths.get())); - - return AppPaths.paths.get()[name]; - } -} diff --git a/src/common/app-paths/app-path-injection-token.ts b/src/common/app-paths/app-path-injection-token.ts new file mode 100644 index 000000000000..fcc3d18e3bc3 --- /dev/null +++ b/src/common/app-paths/app-path-injection-token.ts @@ -0,0 +1,31 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +import { getInjectionToken } from "@ogre-tools/injectable"; +import type { PathName } from "./app-path-names"; +import { createChannel } from "../ipc-channel/create-channel/create-channel"; + +export type AppPaths = Record; + +export const appPathsInjectionToken = getInjectionToken(); + +export const appPathsIpcChannel = createChannel("app-paths"); + + diff --git a/src/common/app-paths/app-path-names.ts b/src/common/app-paths/app-path-names.ts new file mode 100644 index 000000000000..3de302c26965 --- /dev/null +++ b/src/common/app-paths/app-path-names.ts @@ -0,0 +1,42 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +import type { app as electronApp } from "electron"; + +export type PathName = Parameters[0]; + +export const pathNames: PathName[] = [ + "home", + "appData", + "userData", + "cache", + "temp", + "exe", + "module", + "desktop", + "documents", + "downloads", + "music", + "pictures", + "videos", + "logs", + "crashDumps", + "recent", +]; diff --git a/src/common/app-paths/app-paths.test.ts b/src/common/app-paths/app-paths.test.ts new file mode 100644 index 000000000000..d0aea188176c --- /dev/null +++ b/src/common/app-paths/app-paths.test.ts @@ -0,0 +1,160 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +import type { DependencyInjectionContainer } from "@ogre-tools/injectable"; +import { AppPaths, appPathsInjectionToken } from "./app-path-injection-token"; +import getElectronAppPathInjectable from "../../main/app-paths/get-electron-app-path/get-electron-app-path.injectable"; +import { getDisForUnitTesting } from "../../test-utils/get-dis-for-unit-testing"; +import type { PathName } from "./app-path-names"; +import setElectronAppPathInjectable from "../../main/app-paths/set-electron-app-path/set-electron-app-path.injectable"; +import appNameInjectable from "../../main/app-paths/app-name/app-name.injectable"; +import directoryForIntegrationTestingInjectable from "../../main/app-paths/directory-for-integration-testing/directory-for-integration-testing.injectable"; +import path from "path"; + +describe("app-paths", () => { + let mainDi: DependencyInjectionContainer; + let rendererDi: DependencyInjectionContainer; + let runSetups: () => Promise; + + beforeEach(() => { + const dis = getDisForUnitTesting({ doGeneralOverrides: true }); + + mainDi = dis.mainDi; + rendererDi = dis.rendererDi; + runSetups = dis.runSetups; + + const defaultAppPathsStub: AppPaths = { + appData: "some-app-data", + cache: "some-cache", + crashDumps: "some-crash-dumps", + desktop: "some-desktop", + documents: "some-documents", + downloads: "some-downloads", + exe: "some-exe", + home: "some-home-path", + logs: "some-logs", + module: "some-module", + music: "some-music", + pictures: "some-pictures", + recent: "some-recent", + temp: "some-temp", + videos: "some-videos", + userData: "some-irrelevant", + }; + + mainDi.override( + getElectronAppPathInjectable, + () => + (key: PathName): string | null => + defaultAppPathsStub[key], + ); + + mainDi.override( + setElectronAppPathInjectable, + () => + (key: PathName, path: string): void => { + defaultAppPathsStub[key] = path; + }, + ); + + mainDi.override(appNameInjectable, () => "some-app-name"); + }); + + describe("normally", () => { + beforeEach(async () => { + await runSetups(); + }); + + it("given in renderer, when injecting app paths, returns application specific app paths", () => { + const actual = rendererDi.inject(appPathsInjectionToken); + + expect(actual).toEqual({ + appData: "some-app-data", + cache: "some-cache", + crashDumps: "some-crash-dumps", + desktop: "some-desktop", + documents: "some-documents", + downloads: "some-downloads", + exe: "some-exe", + home: "some-home-path", + logs: "some-logs", + module: "some-module", + music: "some-music", + pictures: "some-pictures", + recent: "some-recent", + temp: "some-temp", + videos: "some-videos", + userData: `some-app-data${path.sep}some-app-name`, + }); + }); + + it("given in main, when injecting app paths, returns application specific app paths", () => { + const actual = mainDi.inject(appPathsInjectionToken); + + expect(actual).toEqual({ + appData: "some-app-data", + cache: "some-cache", + crashDumps: "some-crash-dumps", + desktop: "some-desktop", + documents: "some-documents", + downloads: "some-downloads", + exe: "some-exe", + home: "some-home-path", + logs: "some-logs", + module: "some-module", + music: "some-music", + pictures: "some-pictures", + recent: "some-recent", + temp: "some-temp", + videos: "some-videos", + userData: `some-app-data${path.sep}some-app-name`, + }); + }); + }); + + describe("when running integration tests", () => { + beforeEach(async () => { + mainDi.override( + directoryForIntegrationTestingInjectable, + () => "some-integration-testing-app-data", + ); + + await runSetups(); + }); + + it("given in renderer, when injecting path for app data, has integration specific app data path", () => { + const { appData, userData } = rendererDi.inject(appPathsInjectionToken); + + expect({ appData, userData }).toEqual({ + appData: "some-integration-testing-app-data", + userData: `some-integration-testing-app-data${path.sep}some-app-name`, + }); + }); + + it("given in main, when injecting path for app data, has integration specific app data path", () => { + const { appData, userData } = rendererDi.inject(appPathsInjectionToken); + + expect({ appData, userData }).toEqual({ + appData: "some-integration-testing-app-data", + userData: `some-integration-testing-app-data${path.sep}some-app-name`, + }); + }); + }); +}); diff --git a/src/common/utils/local-kubeconfig.ts b/src/common/app-paths/directory-for-binaries/directory-for-binaries.injectable.ts similarity index 73% rename from src/common/utils/local-kubeconfig.ts rename to src/common/app-paths/directory-for-binaries/directory-for-binaries.injectable.ts index 2b2d6e6241bc..dd3162168104 100644 --- a/src/common/utils/local-kubeconfig.ts +++ b/src/common/app-paths/directory-for-binaries/directory-for-binaries.injectable.ts @@ -18,16 +18,15 @@ * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ - +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; import path from "path"; -import * as uuid from "uuid"; -import { AppPaths } from "../app-paths"; -import type { ClusterId } from "../cluster-types"; +import directoryForUserDataInjectable from "../directory-for-user-data/directory-for-user-data.injectable"; + +const directoryForBinariesInjectable = getInjectable({ + instantiate: (di) => + path.join(di.inject(directoryForUserDataInjectable), "binaries"), -export function storedKubeConfigFolder(): string { - return path.resolve(AppPaths.get("userData"), "kubeconfigs"); -} + lifecycle: lifecycleEnum.singleton, +}); -export function getCustomKubeConfigPath(clusterId: ClusterId = uuid.v4()): string { - return path.resolve(storedKubeConfigFolder(), clusterId); -} +export default directoryForBinariesInjectable; diff --git a/src/common/app-paths/directory-for-downloads/directory-for-downloads.injectable.ts b/src/common/app-paths/directory-for-downloads/directory-for-downloads.injectable.ts new file mode 100644 index 000000000000..35a4d160ce35 --- /dev/null +++ b/src/common/app-paths/directory-for-downloads/directory-for-downloads.injectable.ts @@ -0,0 +1,29 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { appPathsInjectionToken } from "../app-path-injection-token"; + +const directoryForDownloadsInjectable = getInjectable({ + instantiate: (di) => di.inject(appPathsInjectionToken).downloads, + lifecycle: lifecycleEnum.singleton, +}); + +export default directoryForDownloadsInjectable; diff --git a/src/common/app-paths/directory-for-exes/directory-for-exes.injectable.ts b/src/common/app-paths/directory-for-exes/directory-for-exes.injectable.ts new file mode 100644 index 000000000000..9a9edda0fa3b --- /dev/null +++ b/src/common/app-paths/directory-for-exes/directory-for-exes.injectable.ts @@ -0,0 +1,29 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { appPathsInjectionToken } from "../app-path-injection-token"; + +const directoryForExesInjectable = getInjectable({ + instantiate: (di) => di.inject(appPathsInjectionToken).exe, + lifecycle: lifecycleEnum.singleton, +}); + +export default directoryForExesInjectable; 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/directory-for-kube-configs.injectable.ts new file mode 100644 index 000000000000..db61207efedf --- /dev/null +++ b/src/common/app-paths/directory-for-kube-configs/directory-for-kube-configs.injectable.ts @@ -0,0 +1,32 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import directoryForUserDataInjectable from "../directory-for-user-data/directory-for-user-data.injectable"; +import path from "path"; + +const directoryForKubeConfigsInjectable = getInjectable({ + instantiate: (di) => + path.resolve(di.inject(directoryForUserDataInjectable), "kubeconfigs"), + + lifecycle: lifecycleEnum.singleton, +}); + +export default directoryForKubeConfigsInjectable; diff --git a/src/common/app-paths/directory-for-temp/directory-for-temp.injectable.ts b/src/common/app-paths/directory-for-temp/directory-for-temp.injectable.ts new file mode 100644 index 000000000000..6faef8622016 --- /dev/null +++ b/src/common/app-paths/directory-for-temp/directory-for-temp.injectable.ts @@ -0,0 +1,29 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { appPathsInjectionToken } from "../app-path-injection-token"; + +const directoryForTempInjectable = getInjectable({ + instantiate: (di) => di.inject(appPathsInjectionToken).temp, + lifecycle: lifecycleEnum.singleton, +}); + +export default directoryForTempInjectable; 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/directory-for-user-data.injectable.ts new file mode 100644 index 000000000000..c4ead5566ae3 --- /dev/null +++ b/src/common/app-paths/directory-for-user-data/directory-for-user-data.injectable.ts @@ -0,0 +1,29 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { appPathsInjectionToken } from "../app-path-injection-token"; + +const directoryForUserDataInjectable = getInjectable({ + instantiate: (di) => di.inject(appPathsInjectionToken).userData, + lifecycle: lifecycleEnum.singleton, +}); + +export default directoryForUserDataInjectable; 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/get-custom-kube-config-directory.injectable.ts new file mode 100644 index 000000000000..c781b610a862 --- /dev/null +++ b/src/common/app-paths/get-custom-kube-config-directory/get-custom-kube-config-directory.injectable.ts @@ -0,0 +1,38 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import path from "path"; +import directoryForKubeConfigsInjectable from "../directory-for-kube-configs/directory-for-kube-configs.injectable"; + +const getCustomKubeConfigDirectoryInjectable = getInjectable({ + instantiate: (di) => (directoryName: string) => { + const directoryForKubeConfigs = di.inject(directoryForKubeConfigsInjectable); + + return path.resolve( + directoryForKubeConfigs, + directoryName, + ); + }, + + lifecycle: lifecycleEnum.singleton, +}); + +export default getCustomKubeConfigDirectoryInjectable; diff --git a/src/common/base-store.ts b/src/common/base-store.ts index 9f00ec3fbbe9..007e68d00501 100644 --- a/src/common/base-store.ts +++ b/src/common/base-store.ts @@ -30,7 +30,9 @@ import { broadcastMessage, ipcMainOn, ipcRendererOn } from "./ipc"; import isEqual from "lodash/isEqual"; import { isTestEnv } from "./vars"; import { kebabCase } from "lodash"; -import { AppPaths } from "./app-paths"; +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"; export interface BaseStoreParams extends ConfOptions { syncOptions?: { @@ -102,7 +104,9 @@ export abstract class BaseStore extends Singleton { } protected cwd() { - return AppPaths.get("userData"); + const di = getLegacyGlobalDiForExtensionApi(); + + return di.inject(directoryForUserDataInjectable); } protected saveToFile(model: T) { diff --git a/src/common/catalog-entities/kubernetes-cluster.ts b/src/common/catalog-entities/kubernetes-cluster.ts index 6901a9dad230..d62effc3876f 100644 --- a/src/common/catalog-entities/kubernetes-cluster.ts +++ b/src/common/catalog-entities/kubernetes-cluster.ts @@ -22,7 +22,7 @@ import { catalogCategoryRegistry } from "../catalog/catalog-category-registry"; import { CatalogEntity, CatalogEntityActionContext, CatalogEntityContextMenuContext, CatalogEntityMetadata, CatalogEntityStatus, CatalogCategory, CatalogCategorySpec } from "../catalog"; import { clusterActivateHandler, clusterDisconnectHandler } from "../cluster-ipc"; -import { ClusterStore } from "../cluster-store"; +import { ClusterStore } from "../cluster-store/cluster-store"; import { broadcastMessage, requestMain } from "../ipc"; import { app } from "electron"; import type { CatalogEntitySpec } from "../catalog/catalog-entity"; diff --git a/src/common/cluster-store/cluster-store.injectable.ts b/src/common/cluster-store/cluster-store.injectable.ts new file mode 100644 index 000000000000..8b1a23ff1d7a --- /dev/null +++ b/src/common/cluster-store/cluster-store.injectable.ts @@ -0,0 +1,34 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { ClusterStore } from "./cluster-store"; +import { createClusterInjectionToken } from "../cluster/create-cluster-injection-token"; + +const clusterStoreInjectable = getInjectable({ + instantiate: (di) => + ClusterStore.createInstance({ + createCluster: di.inject(createClusterInjectionToken), + }), + + lifecycle: lifecycleEnum.singleton, +}); + +export default clusterStoreInjectable; diff --git a/src/common/cluster-store.ts b/src/common/cluster-store/cluster-store.ts similarity index 86% rename from src/common/cluster-store.ts rename to src/common/cluster-store/cluster-store.ts index a3dbc97645cf..51035254d7e6 100644 --- a/src/common/cluster-store.ts +++ b/src/common/cluster-store/cluster-store.ts @@ -18,17 +18,18 @@ * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ + import { ipcMain, ipcRenderer, webFrame } from "electron"; import { action, comparer, computed, makeObservable, observable, reaction } from "mobx"; -import { BaseStore } from "./base-store"; -import { Cluster } from "../main/cluster"; -import migrations from "../migrations/cluster-store"; -import logger from "../main/logger"; -import { appEventBus } from "./event-bus"; -import { ipcMainHandle, requestMain } from "./ipc"; -import { disposer, toJS } from "./utils"; -import type { ClusterModel, ClusterId, ClusterState } from "./cluster-types"; +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"; export interface ClusterStoreModel { clusters?: ClusterModel[]; @@ -36,13 +37,17 @@ export interface ClusterStoreModel { const initialStates = "cluster:states"; +interface Dependencies { + createCluster: (model: ClusterModel) => Cluster +} + export class ClusterStore extends BaseStore { readonly displayName = "ClusterStore"; clusters = observable.map(); protected disposer = disposer(); - constructor() { + constructor(private dependencies: Dependencies) { super({ configName: "lens-cluster-store", accessPropertiesByDotNotation: false, // To make dots safe in cluster context names @@ -123,7 +128,7 @@ export class ClusterStore extends BaseStore { const cluster = clusterOrModel instanceof Cluster ? clusterOrModel - : new Cluster(clusterOrModel); + : this.dependencies.createCluster(clusterOrModel); this.clusters.set(cluster.id, cluster); @@ -143,7 +148,7 @@ export class ClusterStore extends BaseStore { if (cluster) { cluster.updateModel(clusterModel); } else { - cluster = new Cluster(clusterModel); + cluster = this.dependencies.createCluster(clusterModel); } newClusters.set(clusterModel.id, cluster); } catch (error) { diff --git a/src/common/cluster-store/hosted-cluster/hosted-cluster.injectable.ts b/src/common/cluster-store/hosted-cluster/hosted-cluster.injectable.ts new file mode 100644 index 000000000000..a3057e7babc3 --- /dev/null +++ b/src/common/cluster-store/hosted-cluster/hosted-cluster.injectable.ts @@ -0,0 +1,35 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { getHostedClusterId } from "../../utils"; +import clusterStoreInjectable from "../cluster-store.injectable"; + +const hostedClusterInjectable = getInjectable({ + instantiate: (di) => { + const hostedClusterId = getHostedClusterId(); + + return di.inject(clusterStoreInjectable).getById(hostedClusterId); + }, + + lifecycle: lifecycleEnum.singleton, +}); + +export default hostedClusterInjectable; diff --git a/src/main/cluster.ts b/src/common/cluster/cluster.ts similarity index 93% rename from src/main/cluster.ts rename to src/common/cluster/cluster.ts index 2ec97c792a33..274b47d3140a 100644 --- a/src/main/cluster.ts +++ b/src/common/cluster/cluster.ts @@ -21,22 +21,29 @@ import { ipcMain } from "electron"; import { action, comparer, computed, makeObservable, observable, reaction, when } from "mobx"; -import { broadcastMessage, ClusterListNamespaceForbiddenChannel } from "../common/ipc"; -import { ContextHandler } from "./context-handler"; +import { broadcastMessage, ClusterListNamespaceForbiddenChannel } from "../ipc"; +import type { ContextHandler } from "../../main/context-handler/context-handler"; import { AuthorizationV1Api, CoreV1Api, HttpError, KubeConfig, V1ResourceAttributes } from "@kubernetes/client-node"; -import { Kubectl } from "./kubectl"; -import { KubeconfigManager } from "./kubeconfig-manager"; -import { loadConfigFromFile, loadConfigFromFileSync, validateKubeConfig } from "../common/kube-helpers"; -import { apiResourceRecord, apiResources, KubeApiResource, KubeResource } from "../common/rbac"; -import logger from "./logger"; -import { VersionDetector } from "./cluster-detectors/version-detector"; -import { DetectorRegistry } from "./cluster-detectors/detector-registry"; +import type { Kubectl } from "../../main/kubectl/kubectl"; +import type { KubeconfigManager } from "../../main/kubeconfig-manager/kubeconfig-manager"; +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 "../common/cluster-types"; -import { ClusterMetadataKey, initialNodeShellImage, ClusterStatus } from "../common/cluster-types"; -import { disposer, storedKubeConfigFolder, toJS } from "../common/utils"; +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"; +interface Dependencies { + directoryForKubeConfigs: string, + createKubeconfigManager: (cluster: Cluster) => KubeconfigManager, + createContextHandler: (cluster: Cluster) => ContextHandler, + createKubectl: (clusterVersion: string) => Kubectl +} + /** * Cluster * @@ -221,7 +228,7 @@ export class Cluster implements ClusterModel, ClusterState { return this.preferences.defaultNamespace; } - constructor(model: ClusterModel) { + constructor(private dependencies: Dependencies, model: ClusterModel) { makeObservable(this); this.id = model.id; this.updateModel(model); @@ -237,8 +244,8 @@ export class Cluster implements ClusterModel, ClusterState { if (ipcMain) { // for the time being, until renderer gets its own cluster type - this.contextHandler = new ContextHandler(this); - this.proxyKubeconfigManager = new KubeconfigManager(this, this.contextHandler); + this.contextHandler = this.dependencies.createContextHandler(this); + this.proxyKubeconfigManager = this.dependencies.createKubeconfigManager(this); logger.debug(`[CLUSTER]: Cluster init success`, { id: this.id, @@ -362,7 +369,7 @@ export class Cluster implements ClusterModel, ClusterState { * @internal */ async ensureKubectl() { - this.kubeCtl ??= new Kubectl(this.version); + this.kubeCtl ??= this.dependencies.createKubectl(this.version); await this.kubeCtl.ensureKubectl(); @@ -719,6 +726,6 @@ export class Cluster implements ClusterModel, ClusterState { } isInLocalKubeconfig() { - return this.kubeConfigPath.startsWith(storedKubeConfigFolder()); + return this.kubeConfigPath.startsWith(this.dependencies.directoryForKubeConfigs); } } diff --git a/src/common/cluster/create-cluster-injection-token.ts b/src/common/cluster/create-cluster-injection-token.ts new file mode 100644 index 000000000000..86c43302d99e --- /dev/null +++ b/src/common/cluster/create-cluster-injection-token.ts @@ -0,0 +1,26 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +import { getInjectionToken } from "@ogre-tools/injectable"; +import type { ClusterModel } from "../cluster-types"; +import type { Cluster } from "./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 new file mode 100644 index 000000000000..7bbd1017e56e --- /dev/null +++ b/src/common/directory-for-lens-local-storage/directory-for-lens-local-storage.injectable.ts @@ -0,0 +1,35 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +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"; + +const directoryForLensLocalStorageInjectable = getInjectable({ + instantiate: (di) => + path.resolve( + di.inject(directoryForUserDataInjectable), + "lens-local-storage", + ), + + lifecycle: lifecycleEnum.singleton, +}); + +export default directoryForLensLocalStorageInjectable; diff --git a/src/common/event-emitter.ts b/src/common/event-emitter.ts index 4e1fb45151d4..411bc42f7153 100644 --- a/src/common/event-emitter.ts +++ b/src/common/event-emitter.ts @@ -45,7 +45,7 @@ export class EventEmitter { this.listeners.length = 0; } - emit(...data: D) { + emit = (...data: D) => { for (const [callback, { once }] of this.listeners) { if (once) { this.removeListener(callback); @@ -55,5 +55,5 @@ export class EventEmitter { break; } } - } + }; } diff --git a/src/common/fs/fs.injectable.ts b/src/common/fs/fs.injectable.ts new file mode 100644 index 000000000000..e63c0137ccb1 --- /dev/null +++ b/src/common/fs/fs.injectable.ts @@ -0,0 +1,30 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import fse from "fs-extra"; + +const fsInjectable = getInjectable({ + instantiate: () => fse, + causesSideEffects: true, + lifecycle: lifecycleEnum.singleton, +}); + +export default fsInjectable; diff --git a/src/common/fs/read-json-file/read-json-file.injectable.ts b/src/common/fs/read-json-file/read-json-file.injectable.ts new file mode 100644 index 000000000000..ae801fc9e54c --- /dev/null +++ b/src/common/fs/read-json-file/read-json-file.injectable.ts @@ -0,0 +1,33 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +import { readJsonFile } from "./read-json-file"; +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import fsInjectable from "../fs.injectable"; + +const readJsonFileInjectable = getInjectable({ + instantiate: (di) => readJsonFile({ + fs: di.inject(fsInjectable), + }), + + lifecycle: lifecycleEnum.singleton, +}); + +export default readJsonFileInjectable; diff --git a/src/common/fs/read-json-file/read-json-file.ts b/src/common/fs/read-json-file/read-json-file.ts new file mode 100644 index 000000000000..77810de5f22f --- /dev/null +++ b/src/common/fs/read-json-file/read-json-file.ts @@ -0,0 +1,32 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +import type { JsonObject } from "type-fest"; + +interface Dependencies { + fs: { + readJson: (filePath: string) => Promise; + }; +} + +export const readJsonFile = + ({ fs }: Dependencies) => + (filePath: string) => + fs.readJson(filePath); diff --git a/src/common/fs/write-json-file/write-json-file.injectable.ts b/src/common/fs/write-json-file/write-json-file.injectable.ts new file mode 100644 index 000000000000..569be976b040 --- /dev/null +++ b/src/common/fs/write-json-file/write-json-file.injectable.ts @@ -0,0 +1,30 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { writeJsonFile } from "./write-json-file"; +import fsInjectable from "../fs.injectable"; + +const writeJsonFileInjectable = getInjectable({ + instantiate: (di) => writeJsonFile({ fs: di.inject(fsInjectable) }), + lifecycle: lifecycleEnum.singleton, +}); + +export default writeJsonFileInjectable; diff --git a/src/common/fs/write-json-file/write-json-file.ts b/src/common/fs/write-json-file/write-json-file.ts new file mode 100644 index 000000000000..8aea56b50816 --- /dev/null +++ b/src/common/fs/write-json-file/write-json-file.ts @@ -0,0 +1,47 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +import path from "path"; +import type { JsonObject } from "type-fest"; + +interface Dependencies { + fs: { + ensureDir: ( + directoryName: string, + options: { mode: number } + ) => Promise; + + writeJson: ( + filePath: string, + contentObject: JsonObject, + options: { spaces: number } + ) => Promise; + }; +} + +export const writeJsonFile = + ({ fs }: Dependencies) => + async (filePath: string, contentObject: JsonObject) => { + const directoryName = path.dirname(filePath); + + await fs.ensureDir(directoryName, { mode: 0o755 }); + + await fs.writeJson(filePath, contentObject, { spaces: 2 }); + }; diff --git a/src/common/ipc-channel/channel.d.ts b/src/common/ipc-channel/channel.d.ts new file mode 100644 index 000000000000..bb426ada774b --- /dev/null +++ b/src/common/ipc-channel/channel.d.ts @@ -0,0 +1,24 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +export interface Channel { + name: string; + _template: TInstance; +} diff --git a/src/common/ipc-channel/create-channel/create-channel.ts b/src/common/ipc-channel/create-channel/create-channel.ts new file mode 100644 index 000000000000..4c68af128a9c --- /dev/null +++ b/src/common/ipc-channel/create-channel/create-channel.ts @@ -0,0 +1,26 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +import type { Channel } from "../channel"; + +export const createChannel = (name: string): Channel => ({ + name, + _template: null, +}); diff --git a/src/common/k8s-api/cluster-context.ts b/src/common/k8s-api/cluster-context.ts index af892a2ce325..361a854ccba7 100644 --- a/src/common/k8s-api/cluster-context.ts +++ b/src/common/k8s-api/cluster-context.ts @@ -19,7 +19,7 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import type { Cluster } from "../../main/cluster"; +import type { Cluster } from "../cluster/cluster"; export interface ClusterContext { cluster?: Cluster; diff --git a/src/common/k8s-api/endpoints/pods.api.ts b/src/common/k8s-api/endpoints/pods.api.ts index 2c1bfdc6e862..a5ecf97a0678 100644 --- a/src/common/k8s-api/endpoints/pods.api.ts +++ b/src/common/k8s-api/endpoints/pods.api.ts @@ -27,11 +27,11 @@ import type { KubeJsonApiData } from "../kube-json-api"; import { isClusterPageContext } from "../../utils/cluster-id-url-parsing"; export class PodsApi extends KubeApi { - async getLogs(params: { namespace: string; name: string }, query?: IPodLogsQuery): Promise { + getLogs = async (params: { namespace: string; name: string }, query?: IPodLogsQuery): Promise => { const path = `${this.getUrl(params)}/log`; return this.request.get(path, { query }); - } + }; } export function getMetricsForPods(pods: Pod[], namespace: string, selector = "pod, namespace"): Promise { diff --git a/src/common/k8s-api/index.ts b/src/common/k8s-api/index.ts index c4fcb9a1c135..29e05b3b4974 100644 --- a/src/common/k8s-api/index.ts +++ b/src/common/k8s-api/index.ts @@ -23,7 +23,7 @@ import { JsonApi } from "./json-api"; import { KubeJsonApi } from "./kube-json-api"; import { apiKubePrefix, apiPrefix, isDebugging, isDevelopment } from "../../common/vars"; import { isClusterPageContext } from "../utils/cluster-id-url-parsing"; -import { appEventBus } from "../event-bus"; +import { appEventBus } from "../app-event-bus/event-bus"; let apiBase: JsonApi; let apiKube: KubeJsonApi; diff --git a/src/common/k8s-api/kube-api.ts b/src/common/k8s-api/kube-api.ts index b6a823935a13..390140a0c81f 100644 --- a/src/common/k8s-api/kube-api.ts +++ b/src/common/k8s-api/kube-api.ts @@ -30,7 +30,7 @@ import { apiBase, apiKube } from "./index"; import { createKubeApiURL, parseKubeApi } from "./kube-api-parse"; import { KubeObjectConstructor, KubeObject, KubeStatus } from "./kube-object"; import byline from "byline"; -import type { IKubeWatchEvent } from "./kube-watch-api"; +import type { IKubeWatchEvent } from "./kube-watch-event"; import { KubeJsonApi, KubeJsonApiData } from "./kube-json-api"; import { noop } from "../utils"; import type { RequestInit } from "node-fetch"; diff --git a/src/common/k8s-api/kube-object.store.ts b/src/common/k8s-api/kube-object.store.ts index 8f82b9e92a72..cf64b045b70f 100644 --- a/src/common/k8s-api/kube-object.store.ts +++ b/src/common/k8s-api/kube-object.store.ts @@ -24,7 +24,7 @@ import type { ClusterContext } from "./cluster-context"; import { action, computed, makeObservable, observable, reaction, when } from "mobx"; import { autoBind, noop, rejectPromiseBy } from "../utils"; import { KubeObject, KubeStatus } from "./kube-object"; -import type { IKubeWatchEvent } from "./kube-watch-api"; +import type { IKubeWatchEvent } from "./kube-watch-event"; import { ItemStore } from "../item.store"; import { ensureObjectSelfLink, IKubeApiQueryParams, KubeApi } from "./kube-api"; import { parseKubeApi } from "./kube-api-parse"; @@ -101,6 +101,7 @@ export abstract class KubeObjectStore extends ItemStore return KubeObjectStore.defaultContext.get(); } + // TODO: Circular dependency: KubeObjectStore -> ClusterFrameContext -> NamespaceStore -> KubeObjectStore @computed get contextItems(): T[] { const namespaces = this.context?.contextNamespaces ?? []; @@ -327,14 +328,14 @@ export abstract class KubeObjectStore extends ItemStore return this.api.create(params, data); } - async create(params: { name: string; namespace?: string }, data?: Partial): Promise { + create = async (params: { name: string; namespace?: string }, data?: Partial): Promise => { const newItem = await this.createItem(params, data); const items = this.sortItems([...this.items, newItem]); this.items.replace(items); return newItem; - } + }; private postUpdate(rawItem: KubeJsonApiData): T { const newItem = new this.api.objectConstructor(rawItem); diff --git a/src/common/k8s-api/kube-watch-event.d.ts b/src/common/k8s-api/kube-watch-event.d.ts new file mode 100644 index 000000000000..56a4f0c6a863 --- /dev/null +++ b/src/common/k8s-api/kube-watch-event.d.ts @@ -0,0 +1,28 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import type { KubeJsonApiData } from "./kube-json-api"; + +export interface IKubeWatchEvent { + type: "ADDED" | "MODIFIED" | "DELETED" | "ERROR"; + object?: T; +} + diff --git a/src/common/k8s/resource-stack.ts b/src/common/k8s/resource-stack.ts index 483dbeeb9b6f..f5e4e9a7e4c4 100644 --- a/src/common/k8s/resource-stack.ts +++ b/src/common/k8s/resource-stack.ts @@ -27,7 +27,7 @@ import logger from "../../main/logger"; import { app } from "electron"; import { requestMain } from "../ipc"; import { clusterKubectlApplyAllHandler, clusterKubectlDeleteAllHandler } from "../cluster-ipc"; -import { ClusterStore } from "../cluster-store"; +import { ClusterStore } from "../cluster-store/cluster-store"; import yaml from "js-yaml"; import { productName } from "../vars"; diff --git a/src/common/protocol-handler/router.ts b/src/common/protocol-handler/router.ts index b51e687677a9..127436a65d7d 100644 --- a/src/common/protocol-handler/router.ts +++ b/src/common/protocol-handler/router.ts @@ -26,8 +26,8 @@ import { pathToRegexp } from "path-to-regexp"; import logger from "../../main/logger"; import type Url from "url-parse"; import { RoutingError, RoutingErrorType } from "./error"; -import { ExtensionsStore } from "../../extensions/extensions-store"; -import type { ExtensionLoader as ExtensionLoaderType } from "../../extensions/extension-loader/extension-loader"; +import type { ExtensionsStore } from "../../extensions/extensions-store/extensions-store"; +import type { ExtensionLoader } from "../../extensions/extension-loader"; import type { LensExtension } from "../../extensions/lens-extension"; import type { RouteHandler, RouteParams } from "../../extensions/registries/protocol-handler"; import { when } from "mobx"; @@ -79,7 +79,8 @@ export function foldAttemptResults(mainAttempt: RouteAttempt, rendererAttempt: R } interface Dependencies { - extensionLoader: ExtensionLoaderType + extensionLoader: ExtensionLoader + extensionsStore: ExtensionsStore } export abstract class LensProtocolRouter { @@ -212,7 +213,7 @@ export abstract class LensProtocolRouter { return name; } - if (!ExtensionsStore.getInstance().isEnabled(extension)) { + if (!this.dependencies.extensionsStore.isEnabled(extension)) { logger.info(`${LensProtocolRouter.LoggingPrefix}: Extension ${name} matched, but not enabled`); return name; diff --git a/src/renderer/components/layout/sidebar-storage.ts b/src/common/user-store/user-store.injectable.ts similarity index 78% rename from src/renderer/components/layout/sidebar-storage.ts rename to src/common/user-store/user-store.injectable.ts index 162ca3e6b788..ad73efe949c3 100644 --- a/src/renderer/components/layout/sidebar-storage.ts +++ b/src/common/user-store/user-store.injectable.ts @@ -18,19 +18,13 @@ * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { UserStore } from "./user-store"; -import { createStorage } from "../../utils"; +const userStoreInjectable = getInjectable({ + instantiate: () => UserStore.createInstance(), -export interface SidebarStorageState { - width: number; - expanded: { - [itemId: string]: boolean; - } -} - -export const defaultSidebarWidth = 200; - -export const sidebarStorage = createStorage("sidebar", { - width: defaultSidebarWidth, - expanded: {}, + lifecycle: lifecycleEnum.singleton, }); + +export default userStoreInjectable; diff --git a/src/common/user-store/user-store.ts b/src/common/user-store/user-store.ts index 31238f0dedda..6f0203e151a0 100644 --- a/src/common/user-store/user-store.ts +++ b/src/common/user-store/user-store.ts @@ -26,12 +26,10 @@ 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 "../event-bus"; -import path from "path"; +import { appEventBus } from "../app-event-bus/event-bus"; import { ObservableToggleSet, toJS } from "../../renderer/utils"; import { DESCRIPTORS, EditorConfiguration, ExtensionRegistry, KubeconfigSyncValue, UserPreferencesModel } from "./preferences-helpers"; import logger from "../../main/logger"; -import { AppPaths } from "../app-paths"; export interface UserStoreModel { lastSeenAppVersion: string; @@ -233,11 +231,3 @@ export class UserStore extends BaseStore /* implements UserStore return toJS(model); } } - -/** - * Getting default directory to download kubectl binaries - * @returns string - */ -export function getDefaultKubectlDownloadPath(): string { - return path.join(AppPaths.get("userData"), "binaries"); -} diff --git a/src/common/utils/allowed-resource.ts b/src/common/utils/allowed-resource.ts index d8d3676f176b..b924a367122d 100644 --- a/src/common/utils/allowed-resource.ts +++ b/src/common/utils/allowed-resource.ts @@ -19,7 +19,7 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import { ClusterStore } from "../cluster-store"; +import { ClusterStore } from "../cluster-store/cluster-store"; import type { KubeResource } from "../rbac"; import { getHostedClusterId } from "./cluster-id-url-parsing"; diff --git a/src/common/utils/index.ts b/src/common/utils/index.ts index ea2f742bc7b6..74deb61f90eb 100644 --- a/src/common/utils/index.ts +++ b/src/common/utils/index.ts @@ -43,7 +43,6 @@ export * from "./extended-map"; export * from "./formatDuration"; export * from "./getRandId"; export * from "./hash-set"; -export * from "./local-kubeconfig"; export * from "./n-fircate"; export * from "./objects"; export * from "./openExternal"; diff --git a/src/extensions/__tests__/extension-loader.test.ts b/src/extensions/__tests__/extension-loader.test.ts index 4a8786e34cef..1426a174d469 100644 --- a/src/extensions/__tests__/extension-loader.test.ts +++ b/src/extensions/__tests__/extension-loader.test.ts @@ -20,12 +20,14 @@ */ import type { ExtensionLoader } from "../extension-loader"; -import { ipcRenderer } from "electron"; -import { ExtensionsStore } from "../extensions-store"; import { Console } from "console"; import { stdout, stderr } from "process"; -import { getDiForUnitTesting } from "../getDiForUnitTesting"; import extensionLoaderInjectable from "../extension-loader/extension-loader.injectable"; +import { runInAction } from "mobx"; +import updateExtensionsStateInjectable + from "../extension-loader/update-extensions-state/update-extensions-state.injectable"; +import { getDisForUnitTesting } from "../../test-utils/get-dis-for-unit-testing"; +import mockFs from "mock-fs"; console = new Console(stdout, stderr); @@ -33,15 +35,6 @@ const manifestPath = "manifest/path"; const manifestPath2 = "manifest/path2"; const manifestPath3 = "manifest/path3"; -jest.mock("../extensions-store", () => ({ - ExtensionsStore: { - getInstance: () => ({ - whenLoaded: Promise.resolve(true), - mergeState: jest.fn(), - }), - }, -})); - jest.mock( "electron", () => ({ @@ -131,14 +124,27 @@ jest.mock( describe("ExtensionLoader", () => { let extensionLoader: ExtensionLoader; + let updateExtensionStateMock: jest.Mock; + + beforeEach(async () => { + const dis = getDisForUnitTesting({ doGeneralOverrides: true }); + + mockFs(); - beforeEach(() => { - const di = getDiForUnitTesting(); + updateExtensionStateMock = jest.fn(); + + dis.mainDi.override(updateExtensionsStateInjectable, () => updateExtensionStateMock); + + await dis.runSetups(); + + extensionLoader = dis.mainDi.inject(extensionLoaderInjectable); + }); - extensionLoader = di.inject(extensionLoaderInjectable); + afterEach(() => { + mockFs.restore(); }); - it.only("renderer updates extension after ipc broadcast", async done => { + it("renderer updates extension after ipc broadcast", async done => { expect(extensionLoader.userExtensions).toMatchInlineSnapshot(`Map {}`); await extensionLoader.init(); @@ -177,26 +183,26 @@ describe("ExtensionLoader", () => { }); it("updates ExtensionsStore after isEnabled is changed", async () => { - (ExtensionsStore.getInstance().mergeState as any).mockClear(); - - // Disable sending events in this test - (ipcRenderer.on as any).mockImplementation(); - await extensionLoader.init(); - expect(ExtensionsStore.getInstance().mergeState).not.toHaveBeenCalled(); + expect(updateExtensionStateMock).not.toHaveBeenCalled(); - Array.from(extensionLoader.userExtensions.values())[0].isEnabled = false; - - expect(ExtensionsStore.getInstance().mergeState).toHaveBeenCalledWith({ - "manifest/path": { - enabled: false, - name: "TestExtension", - }, - "manifest/path2": { - enabled: true, - name: "TestExtension2", - }, + runInAction(() => { + extensionLoader.setIsEnabled("manifest/path", false); }); + + expect(updateExtensionStateMock).toHaveBeenCalledWith( + expect.objectContaining({ + "manifest/path": { + enabled: false, + name: "TestExtension", + }, + + "manifest/path2": { + enabled: true, + name: "TestExtension2", + }, + }), + ); }); }); diff --git a/src/extensions/common-api/app.ts b/src/extensions/common-api/app.ts index 401fc4d28317..5c407bdc9813 100644 --- a/src/extensions/common-api/app.ts +++ b/src/extensions/common-api/app.ts @@ -20,14 +20,13 @@ */ import { getAppVersion } from "../../common/utils"; -import { ExtensionsStore } from "../extensions-store"; +import { asLegacyGlobalFunctionForExtensionApi } from "../as-legacy-globals-for-extension-api/as-legacy-global-function-for-extension-api"; +import getEnabledExtensionsInjectable from "./get-enabled-extensions/get-enabled-extensions.injectable"; import * as Preferences from "./user-preferences"; export const version = getAppVersion(); export { isSnap, isWindows, isMac, isLinux, appName, slackUrl, issuesTrackerUrl } from "../../common/vars"; -export function getEnabledExtensions(): string[] { - return ExtensionsStore.getInstance().enabledExtensions; -} +export const getEnabledExtensions = asLegacyGlobalFunctionForExtensionApi(getEnabledExtensionsInjectable); export { Preferences }; diff --git a/src/extensions/common-api/event-bus.ts b/src/extensions/common-api/event-bus.ts index 1d296e299231..30654a05688e 100644 --- a/src/extensions/common-api/event-bus.ts +++ b/src/extensions/common-api/event-bus.ts @@ -19,5 +19,5 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -export { appEventBus } from "../../common/event-bus"; -export type { AppEvent } from "../../common/event-bus"; +export { appEventBus } from "../../common/app-event-bus/event-bus"; +export type { AppEvent } from "../../common/app-event-bus/event-bus"; diff --git a/src/extensions/common-api/get-enabled-extensions/get-enabled-extensions.injectable.ts b/src/extensions/common-api/get-enabled-extensions/get-enabled-extensions.injectable.ts new file mode 100644 index 000000000000..41f452a2b5b4 --- /dev/null +++ b/src/extensions/common-api/get-enabled-extensions/get-enabled-extensions.injectable.ts @@ -0,0 +1,31 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import extensionsStoreInjectable from "../../extensions-store/extensions-store.injectable"; + +const getEnabledExtensionsInjectable = getInjectable({ + instantiate: (di) => () => + di.inject(extensionsStoreInjectable).enabledExtensions, + + lifecycle: lifecycleEnum.singleton, +}); + +export default getEnabledExtensionsInjectable; diff --git a/src/extensions/extension-discovery/extension-discovery.injectable.ts b/src/extensions/extension-discovery/extension-discovery.injectable.ts new file mode 100644 index 000000000000..66b9d544dd69 --- /dev/null +++ b/src/extensions/extension-discovery/extension-discovery.injectable.ts @@ -0,0 +1,59 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { ExtensionDiscovery } from "./extension-discovery"; +import extensionLoaderInjectable from "../extension-loader/extension-loader.injectable"; +import isCompatibleExtensionInjectable from "./is-compatible-extension/is-compatible-extension.injectable"; +import isCompatibleBundledExtensionInjectable from "./is-compatible-bundled-extension/is-compatible-bundled-extension.injectable"; +import extensionsStoreInjectable from "../extensions-store/extensions-store.injectable"; +import extensionInstallationStateStoreInjectable from "../extension-installation-state-store/extension-installation-state-store.injectable"; +import installExtensionInjectable from "../extension-installer/install-extension/install-extension.injectable"; +import extensionPackageRootDirectoryInjectable from "../extension-installer/extension-package-root-directory/extension-package-root-directory.injectable"; +import installExtensionsInjectable from "../extension-installer/install-extensions/install-extensions.injectable"; + +const extensionDiscoveryInjectable = getInjectable({ + instantiate: (di) => + new ExtensionDiscovery({ + extensionLoader: di.inject(extensionLoaderInjectable), + extensionsStore: di.inject(extensionsStoreInjectable), + + extensionInstallationStateStore: di.inject( + extensionInstallationStateStoreInjectable, + ), + + isCompatibleBundledExtension: di.inject( + isCompatibleBundledExtensionInjectable, + ), + + isCompatibleExtension: di.inject(isCompatibleExtensionInjectable), + + installExtension: di.inject(installExtensionInjectable), + installExtensions: di.inject(installExtensionsInjectable), + + extensionPackageRootDirectory: di.inject( + extensionPackageRootDirectoryInjectable, + ), + }), + + lifecycle: lifecycleEnum.singleton, +}); + +export default extensionDiscoveryInjectable; diff --git a/src/extensions/__tests__/extension-discovery.test.ts b/src/extensions/extension-discovery/extension-discovery.test.ts similarity index 77% rename from src/extensions/__tests__/extension-discovery.test.ts rename to src/extensions/extension-discovery/extension-discovery.test.ts index b39a0a0dfa4f..8713f5634421 100644 --- a/src/extensions/__tests__/extension-discovery.test.ts +++ b/src/extensions/extension-discovery/extension-discovery.test.ts @@ -20,16 +20,18 @@ */ import { watch } from "chokidar"; -import { ExtensionsStore } from "../extensions-store"; import path from "path"; -import { ExtensionDiscovery } from "../extension-discovery"; import os from "os"; import { Console } from "console"; -import { AppPaths } from "../../common/app-paths"; -import type { ExtensionLoader } from "../extension-loader"; -import extensionLoaderInjectable from "../extension-loader/extension-loader.injectable"; -import { getDiForUnitTesting } from "../getDiForUnitTesting"; import * as fse from "fs-extra"; +import { getDiForUnitTesting } from "../../main/getDiForUnitTesting"; +import extensionDiscoveryInjectable from "../extension-discovery/extension-discovery.injectable"; +import type { ExtensionDiscovery } from "../extension-discovery/extension-discovery"; +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"; +import mockFs from "mock-fs"; jest.setTimeout(60_000); @@ -37,12 +39,7 @@ jest.mock("../../common/ipc"); jest.mock("chokidar", () => ({ watch: jest.fn(), })); -jest.mock("../extension-installer", () => ({ - extensionInstaller: { - extensionPackagesRoot: "", - installPackage: jest.fn(), - }, -})); + jest.mock("fs-extra"); jest.mock("electron", () => ({ app: { @@ -60,23 +57,28 @@ jest.mock("electron", () => ({ }, })); -AppPaths.init(); - console = new Console(process.stdout, process.stderr); // fix mockFS const mockedWatch = watch as jest.MockedFunction; const mockedFse = fse as jest.Mocked; describe("ExtensionDiscovery", () => { - let extensionLoader: ExtensionLoader; + let extensionDiscovery: ExtensionDiscovery; + + beforeEach(async () => { + const di = getDiForUnitTesting({ doGeneralOverrides: true }); - beforeEach(() => { - ExtensionDiscovery.resetInstance(); - ExtensionsStore.resetInstance(); - ExtensionsStore.createInstance(); + di.override(directoryForUserDataInjectable, () => "some-directory-for-user-data"); + di.override(installExtensionInjectable, () => () => Promise.resolve()); - const di = getDiForUnitTesting(); + mockFs(); - extensionLoader = di.inject(extensionLoaderInjectable); + await di.runSetups(); + + extensionDiscovery = di.inject(extensionDiscoveryInjectable); + }); + + afterEach(() => { + mockFs.restore(); }); it("emits add for added extension", async (done) => { @@ -106,9 +108,6 @@ describe("ExtensionDiscovery", () => { mockedWatch.mockImplementationOnce(() => (mockWatchInstance) as any, ); - const extensionDiscovery = ExtensionDiscovery.createInstance( - extensionLoader, - ); // Need to force isLoaded to be true so that the file watching is started extensionDiscovery.isLoaded = true; @@ -118,7 +117,7 @@ describe("ExtensionDiscovery", () => { extensionDiscovery.events.on("add", extension => { expect(extension).toEqual({ absolutePath: expect.any(String), - id: path.normalize("node_modules/my-extension/package.json"), + id: path.normalize("some-directory-for-user-data/node_modules/my-extension/package.json"), isBundled: false, isEnabled: false, isCompatible: false, @@ -126,7 +125,7 @@ describe("ExtensionDiscovery", () => { name: "my-extension", version: "1.0.0", }, - manifestPath: path.normalize("node_modules/my-extension/package.json"), + manifestPath: path.normalize("some-directory-for-user-data/node_modules/my-extension/package.json"), }); done(); }); @@ -150,9 +149,6 @@ describe("ExtensionDiscovery", () => { mockedWatch.mockImplementationOnce(() => (mockWatchInstance) as any, ); - const extensionDiscovery = ExtensionDiscovery.createInstance( - extensionLoader, - ); // Need to force isLoaded to be true so that the file watching is started extensionDiscovery.isLoaded = true; diff --git a/src/extensions/extension-discovery.ts b/src/extensions/extension-discovery/extension-discovery.ts similarity index 86% rename from src/extensions/extension-discovery.ts rename to src/extensions/extension-discovery/extension-discovery.ts index d45c8bde8096..a546218734a6 100644 --- a/src/extensions/extension-discovery.ts +++ b/src/extensions/extension-discovery/extension-discovery.ts @@ -23,19 +23,37 @@ import { watch } from "chokidar"; import { ipcRenderer } from "electron"; import { EventEmitter } from "events"; import fse from "fs-extra"; -import { observable, reaction, when, makeObservable } from "mobx"; +import { makeObservable, observable, reaction, when } from "mobx"; import os from "os"; import path from "path"; -import { broadcastMessage, ipcMainHandle, ipcRendererOn, requestMain } from "../common/ipc"; -import { Singleton, toJS } from "../common/utils"; -import logger from "../main/logger"; -import { ExtensionInstallationStateStore } from "../renderer/components/+extensions/extension-install.store"; -import { extensionInstaller } from "./extension-installer"; -import { ExtensionsStore } from "./extensions-store"; -import type { ExtensionLoader } from "./extension-loader"; -import type { LensExtensionId, LensExtensionManifest } from "./lens-extension"; -import { isProduction } from "../common/vars"; -import { isCompatibleBundledExtension, isCompatibleExtension } from "./extension-compatibility"; +import { + broadcastMessage, + ipcMainHandle, + ipcRendererOn, + requestMain, +} from "../../common/ipc"; +import { toJS } from "../../common/utils"; +import logger from "../../main/logger"; +import type { ExtensionsStore } from "../extensions-store/extensions-store"; +import type { ExtensionLoader } from "../extension-loader"; +import type { LensExtensionId, LensExtensionManifest } from "../lens-extension"; +import { isProduction } from "../../common/vars"; +import type { ExtensionInstallationStateStore } from "../extension-installation-state-store/extension-installation-state-store"; +import type { PackageJson } from "type-fest"; + +interface Dependencies { + extensionLoader: ExtensionLoader; + extensionsStore: ExtensionsStore; + + extensionInstallationStateStore: ExtensionInstallationStateStore; + + isCompatibleBundledExtension: (manifest: LensExtensionManifest) => boolean; + isCompatibleExtension: (manifest: LensExtensionManifest) => boolean; + + installExtension: (name: string) => Promise; + installExtensions: (packageJsonPath: string, packagesJson: PackageJson) => Promise + extensionPackageRootDirectory: string; +} export interface InstalledExtension { id: LensExtensionId; @@ -81,7 +99,7 @@ interface LoadFromFolderOptions { * - "add": When extension is added. The event is of type InstalledExtension * - "remove": When extension is removed. The event is of type LensExtensionId */ -export class ExtensionDiscovery extends Singleton { +export class ExtensionDiscovery { protected bundledFolderPath: string; private loadStarted = false; @@ -99,9 +117,7 @@ export class ExtensionDiscovery extends Singleton { public events = new EventEmitter(); - constructor(protected extensionLoader: ExtensionLoader) { - super(); - + constructor(protected dependencies : Dependencies) { makeObservable(this); } @@ -110,11 +126,11 @@ export class ExtensionDiscovery extends Singleton { } get packageJsonPath(): string { - return path.join(extensionInstaller.extensionPackagesRoot, manifestFilename); + return path.join(this.dependencies.extensionPackageRootDirectory, manifestFilename); } get inTreeTargetPath(): string { - return path.join(extensionInstaller.extensionPackagesRoot, "extensions"); + return path.join(this.dependencies.extensionPackageRootDirectory, "extensions"); } get inTreeFolderPath(): string { @@ -122,7 +138,7 @@ export class ExtensionDiscovery extends Singleton { } get nodeModulesPath(): string { - return path.join(extensionInstaller.extensionPackagesRoot, "node_modules"); + return path.join(this.dependencies.extensionPackageRootDirectory, "node_modules"); } /** @@ -197,7 +213,7 @@ export class ExtensionDiscovery extends Singleton { if (path.basename(manifestPath) === manifestFilename && isUnderLocalFolderPath) { try { - ExtensionInstallationStateStore.setInstallingFromMain(manifestPath); + this.dependencies.extensionInstallationStateStore.setInstallingFromMain(manifestPath); const absPath = path.dirname(manifestPath); // this.loadExtensionFromPath updates this.packagesJson @@ -208,7 +224,7 @@ export class ExtensionDiscovery extends Singleton { await fse.remove(extension.manifestPath); // Install dependencies for the new extension - await this.installPackage(extension.absolutePath); + await this.dependencies.installExtension(extension.absolutePath); this.extensions.set(extension.id, extension); logger.info(`${logModule} Added extension ${extension.manifest.name}`); @@ -217,7 +233,7 @@ export class ExtensionDiscovery extends Singleton { } catch (error) { logger.error(`${logModule}: failed to add extension: ${error}`, { error }); } finally { - ExtensionInstallationStateStore.clearInstallingFromMain(manifestPath); + this.dependencies.extensionInstallationStateStore.clearInstallingFromMain(manifestPath); } } }; @@ -277,7 +293,7 @@ export class ExtensionDiscovery extends Singleton { * @param extensionId The ID of the extension to uninstall. */ async uninstallExtension(extensionId: LensExtensionId): Promise { - const { manifest, absolutePath } = this.extensions.get(extensionId) ?? this.extensionLoader.getExtension(extensionId); + const { manifest, absolutePath } = this.extensions.get(extensionId) ?? this.dependencies.extensionLoader.getExtension(extensionId); logger.info(`${logModule} Uninstalling ${manifest.name}`); @@ -295,10 +311,12 @@ export class ExtensionDiscovery extends Singleton { this.loadStarted = true; - logger.info(`${logModule} loading extensions from ${extensionInstaller.extensionPackagesRoot}`); + logger.info( + `${logModule} loading extensions from ${this.dependencies.extensionPackageRootDirectory}`, + ); // fs.remove won't throw if path is missing - await fse.remove(path.join(extensionInstaller.extensionPackagesRoot, "package-lock.json")); + await fse.remove(path.join(this.dependencies.extensionPackageRootDirectory, "package-lock.json")); try { // Verify write access to static/extensions, which is needed for symlinking @@ -357,11 +375,11 @@ export class ExtensionDiscovery extends Singleton { try { const manifest = await fse.readJson(manifestPath) as LensExtensionManifest; const id = this.getInstalledManifestPath(manifest.name); - const isEnabled = ExtensionsStore.getInstance().isEnabled({ id, isBundled }); + const isEnabled = this.dependencies.extensionsStore.isEnabled({ id, isBundled }); const extensionDir = path.dirname(manifestPath); const npmPackage = path.join(extensionDir, `${manifest.name}-${manifest.version}.tgz`); const absolutePath = (isProduction && await fse.pathExists(npmPackage)) ? npmPackage : extensionDir; - const isCompatible = (isBundled && isCompatibleBundledExtension(manifest)) || isCompatibleExtension(manifest); + const isCompatible = (isBundled && this.dependencies.isCompatibleBundledExtension(manifest)) || this.dependencies.isCompatibleExtension(manifest); return { id, @@ -394,7 +412,7 @@ export class ExtensionDiscovery extends Singleton { for (const extension of userExtensions) { if ((await fse.pathExists(extension.manifestPath)) === false) { try { - await this.installPackage(extension.absolutePath); + await this.dependencies.installExtension(extension.absolutePath); } catch (error) { const message = error.message || error || "unknown error"; const { name, version } = extension.manifest; @@ -417,11 +435,7 @@ export class ExtensionDiscovery extends Singleton { extensions.map(extension => [extension.manifest.name, extension.absolutePath]), ); - return extensionInstaller.installPackages(packageJsonPath, { dependencies }); - } - - async installPackage(name: string): Promise { - return extensionInstaller.installPackage(name); + return this.dependencies.installExtensions(packageJsonPath, { dependencies }); } async loadBundledExtensions(): Promise { diff --git a/src/extensions/extension-discovery/is-compatible-bundled-extension/is-compatible-bundled-extension.injectable.ts b/src/extensions/extension-discovery/is-compatible-bundled-extension/is-compatible-bundled-extension.injectable.ts new file mode 100644 index 000000000000..6d486a4f41a5 --- /dev/null +++ b/src/extensions/extension-discovery/is-compatible-bundled-extension/is-compatible-bundled-extension.injectable.ts @@ -0,0 +1,30 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { appSemVer } from "../../../common/vars"; +import { isCompatibleBundledExtension } from "./is-compatible-bundled-extension"; + +const isCompatibleBundledExtensionInjectable = getInjectable({ + instantiate: () => isCompatibleBundledExtension({ appSemVer }), + lifecycle: lifecycleEnum.singleton, +}); + +export default isCompatibleBundledExtensionInjectable; diff --git a/src/extensions/extension-discovery/is-compatible-bundled-extension/is-compatible-bundled-extension.ts b/src/extensions/extension-discovery/is-compatible-bundled-extension/is-compatible-bundled-extension.ts new file mode 100644 index 000000000000..23f841a42c00 --- /dev/null +++ b/src/extensions/extension-discovery/is-compatible-bundled-extension/is-compatible-bundled-extension.ts @@ -0,0 +1,32 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +import type { LensExtensionManifest } from "../../lens-extension"; +import { isProduction } from "../../../common/vars"; +import type { SemVer } from "semver"; + +interface Dependencies { + appSemVer: SemVer; +} + +export const isCompatibleBundledExtension = + ({ appSemVer }: Dependencies) => + (manifest: LensExtensionManifest): boolean => + !isProduction || manifest.version === appSemVer.raw; diff --git a/src/extensions/extension-discovery/is-compatible-extension/is-compatible-extension.injectable.ts b/src/extensions/extension-discovery/is-compatible-extension/is-compatible-extension.injectable.ts new file mode 100644 index 000000000000..65c58a1a6350 --- /dev/null +++ b/src/extensions/extension-discovery/is-compatible-extension/is-compatible-extension.injectable.ts @@ -0,0 +1,30 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { appSemVer } from "../../../common/vars"; +import { isCompatibleExtension } from "./is-compatible-extension"; + +const isCompatibleExtensionInjectable = getInjectable({ + instantiate: () => isCompatibleExtension({ appSemVer }), + lifecycle: lifecycleEnum.singleton, +}); + +export default isCompatibleExtensionInjectable; diff --git a/src/extensions/extension-discovery/is-compatible-extension/is-compatible-extension.ts b/src/extensions/extension-discovery/is-compatible-extension/is-compatible-extension.ts new file mode 100644 index 000000000000..920171cfc621 --- /dev/null +++ b/src/extensions/extension-discovery/is-compatible-extension/is-compatible-extension.ts @@ -0,0 +1,68 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +import semver, { SemVer } from "semver"; +import type { LensExtensionManifest } from "../../lens-extension"; + +interface Dependencies { + appSemVer: SemVer; +} + +export const isCompatibleExtension = ({ + appSemVer, +}: Dependencies): ((manifest: LensExtensionManifest) => boolean) => { + const { major, minor, patch, prerelease: oldPrelease } = appSemVer; + let prerelease = ""; + + if (oldPrelease.length > 0) { + const [first] = oldPrelease; + + if (first === "alpha" || first === "beta" || first === "rc") { + /** + * Strip the build IDs and "latest" prerelease tag as that is not really + * a part of API version + */ + prerelease = `-${oldPrelease.slice(0, 2).join(".")}`; + } + } + + /** + * We unfortunately have to format as string because the constructor only + * takes an instance or a string. + */ + const strippedVersion = new SemVer( + `${major}.${minor}.${patch}${prerelease}`, + { includePrerelease: true }, + ); + + return (manifest: LensExtensionManifest): boolean => { + if (manifest.engines?.lens) { + /** + * include Lens's prerelease tag in the matching so the extension's + * compatibility is not limited by it + */ + return semver.satisfies(strippedVersion, manifest.engines.lens, { + includePrerelease: true, + }); + } + + return false; + }; +}; diff --git a/src/extensions/extension-installation-state-store/extension-installation-state-store.injectable.ts b/src/extensions/extension-installation-state-store/extension-installation-state-store.injectable.ts new file mode 100644 index 000000000000..e02206e41c9a --- /dev/null +++ b/src/extensions/extension-installation-state-store/extension-installation-state-store.injectable.ts @@ -0,0 +1,29 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { ExtensionInstallationStateStore } from "./extension-installation-state-store"; + +const extensionInstallationStateStoreInjectable = getInjectable({ + instantiate: () => new ExtensionInstallationStateStore(), + lifecycle: lifecycleEnum.singleton, +}); + +export default extensionInstallationStateStoreInjectable; diff --git a/src/extensions/extension-installation-state-store/extension-installation-state-store.ts b/src/extensions/extension-installation-state-store/extension-installation-state-store.ts new file mode 100644 index 000000000000..6a8be9269875 --- /dev/null +++ b/src/extensions/extension-installation-state-store/extension-installation-state-store.ts @@ -0,0 +1,260 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import { action, computed, observable } from "mobx"; +import logger from "../../main/logger"; +import { disposer } from "../../renderer/utils"; +import type { ExtendableDisposer } from "../../renderer/utils"; +import * as uuid from "uuid"; +import { broadcastMessage } from "../../common/ipc"; +import { ipcRenderer } from "electron"; + +export enum ExtensionInstallationState { + INSTALLING = "installing", + UNINSTALLING = "uninstalling", + IDLE = "idle", +} + +const Prefix = "[ExtensionInstallationStore]"; + +export class ExtensionInstallationStateStore { + private InstallingFromMainChannel = + "extension-installation-state-store:install"; + + private ClearInstallingFromMainChannel = + "extension-installation-state-store:clear-install"; + + private PreInstallIds = observable.set(); + private UninstallingExtensions = observable.set(); + private InstallingExtensions = observable.set(); + + bindIpcListeners = () => { + ipcRenderer + .on(this.InstallingFromMainChannel, (event, extId) => { + this.setInstalling(extId); + }) + + .on(this.ClearInstallingFromMainChannel, (event, extId) => { + this.clearInstalling(extId); + }); + }; + + /** + * Strictly transitions an extension from not installing to installing + * @param extId the ID of the extension + * @throws if state is not IDLE + */ + @action setInstalling = (extId: string): void => { + logger.debug(`${Prefix}: trying to set ${extId} as installing`); + + const curState = this.getInstallationState(extId); + + if (curState !== ExtensionInstallationState.IDLE) { + throw new Error( + `${Prefix}: cannot set ${extId} as installing. Is currently ${curState}.`, + ); + } + + this.InstallingExtensions.add(extId); + }; + + /** + * Broadcasts that an extension is being installed by the main process + * @param extId the ID of the extension + */ + setInstallingFromMain = (extId: string): void => { + broadcastMessage(this.InstallingFromMainChannel, extId); + }; + + /** + * Broadcasts that an extension is no longer being installed by the main process + * @param extId the ID of the extension + */ + clearInstallingFromMain = (extId: string): void => { + broadcastMessage(this.ClearInstallingFromMainChannel, extId); + }; + + /** + * Marks the start of a pre-install phase of an extension installation. The + * part of the installation before the tarball has been unpacked and the ID + * determined. + * @returns a disposer which should be called to mark the end of the install phase + */ + @action startPreInstall = (): ExtendableDisposer => { + const preInstallStepId = uuid.v4(); + + logger.debug( + `${Prefix}: starting a new preinstall phase: ${preInstallStepId}`, + ); + this.PreInstallIds.add(preInstallStepId); + + return disposer(() => { + this.PreInstallIds.delete(preInstallStepId); + logger.debug(`${Prefix}: ending a preinstall phase: ${preInstallStepId}`); + }); + }; + + /** + * Strictly transitions an extension from not uninstalling to uninstalling + * @param extId the ID of the extension + * @throws if state is not IDLE + */ + @action setUninstalling = (extId: string): void => { + logger.debug(`${Prefix}: trying to set ${extId} as uninstalling`); + + const curState = this.getInstallationState(extId); + + if (curState !== ExtensionInstallationState.IDLE) { + throw new Error( + `${Prefix}: cannot set ${extId} as uninstalling. Is currently ${curState}.`, + ); + } + + this.UninstallingExtensions.add(extId); + }; + + /** + * Strictly clears the INSTALLING state of an extension + * @param extId The ID of the extension + * @throws if state is not INSTALLING + */ + @action clearInstalling = (extId: string): void => { + logger.debug(`${Prefix}: trying to clear ${extId} as installing`); + + const curState = this.getInstallationState(extId); + + switch (curState) { + case ExtensionInstallationState.INSTALLING: + return void this.InstallingExtensions.delete(extId); + default: + throw new Error( + `${Prefix}: cannot clear INSTALLING state for ${extId}, it is currently ${curState}`, + ); + } + }; + + /** + * Strictly clears the UNINSTALLING state of an extension + * @param extId The ID of the extension + * @throws if state is not UNINSTALLING + */ + @action clearUninstalling = (extId: string): void => { + logger.debug(`${Prefix}: trying to clear ${extId} as uninstalling`); + + const curState = this.getInstallationState(extId); + + switch (curState) { + case ExtensionInstallationState.UNINSTALLING: + return void this.UninstallingExtensions.delete(extId); + default: + throw new Error( + `${Prefix}: cannot clear UNINSTALLING state for ${extId}, it is currently ${curState}`, + ); + } + }; + + /** + * Returns the current state of the extension. IDLE is default value. + * @param extId The ID of the extension + */ + getInstallationState = (extId: string): ExtensionInstallationState => { + if (this.InstallingExtensions.has(extId)) { + return ExtensionInstallationState.INSTALLING; + } + + if (this.UninstallingExtensions.has(extId)) { + return ExtensionInstallationState.UNINSTALLING; + } + + return ExtensionInstallationState.IDLE; + }; + + /** + * Returns true if the extension is currently INSTALLING + * @param extId The ID of the extension + */ + isExtensionInstalling = (extId: string): boolean => + this.getInstallationState(extId) === ExtensionInstallationState.INSTALLING; + + /** + * Returns true if the extension is currently UNINSTALLING + * @param extId The ID of the extension + */ + isExtensionUninstalling = (extId: string): boolean => + this.getInstallationState(extId) === + ExtensionInstallationState.UNINSTALLING; + + /** + * Returns true if the extension is currently IDLE + * @param extId The ID of the extension + */ + isExtensionIdle = (extId: string): boolean => + this.getInstallationState(extId) === ExtensionInstallationState.IDLE; + + /** + * The current number of extensions installing + */ + @computed get installing(): number { + return this.InstallingExtensions.size; + } + + /** + * The current number of extensions uninstalling + */ + get uninstalling(): number { + return this.UninstallingExtensions.size; + } + + /** + * If there is at least one extension currently installing + */ + get anyInstalling(): boolean { + return this.installing > 0; + } + + /** + * If there is at least one extension currently uninstalling + */ + get anyUninstalling(): boolean { + return this.uninstalling > 0; + } + + /** + * The current number of extensions preinstalling + */ + get preinstalling(): number { + return this.PreInstallIds.size; + } + + /** + * If there is at least one extension currently downloading + */ + get anyPreinstalling(): boolean { + return this.preinstalling > 0; + } + + /** + * If there is at least one installing or preinstalling step taking place + */ + get anyPreInstallingOrInstalling(): boolean { + return this.anyInstalling || this.anyPreinstalling; + } +} diff --git a/src/extensions/extension-installer/extension-installer.injectable.ts b/src/extensions/extension-installer/extension-installer.injectable.ts new file mode 100644 index 000000000000..390f751e7e97 --- /dev/null +++ b/src/extensions/extension-installer/extension-installer.injectable.ts @@ -0,0 +1,36 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { ExtensionInstaller } from "./extension-installer"; +import extensionPackageRootDirectoryInjectable from "./extension-package-root-directory/extension-package-root-directory.injectable"; + +const extensionInstallerInjectable = getInjectable({ + instantiate: (di) => + new ExtensionInstaller({ + extensionPackageRootDirectory: di.inject( + extensionPackageRootDirectoryInjectable, + ), + }), + + lifecycle: lifecycleEnum.singleton, +}); + +export default extensionInstallerInjectable; diff --git a/src/extensions/extension-installer.ts b/src/extensions/extension-installer/extension-installer.ts similarity index 79% rename from src/extensions/extension-installer.ts rename to src/extensions/extension-installer/extension-installer.ts index 97eab7999f1c..84f096afabe5 100644 --- a/src/extensions/extension-installer.ts +++ b/src/extensions/extension-installer/extension-installer.ts @@ -23,12 +23,14 @@ import AwaitLock from "await-lock"; import child_process from "child_process"; import fs from "fs-extra"; import path from "path"; -import logger from "../main/logger"; -import { extensionPackagesRoot } from "./extension-loader"; +import logger from "../../main/logger"; import type { PackageJson } from "type-fest"; const logModule = "[EXTENSION-INSTALLER]"; +interface Dependencies { + extensionPackageRootDirectory: string +} /** * Installs dependencies for extensions @@ -36,9 +38,7 @@ const logModule = "[EXTENSION-INSTALLER]"; export class ExtensionInstaller { private installLock = new AwaitLock(); - get extensionPackagesRoot() { - return extensionPackagesRoot(); - } + constructor(private dependencies: Dependencies) {} get npmPath() { return __non_webpack_require__.resolve("npm/bin/npm-cli"); @@ -47,7 +47,7 @@ export class ExtensionInstaller { /** * Write package.json to the file system and execute npm install for it. */ - async installPackages(packageJsonPath: string, packagesJson: PackageJson): Promise { + installPackages = async (packageJsonPath: string, packagesJson: PackageJson): Promise => { // Mutual exclusion to install packages in sequence await this.installLock.acquireAsync(); @@ -57,34 +57,34 @@ export class ExtensionInstaller { mode: 0o600, }); - logger.info(`${logModule} installing dependencies at ${extensionPackagesRoot()}`); + logger.info(`${logModule} installing dependencies at ${this.dependencies.extensionPackageRootDirectory}`); await this.npm(["install", "--no-audit", "--only=prod", "--prefer-offline", "--no-package-lock"]); - logger.info(`${logModule} dependencies installed at ${extensionPackagesRoot()}`); + logger.info(`${logModule} dependencies installed at ${this.dependencies.extensionPackageRootDirectory}`); } finally { this.installLock.release(); } - } + }; /** * Install single package using npm */ - async installPackage(name: string): Promise { + installPackage = async (name: string): Promise => { // Mutual exclusion to install packages in sequence await this.installLock.acquireAsync(); try { - logger.info(`${logModule} installing package from ${name} to ${extensionPackagesRoot()}`); + logger.info(`${logModule} installing package from ${name} to ${this.dependencies.extensionPackageRootDirectory}`); await this.npm(["install", "--no-audit", "--only=prod", "--prefer-offline", "--no-package-lock", "--no-save", name]); - logger.info(`${logModule} package ${name} installed to ${extensionPackagesRoot()}`); + logger.info(`${logModule} package ${name} installed to ${this.dependencies.extensionPackageRootDirectory}`); } finally { this.installLock.release(); } - } + }; private npm(args: string[]): Promise { return new Promise((resolve, reject) => { const child = child_process.fork(this.npmPath, args, { - cwd: extensionPackagesRoot(), + cwd: this.dependencies.extensionPackageRootDirectory, silent: true, env: {}, }); @@ -108,5 +108,3 @@ export class ExtensionInstaller { }); } } - -export const extensionInstaller = new ExtensionInstaller(); 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 new file mode 100644 index 000000000000..a5ee89870688 --- /dev/null +++ b/src/extensions/extension-installer/extension-package-root-directory/extension-package-root-directory.injectable.ts @@ -0,0 +1,31 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import directoryForUserDataInjectable + from "../../../common/app-paths/directory-for-user-data/directory-for-user-data.injectable"; + +const extensionPackageRootDirectoryInjectable = getInjectable({ + instantiate: (di) => di.inject(directoryForUserDataInjectable), + + lifecycle: lifecycleEnum.singleton, +}); + +export default extensionPackageRootDirectoryInjectable; diff --git a/src/extensions/extension-installer/install-extension/install-extension.injectable.ts b/src/extensions/extension-installer/install-extension/install-extension.injectable.ts new file mode 100644 index 000000000000..2e0b1b8ca457 --- /dev/null +++ b/src/extensions/extension-installer/install-extension/install-extension.injectable.ts @@ -0,0 +1,30 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import extensionInstallerInjectable from "../extension-installer.injectable"; + +const installExtensionInjectable = getInjectable({ + instantiate: (di) => di.inject(extensionInstallerInjectable).installPackage, + + lifecycle: lifecycleEnum.singleton, +}); + +export default installExtensionInjectable; diff --git a/src/extensions/extension-installer/install-extensions/install-extensions.injectable.ts b/src/extensions/extension-installer/install-extensions/install-extensions.injectable.ts new file mode 100644 index 000000000000..3c370b4690e0 --- /dev/null +++ b/src/extensions/extension-installer/install-extensions/install-extensions.injectable.ts @@ -0,0 +1,30 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import extensionInstallerInjectable from "../extension-installer.injectable"; + +const installExtensionsInjectable = getInjectable({ + instantiate: (di) => di.inject(extensionInstallerInjectable).installPackages, + + lifecycle: lifecycleEnum.singleton, +}); + +export default installExtensionsInjectable; diff --git a/src/extensions/extension-loader/create-extension-instance/create-extension-instance.injectable.ts b/src/extensions/extension-loader/create-extension-instance/create-extension-instance.injectable.ts new file mode 100644 index 000000000000..b507be0c36d9 --- /dev/null +++ b/src/extensions/extension-loader/create-extension-instance/create-extension-instance.injectable.ts @@ -0,0 +1,33 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { createExtensionInstance } from "./create-extension-instance"; +import fileSystemProvisionerStoreInjectable from "./file-system-provisioner-store/file-system-provisioner-store.injectable"; + +const createExtensionInstanceInjectable = getInjectable({ + instantiate: (di) => createExtensionInstance({ + fileSystemProvisionerStore: di.inject(fileSystemProvisionerStoreInjectable), + }), + + lifecycle: lifecycleEnum.singleton, +}); + +export default createExtensionInstanceInjectable; diff --git a/src/extensions/extension-loader/create-extension-instance/create-extension-instance.ts b/src/extensions/extension-loader/create-extension-instance/create-extension-instance.ts new file mode 100644 index 000000000000..105c9f6872dd --- /dev/null +++ b/src/extensions/extension-loader/create-extension-instance/create-extension-instance.ts @@ -0,0 +1,36 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +import type { LensExtensionConstructor } from "../../lens-extension"; +import type { InstalledExtension } from "../../extension-discovery/extension-discovery"; +import { + LensExtensionDependencies, + setLensExtensionDependencies, +} from "../../lens-extension-set-dependencies"; + +export const createExtensionInstance = + (dependencies: LensExtensionDependencies) => + (ExtensionClass: LensExtensionConstructor, extension: InstalledExtension) => { + const instance = new ExtensionClass(extension); + + instance[setLensExtensionDependencies](dependencies); + + return instance; + }; 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/directory-for-extension-data.injectable.ts new file mode 100644 index 000000000000..cd2a63085a23 --- /dev/null +++ b/src/extensions/extension-loader/create-extension-instance/file-system-provisioner-store/directory-for-extension-data/directory-for-extension-data.injectable.ts @@ -0,0 +1,32 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +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"; + +const directoryForExtensionDataInjectable = getInjectable({ + instantiate: (di) => + path.join(di.inject(directoryForUserDataInjectable), "extension_data"), + + lifecycle: lifecycleEnum.singleton, +}); + +export default directoryForExtensionDataInjectable; 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 new file mode 100644 index 000000000000..4ed7728e3b1e --- /dev/null +++ b/src/extensions/extension-loader/create-extension-instance/file-system-provisioner-store/file-system-provisioner-store.injectable.ts @@ -0,0 +1,36 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +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"; + +const fileSystemProvisionerStoreInjectable = getInjectable({ + instantiate: (di) => + FileSystemProvisionerStore.createInstance({ + directoryForExtensionData: di.inject( + directoryForExtensionDataInjectable, + ), + }), + + lifecycle: lifecycleEnum.singleton, +}); + +export default fileSystemProvisionerStoreInjectable; diff --git a/src/main/extension-filesystem.ts b/src/extensions/extension-loader/create-extension-instance/file-system-provisioner-store/file-system-provisioner-store.ts similarity index 86% rename from src/main/extension-filesystem.ts rename to src/extensions/extension-loader/create-extension-instance/file-system-provisioner-store/file-system-provisioner-store.ts index cc49a0b706c5..5cbb05cb4341 100644 --- a/src/main/extension-filesystem.ts +++ b/src/extensions/extension-loader/create-extension-instance/file-system-provisioner-store/file-system-provisioner-store.ts @@ -24,24 +24,28 @@ import { SHA256 } from "crypto-js"; import fse from "fs-extra"; import { action, makeObservable, observable } from "mobx"; import path from "path"; -import { BaseStore } from "../common/base-store"; -import type { LensExtensionId } from "../extensions/lens-extension"; -import { toJS } from "../common/utils"; -import { AppPaths } from "../common/app-paths"; +import { BaseStore } from "../../../../common/base-store"; +import type { LensExtensionId } from "../../../lens-extension"; +import { toJS } from "../../../../common/utils"; interface FSProvisionModel { extensions: Record; // extension names to paths } -export class FilesystemProvisionerStore extends BaseStore { +interface Dependencies { + directoryForExtensionData: string +} + +export class FileSystemProvisionerStore extends BaseStore { readonly displayName = "FilesystemProvisionerStore"; registeredExtensions = observable.map(); - constructor() { + constructor(private dependencies: Dependencies) { super({ configName: "lens-filesystem-provisioner-store", accessPropertiesByDotNotation: false, // To make dots safe in cluster context names }); + makeObservable(this); this.load(); } @@ -56,7 +60,8 @@ export class FilesystemProvisionerStore extends BaseStore { if (!this.registeredExtensions.has(extensionName)) { const salt = randomBytes(32).toString("hex"); const hashedName = SHA256(`${extensionName}/${salt}`).toString(); - const dirPath = path.resolve(AppPaths.get("userData"), "extension_data", hashedName); + + const dirPath = path.resolve(this.dependencies.directoryForExtensionData, hashedName); this.registeredExtensions.set(extensionName, dirPath); } diff --git a/src/extensions/extension-loader/extension-loader.injectable.ts b/src/extensions/extension-loader/extension-loader.injectable.ts index ffb87e3b9779..87014dd9eebf 100644 --- a/src/extensions/extension-loader/extension-loader.injectable.ts +++ b/src/extensions/extension-loader/extension-loader.injectable.ts @@ -20,9 +20,17 @@ */ import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; import { ExtensionLoader } from "./extension-loader"; +import updateExtensionsStateInjectable from "./update-extensions-state/update-extensions-state.injectable"; +import createExtensionInstanceInjectable + from "./create-extension-instance/create-extension-instance.injectable"; const extensionLoaderInjectable = getInjectable({ - instantiate: () => new ExtensionLoader(), + instantiate: (di) => + new ExtensionLoader({ + updateExtensionsState: di.inject(updateExtensionsStateInjectable), + createExtensionInstance: di.inject(createExtensionInstanceInjectable), + }), + lifecycle: lifecycleEnum.singleton, }); diff --git a/src/extensions/extension-loader/extension-loader.ts b/src/extensions/extension-loader/extension-loader.ts index 33a716e03a3a..39e97ae30780 100644 --- a/src/extensions/extension-loader/extension-loader.ts +++ b/src/extensions/extension-loader/extension-loader.ts @@ -24,22 +24,27 @@ import { EventEmitter } from "events"; import { isEqual } from "lodash"; import { action, computed, makeObservable, observable, observe, reaction, when } from "mobx"; import path from "path"; -import { AppPaths } from "../../common/app-paths"; import { broadcastMessage, ipcMainOn, ipcRendererOn, requestMain, ipcMainHandle } from "../../common/ipc"; import { Disposer, toJS } from "../../common/utils"; import logger from "../../main/logger"; import type { KubernetesCluster } from "../common-api/catalog"; -import type { InstalledExtension } from "../extension-discovery"; -import { ExtensionsStore } from "../extensions-store"; +import type { InstalledExtension } from "../extension-discovery/extension-discovery"; import type { LensExtension, LensExtensionConstructor, LensExtensionId } from "../lens-extension"; import type { LensRendererExtension } from "../lens-renderer-extension"; import * as registries from "../registries"; +import type { LensExtensionState } from "../extensions-store/extensions-store"; -export function extensionPackagesRoot() { - return path.join(AppPaths.get("userData")); +const logModule = "[EXTENSIONS-LOADER]"; + +interface Dependencies { + updateExtensionsState: (extensionsState: Record) => void + createExtensionInstance: (ExtensionClass: LensExtensionConstructor, extension: InstalledExtension) => LensExtension, } -const logModule = "[EXTENSIONS-LOADER]"; +export interface ExtensionLoading { + isBundled: boolean, + loaded: Promise +} /** * Loads installed extensions to the Lens application @@ -75,8 +80,9 @@ export class ExtensionLoader { return when(() => this.isLoaded); } - constructor() { + constructor(protected dependencies : Dependencies) { makeObservable(this); + observe(this.instances, change => { switch (change.type) { case "add": @@ -154,10 +160,13 @@ export class ExtensionLoader { fireImmediately: true, }); - // save state on change `extension.isEnabled` - reaction(() => this.storeState, extensionsState => { - ExtensionsStore.getInstance().mergeState(extensionsState); - }); + reaction( + () => this.storeState, + + (state) => { + this.dependencies.updateExtensionsState(state); + }, + ); } initExtensions(extensions?: Map) { @@ -253,7 +262,7 @@ export class ExtensionLoader { this.autoInitExtensions(() => Promise.resolve([])); } - loadOnClusterManagerRenderer() { + loadOnClusterManagerRenderer = () => { logger.debug(`${logModule}: load on main renderer (cluster manager)`); return this.autoInitExtensions(async (extension: LensRendererExtension) => { @@ -275,9 +284,9 @@ export class ExtensionLoader { return removeItems; }); - } + }; - loadOnClusterRenderer(entity: KubernetesCluster) { + loadOnClusterRenderer = (entity: KubernetesCluster) => { logger.debug(`${logModule}: load on cluster renderer (dashboard)`); this.autoInitExtensions(async (extension: LensRendererExtension) => { @@ -304,12 +313,12 @@ export class ExtensionLoader { return removeItems; }); - } + }; protected autoInitExtensions(register: (ext: LensExtension) => Promise) { - const loadingExtensions: { isBundled: boolean, loaded: Promise }[] = []; + const loadingExtensions: ExtensionLoading[] = []; - reaction(() => this.toJSON(), installedExtensions => { + reaction(() => this.toJSON(), async installedExtensions => { for (const [extId, extension] of installedExtensions) { const alreadyInit = this.instances.has(extId) || this.nonInstancesByName.has(extension.manifest.name); @@ -322,7 +331,10 @@ export class ExtensionLoader { continue; } - const instance = new LensExtensionClass(extension); + const instance = this.dependencies.createExtensionInstance( + LensExtensionClass, + extension, + ); const loaded = instance.enable(register).catch((err) => { logger.error(`${logModule}: failed to enable`, { ext: extension, err }); diff --git a/src/extensions/extension-loader/update-extensions-state/update-extensions-state.injectable.ts b/src/extensions/extension-loader/update-extensions-state/update-extensions-state.injectable.ts new file mode 100644 index 000000000000..dbc62520cd12 --- /dev/null +++ b/src/extensions/extension-loader/update-extensions-state/update-extensions-state.injectable.ts @@ -0,0 +1,29 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import extensionsStoreInjectable from "../../extensions-store/extensions-store.injectable"; + +const updateExtensionsStateInjectable = getInjectable({ + instantiate: (di) => di.inject(extensionsStoreInjectable).mergeState, + lifecycle: lifecycleEnum.singleton, +}); + +export default updateExtensionsStateInjectable; diff --git a/src/extensions/extension-packages-root/extension-packages-root.injectable.ts b/src/extensions/extension-packages-root/extension-packages-root.injectable.ts new file mode 100644 index 000000000000..a926660e68f7 --- /dev/null +++ b/src/extensions/extension-packages-root/extension-packages-root.injectable.ts @@ -0,0 +1,30 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import directoryForUserDataInjectable + from "../../common/app-paths/directory-for-user-data/directory-for-user-data.injectable"; + +const extensionPackagesRootInjectable = getInjectable({ + instantiate: (di) => di.inject(directoryForUserDataInjectable), + lifecycle: lifecycleEnum.singleton, +}); + +export default extensionPackagesRootInjectable; diff --git a/src/extensions/extensions-store/extensions-store.injectable.ts b/src/extensions/extensions-store/extensions-store.injectable.ts new file mode 100644 index 000000000000..dc148b3759a5 --- /dev/null +++ b/src/extensions/extensions-store/extensions-store.injectable.ts @@ -0,0 +1,29 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { ExtensionsStore } from "./extensions-store"; + +const extensionsStoreInjectable = getInjectable({ + instantiate: () => ExtensionsStore.createInstance(), + lifecycle: lifecycleEnum.singleton, +}); + +export default extensionsStoreInjectable; diff --git a/src/extensions/extensions-store.ts b/src/extensions/extensions-store/extensions-store.ts similarity index 88% rename from src/extensions/extensions-store.ts rename to src/extensions/extensions-store/extensions-store.ts index a69587e95d77..07bb7bed4956 100644 --- a/src/extensions/extensions-store.ts +++ b/src/extensions/extensions-store/extensions-store.ts @@ -19,10 +19,10 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import type { LensExtensionId } from "./lens-extension"; -import { BaseStore } from "../common/base-store"; -import { action, computed, observable, makeObservable } from "mobx"; -import { toJS } from "../common/utils"; +import type { LensExtensionId } from "../lens-extension"; +import { action, computed, makeObservable, observable } from "mobx"; +import { toJS } from "../../common/utils"; +import { BaseStore } from "../../common/base-store"; export interface LensExtensionsStoreModel { extensions: Record; @@ -59,9 +59,9 @@ export class ExtensionsStore extends BaseStore { } @action - mergeState(extensionsState: Record) { + mergeState = (extensionsState: Record) => { this.state.merge(extensionsState); - } + }; @action protected fromStore({ extensions }: LensExtensionsStoreModel) { diff --git a/src/extensions/lens-extension-set-dependencies.ts b/src/extensions/lens-extension-set-dependencies.ts new file mode 100644 index 000000000000..500b19835433 --- /dev/null +++ b/src/extensions/lens-extension-set-dependencies.ts @@ -0,0 +1,30 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import type { FileSystemProvisionerStore } from "./extension-loader/create-extension-instance/file-system-provisioner-store/file-system-provisioner-store"; + +// This symbol encapsulates setting of dependencies to only happen locally in Lens Core +// and not by e.g. authors of extensions +export const setLensExtensionDependencies = Symbol("set-lens-extension-dependencies"); + +export interface LensExtensionDependencies { + fileSystemProvisionerStore: FileSystemProvisionerStore +} diff --git a/src/extensions/lens-extension.ts b/src/extensions/lens-extension.ts index 7b6ca8c94f8f..2c32b984a641 100644 --- a/src/extensions/lens-extension.ts +++ b/src/extensions/lens-extension.ts @@ -19,13 +19,16 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import type { InstalledExtension } from "./extension-discovery"; +import type { InstalledExtension } from "./extension-discovery/extension-discovery"; import { action, observable, makeObservable, computed } from "mobx"; -import { FilesystemProvisionerStore } from "../main/extension-filesystem"; import logger from "../main/logger"; import type { ProtocolHandlerRegistration } from "./registries"; import type { PackageJson } from "type-fest"; import { Disposer, disposer } from "../common/utils"; +import { + LensExtensionDependencies, + setLensExtensionDependencies, +} from "./lens-extension-set-dependencies"; export type LensExtensionId = string; // path to manifest (package.json) export type LensExtensionConstructor = new (...args: ConstructorParameters) => LensExtension; @@ -75,6 +78,12 @@ export class LensExtension { return this.manifest.description; } + private dependencies: LensExtensionDependencies; + + [setLensExtensionDependencies] = (dependencies: LensExtensionDependencies) => { + this.dependencies = dependencies; + }; + /** * getExtensionFileFolder returns the path to an already created folder. This * folder is for the sole use of this extension. @@ -83,7 +92,7 @@ export class LensExtension { * folder name. */ async getExtensionFileFolder(): Promise { - return FilesystemProvisionerStore.getInstance().requestDirectory(this.id); + return this.dependencies.fileSystemProvisionerStore.requestDirectory(this.id); } @action diff --git a/src/extensions/registries/__tests__/page-registry.test.ts b/src/extensions/registries/__tests__/page-registry.test.ts index 00d00961971e..51d82da23cb4 100644 --- a/src/extensions/registries/__tests__/page-registry.test.ts +++ b/src/extensions/registries/__tests__/page-registry.test.ts @@ -26,10 +26,10 @@ import React from "react"; import fse from "fs-extra"; import { Console } from "console"; import { stderr, stdout } from "process"; -import { TerminalStore } from "../../../renderer/components/dock/terminal.store"; import { ThemeStore } from "../../../renderer/theme.store"; import { UserStore } from "../../../common/user-store"; -import { AppPaths } from "../../../common/app-paths"; +import { getDisForUnitTesting } from "../../../test-utils/get-dis-for-unit-testing"; +import mockFs from "mock-fs"; jest.mock("electron", () => ({ app: { @@ -47,14 +47,18 @@ jest.mock("electron", () => ({ }, })); -AppPaths.init(); - console = new Console(stdout, stderr); let ext: LensExtension = null; describe("page registry tests", () => { beforeEach(async () => { + const dis = getDisForUnitTesting({ doGeneralOverrides: true }); + + mockFs(); + + await dis.runSetups(); + ext = new LensExtension({ manifest: { name: "foo-bar", @@ -69,7 +73,6 @@ describe("page registry tests", () => { }); UserStore.createInstance(); ThemeStore.createInstance(); - TerminalStore.createInstance(); ClusterPageRegistry.createInstance(); GlobalPageRegistry.createInstance().add({ id: "page-with-params", @@ -105,10 +108,10 @@ describe("page registry tests", () => { afterEach(() => { GlobalPageRegistry.resetInstance(); ClusterPageRegistry.resetInstance(); - TerminalStore.resetInstance(); ThemeStore.resetInstance(); UserStore.resetInstance(); fse.remove("tmp"); + mockFs.restore(); }); describe("getPageUrl", () => { diff --git a/src/extensions/renderer-api/components.ts b/src/extensions/renderer-api/components.ts index ad31884330b2..005e55c3fd2f 100644 --- a/src/extensions/renderer-api/components.ts +++ b/src/extensions/renderer-api/components.ts @@ -18,9 +18,15 @@ * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ +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 { 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 commandOverlayInjectable from "../../renderer/components/command-palette/command-overlay.injectable"; -import { asLegacyGlobalObjectForExtensionApi } from "../as-legacy-globals-for-extension-api/as-legacy-global-object-for-extension-api"; // layouts export * from "../../renderer/components/layout/main-layout"; @@ -74,5 +80,9 @@ export * from "../../renderer/components/+events/kube-event-details"; // specific exports export * from "../../renderer/components/status-brick"; -export { terminalStore, createTerminalTab, TerminalStore } from "../../renderer/components/dock/terminal.store"; -export { logTabStore } from "../../renderer/components/dock/log-tab.store"; + +export const createTerminalTab = asLegacyGlobalFunctionForExtensionApi(createTerminalTabInjectable); +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 ecae80fb5a0b..6ce8e62074fc 100644 --- a/src/extensions/renderer-api/k8s-api.ts +++ b/src/extensions/renderer-api/k8s-api.ts @@ -86,7 +86,7 @@ export type { NetworkPolicyStore } from "../../renderer/components/+network-poli 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"; +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"; diff --git a/src/main/__test__/cluster.test.ts b/src/main/__test__/cluster.test.ts index ffd77da3d235..1379937c490d 100644 --- a/src/main/__test__/cluster.test.ts +++ b/src/main/__test__/cluster.test.ts @@ -45,25 +45,29 @@ jest.mock("winston", () => ({ })); jest.mock("../../common/ipc"); -jest.mock("../context-handler"); jest.mock("request"); jest.mock("request-promise-native"); import { Console } from "console"; import mockFs from "mock-fs"; -import { Cluster } from "../cluster"; -import { Kubectl } from "../kubectl"; +import type { Cluster } from "../../common/cluster/cluster"; +import { Kubectl } from "../kubectl/kubectl"; +import { getDiForUnitTesting } from "../getDiForUnitTesting"; +import type { ClusterModel } from "../../common/cluster-types"; +import { createClusterInjectionToken } from "../../common/cluster/create-cluster-injection-token"; console = new Console(process.stdout, process.stderr); // fix mockFS describe("create clusters", () => { - beforeEach(() => { + let cluster: Cluster; + let createCluster: (model: ClusterModel) => Cluster; + + beforeEach(async () => { jest.clearAllMocks(); - }); - let c: Cluster; + const di = getDiForUnitTesting({ doGeneralOverrides: true }); + - beforeEach(() => { const mockOpts = { "minikube-config.yml": JSON.stringify({ apiVersion: "v1", @@ -89,8 +93,14 @@ describe("create clusters", () => { }; mockFs(mockOpts); + + await di.runSetups(); + + createCluster = di.inject(createClusterInjectionToken); + jest.spyOn(Kubectl.prototype, "ensureKubectl").mockReturnValue(Promise.resolve(true)); - c = new Cluster({ + + cluster = createCluster({ id: "foo", contextName: "minikube", kubeConfigPath: "minikube-config.yml", @@ -102,48 +112,39 @@ describe("create clusters", () => { }); it("should be able to create a cluster from a cluster model and apiURL should be decoded", () => { - expect(c.apiUrl).toBe("https://192.168.64.3:8443"); + expect(cluster.apiUrl).toBe("https://192.168.64.3:8443"); }); it("reconnect should not throw if contextHandler is missing", () => { - expect(() => c.reconnect()).not.toThrowError(); + expect(() => cluster.reconnect()).not.toThrowError(); }); it("disconnect should not throw if contextHandler is missing", () => { - expect(() => c.disconnect()).not.toThrowError(); + expect(() => cluster.disconnect()).not.toThrowError(); }); it("activating cluster should try to connect to cluster and do a refresh", async () => { - - const c = new class extends Cluster { - // only way to mock protected methods, without these we leak promises - protected bindEvents() { - return; - } - async ensureKubectl() { - return Promise.resolve(null); - } - }({ + const cluster = createCluster({ id: "foo", contextName: "minikube", kubeConfigPath: "minikube-config.yml", }); - c.contextHandler = { + cluster.contextHandler = { ensureServer: jest.fn(), stopServer: jest.fn(), } as any; - jest.spyOn(c, "reconnect"); - jest.spyOn(c, "canI"); - jest.spyOn(c, "refreshConnectionStatus"); + jest.spyOn(cluster, "reconnect"); + jest.spyOn(cluster, "canI"); + jest.spyOn(cluster, "refreshConnectionStatus"); - await c.activate(); + await cluster.activate(); - expect(c.reconnect).toBeCalled(); - expect(c.refreshConnectionStatus).toBeCalled(); + expect(cluster.reconnect).toBeCalled(); + expect(cluster.refreshConnectionStatus).toBeCalled(); - c.disconnect(); + cluster.disconnect(); jest.resetAllMocks(); }); }); diff --git a/src/main/__test__/context-handler.test.ts b/src/main/__test__/context-handler.test.ts index 330195f22e32..03f54a3a2a25 100644 --- a/src/main/__test__/context-handler.test.ts +++ b/src/main/__test__/context-handler.test.ts @@ -20,10 +20,12 @@ */ import { UserStore } from "../../common/user-store"; -import { ContextHandler } from "../context-handler"; +import type { ContextHandler } from "../context-handler/context-handler"; import { PrometheusProvider, PrometheusProviderRegistry, PrometheusService } from "../prometheus"; import mockFs from "mock-fs"; -import { AppPaths } from "../../common/app-paths"; +import { getDiForUnitTesting } from "../getDiForUnitTesting"; +import createContextHandlerInjectable from "../context-handler/create-context-handler.injectable"; +import type { Cluster } from "../../common/cluster/cluster"; jest.mock("electron", () => ({ app: { @@ -77,25 +79,28 @@ class TestProvider extends PrometheusProvider { } } -function getHandler() { - return new ContextHandler(({ - getProxyKubeconfig: (): any => ({ - makeApiClient: (): any => undefined, - }), - apiUrl: "http://localhost:81", - }) as any); -} - -AppPaths.init(); +const clusterStub = { + getProxyKubeconfig: (): any => ({ + makeApiClient: (): any => undefined, + }), + apiUrl: "http://localhost:81", +} as Cluster; describe("ContextHandler", () => { - beforeEach(() => { + let createContextHandler: (cluster: Cluster) => ContextHandler; + + beforeEach(async () => { + const di = getDiForUnitTesting({ doGeneralOverrides: true }); + mockFs({ "tmp": {}, }); + await di.runSetups(); + + createContextHandler = di.inject(createContextHandlerInjectable); + PrometheusProviderRegistry.createInstance(); - UserStore.createInstance(); }); afterEach(() => { @@ -124,7 +129,12 @@ describe("ContextHandler", () => { reg.registerProvider(new TestProvider(`id_${count++}`, ServiceResult.Success)); } - expect(() => (getHandler() as any).getPrometheusService()).rejects.toBeDefined(); + expect(() => { + // TODO: Unit test shouldn't access protected or private methods + const contextHandler = createContextHandler(clusterStub) as any; + + return contextHandler.getPrometheusService(); + }).rejects.toBeDefined(); }); it.each([ @@ -150,7 +160,10 @@ describe("ContextHandler", () => { reg.registerProvider(new TestProvider(`id_${count++}`, ServiceResult.Success)); } - const service = await (getHandler() as any).getPrometheusService(); + // TODO: Unit test shouldn't access protected or private methods + const contextHandler = createContextHandler(clusterStub) as any; + + const service = await contextHandler.getPrometheusService(); expect(service.id === `id_${failures}`); }); @@ -178,7 +191,10 @@ describe("ContextHandler", () => { reg.registerProvider(new TestProvider(`id_${count++}`, serviceResult)); } - const service = await (getHandler() as any).getPrometheusService(); + // TODO: Unit test shouldn't access protected or private methods + const contextHandler = createContextHandler(clusterStub) as any; + + const service = await contextHandler.getPrometheusService(); expect(service.id === "id_0"); }); @@ -211,8 +227,11 @@ describe("ContextHandler", () => { for (let i = 0; i < afterSuccesses; i += 1) { reg.registerProvider(new TestProvider(`id_${count++}`, ServiceResult.Success)); } - - const service = await (getHandler() as any).getPrometheusService(); + + // TODO: Unit test shouldn't access protected or private methods + const contextHandler = createContextHandler(clusterStub) as any; + + const service = await contextHandler.getPrometheusService(); expect(service.id === "id_0"); }); @@ -224,8 +243,11 @@ describe("ContextHandler", () => { reg.registerProvider(new TestProvider(`id_${count++}`, ServiceResult.Failure)); reg.registerProvider(new TestProvider(`id_${count++}`, ServiceResult.Success)); reg.registerProvider(new TestProvider(`id_${count++}`, ServiceResult.Success)); + + // TODO: Unit test shouldn't access protected or private methods + const contextHandler = createContextHandler(clusterStub) as any; - const service = await (getHandler() as any).getPrometheusService(); + const service = await contextHandler.getPrometheusService(); expect(service.id).not.toBe("id_2"); }); diff --git a/src/main/__test__/kube-auth-proxy.test.ts b/src/main/__test__/kube-auth-proxy.test.ts index a2ba4cb453b0..b56250e34fdf 100644 --- a/src/main/__test__/kube-auth-proxy.test.ts +++ b/src/main/__test__/kube-auth-proxy.test.ts @@ -19,6 +19,8 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ +import type { ClusterModel } from "../../common/cluster-types"; + jest.mock("winston", () => ({ format: { colorize: jest.fn(), @@ -48,11 +50,11 @@ jest.mock("../../common/ipc"); jest.mock("child_process"); jest.mock("tcp-port-used"); -import { Cluster } from "../cluster"; -import { KubeAuthProxy } from "../kube-auth-proxy"; +import type { Cluster } from "../../common/cluster/cluster"; +import type { KubeAuthProxy } from "../kube-auth-proxy/kube-auth-proxy"; import { broadcastMessage } from "../../common/ipc"; import { ChildProcess, spawn } from "child_process"; -import { bundledKubectlPath, Kubectl } from "../kubectl"; +import { bundledKubectlPath, Kubectl } from "../kubectl/kubectl"; import { mock, MockProxy } from "jest-mock-extended"; import { waitUntilUsed } from "tcp-port-used"; import { EventEmitter, Readable } from "stream"; @@ -60,7 +62,9 @@ import { UserStore } from "../../common/user-store"; import { Console } from "console"; import { stdout, stderr } from "process"; import mockFs from "mock-fs"; -import { AppPaths } from "../../common/app-paths"; +import { getDiForUnitTesting } from "../getDiForUnitTesting"; +import createKubeAuthProxyInjectable from "../kube-auth-proxy/create-kube-auth-proxy.injectable"; +import { createClusterInjectionToken } from "../../common/cluster/create-cluster-injection-token"; console = new Console(stdout, stderr); @@ -68,25 +72,11 @@ const mockBroadcastIpc = broadcastMessage as jest.MockedFunction; const mockWaitUntilUsed = waitUntilUsed as jest.MockedFunction; -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(), - }, -})); -AppPaths.init(); - describe("kube auth proxy tests", () => { - beforeEach(() => { + let createCluster: (model: ClusterModel) => Cluster; + let createKubeAuthProxy: (cluster: Cluster, environmentVariables: NodeJS.ProcessEnv) => KubeAuthProxy; + + beforeEach(async () => { jest.clearAllMocks(); const mockMinikubeConfig = { @@ -115,7 +105,16 @@ describe("kube auth proxy tests", () => { "tmp": {}, }; + const di = getDiForUnitTesting({ doGeneralOverrides: true }); + mockFs(mockMinikubeConfig); + + await di.runSetups(); + + createCluster = di.inject(createClusterInjectionToken); + + createKubeAuthProxy = di.inject(createKubeAuthProxyInjectable); + UserStore.createInstance(); }); @@ -125,7 +124,13 @@ describe("kube auth proxy tests", () => { }); it("calling exit multiple times shouldn't throw", async () => { - const kap = new KubeAuthProxy(new Cluster({ id: "foobar", kubeConfigPath: "minikube-config.yml", contextName: "minikube" }), {}); + const cluster = createCluster({ + id: "foobar", + kubeConfigPath: "minikube-config.yml", + contextName: "minikube", + }); + + const kap = createKubeAuthProxy(cluster, {}); kap.exit(); kap.exit(); @@ -211,9 +216,13 @@ describe("kube auth proxy tests", () => { }); mockWaitUntilUsed.mockReturnValueOnce(Promise.resolve()); - const cluster = new Cluster({ id: "foobar", kubeConfigPath: "minikube-config.yml", contextName: "minikube" }); + const cluster = createCluster({ + id: "foobar", + kubeConfigPath: "minikube-config.yml", + contextName: "minikube", + }); - proxy = new KubeAuthProxy(cluster, {}); + proxy = createKubeAuthProxy(cluster, {}); }); it("should call spawn and broadcast errors", async () => { diff --git a/src/main/__test__/kubeconfig-manager.test.ts b/src/main/__test__/kubeconfig-manager.test.ts index 641881c38b30..a46e76c797f8 100644 --- a/src/main/__test__/kubeconfig-manager.test.ts +++ b/src/main/__test__/kubeconfig-manager.test.ts @@ -18,7 +18,6 @@ * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ - const logger = { silly: jest.fn(), debug: jest.fn(), @@ -46,41 +45,29 @@ jest.mock("winston", () => ({ }, })); -import { KubeconfigManager } from "../kubeconfig-manager"; +import { getDiForUnitTesting } from "../getDiForUnitTesting"; +import { KubeconfigManager } from "../kubeconfig-manager/kubeconfig-manager"; import mockFs from "mock-fs"; -import { Cluster } from "../cluster"; -import type { ContextHandler } from "../context-handler"; +import type { Cluster } from "../../common/cluster/cluster"; import fse from "fs-extra"; import { loadYaml } from "@kubernetes/client-node"; import { Console } from "console"; import * as path from "path"; -import { AppPaths } from "../../common/app-paths"; - -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(), - }, -})); - -AppPaths.init(); +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"; console = new Console(process.stdout, process.stderr); // fix mockFS describe("kubeconfig manager tests", () => { let cluster: Cluster; - let contextHandler: ContextHandler; + let createKubeconfigManager: (cluster: Cluster) => KubeconfigManager; + + beforeEach(async () => { + const di = getDiForUnitTesting({ doGeneralOverrides: true }); + + di.override(directoryForTempInjectable, () => "some-directory-for-temp"); - beforeEach(() => { mockFs({ "minikube-config.yml": JSON.stringify({ apiVersion: "v1", @@ -105,14 +92,22 @@ describe("kubeconfig manager tests", () => { }), }); - cluster = new Cluster({ + await di.runSetups(); + + const createCluster = di.inject(createClusterInjectionToken); + + createKubeconfigManager = di.inject(createKubeconfigManagerInjectable); + + cluster = createCluster({ id: "foo", contextName: "minikube", kubeConfigPath: "minikube-config.yml", }); - contextHandler = { + + cluster.contextHandler = { ensureServer: () => Promise.resolve(), } as any; + jest.spyOn(KubeconfigManager.prototype, "resolveProxyUrl", "get").mockReturnValue("http://127.0.0.1:9191/foo"); }); @@ -121,10 +116,10 @@ describe("kubeconfig manager tests", () => { }); it("should create 'temp' kube config with proxy", async () => { - const kubeConfManager = new KubeconfigManager(cluster, contextHandler); + const kubeConfManager = createKubeconfigManager(cluster); expect(logger.error).not.toBeCalled(); - expect(await kubeConfManager.getPath()).toBe(`tmp${path.sep}kubeconfig-foo`); + expect(await kubeConfManager.getPath()).toBe(`some-directory-for-temp${path.sep}kubeconfig-foo`); // this causes an intermittent "ENXIO: no such device or address, read" error // const file = await fse.readFile(await kubeConfManager.getPath()); const file = fse.readFileSync(await kubeConfManager.getPath()); @@ -136,7 +131,8 @@ describe("kubeconfig manager tests", () => { }); it("should remove 'temp' kube config on unlink and remove reference from inside class", async () => { - const kubeConfManager = new KubeconfigManager(cluster, contextHandler); + const kubeConfManager = createKubeconfigManager(cluster); + const configPath = await kubeConfManager.getPath(); expect(await fse.pathExists(configPath)).toBe(true); diff --git a/src/main/__test__/router.test.ts b/src/main/__test__/router.test.ts index 278467eca07e..9dd87a9bf6b9 100644 --- a/src/main/__test__/router.test.ts +++ b/src/main/__test__/router.test.ts @@ -19,7 +19,6 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import { AppPaths } from "../../common/app-paths"; import { Router } from "../router"; jest.mock("electron", () => ({ @@ -38,8 +37,6 @@ jest.mock("electron", () => ({ }, })); -AppPaths.init(); - describe("Router", () => { it("blocks path traversal attacks", async () => { const response: any = { diff --git a/src/main/app-paths/app-name/app-name.injectable.ts b/src/main/app-paths/app-name/app-name.injectable.ts new file mode 100644 index 000000000000..60edfac709c7 --- /dev/null +++ b/src/main/app-paths/app-name/app-name.injectable.ts @@ -0,0 +1,29 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import electronAppInjectable from "../get-electron-app-path/electron-app/electron-app.injectable"; + +const appNameInjectable = getInjectable({ + instantiate: (di) => di.inject(electronAppInjectable).name, + lifecycle: lifecycleEnum.singleton, +}); + +export default appNameInjectable; diff --git a/src/main/app-paths/app-paths.injectable.ts b/src/main/app-paths/app-paths.injectable.ts new file mode 100644 index 000000000000..86b89ae4ca7c --- /dev/null +++ b/src/main/app-paths/app-paths.injectable.ts @@ -0,0 +1,84 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +import { + DependencyInjectionContainer, + getInjectable, + lifecycleEnum, +} from "@ogre-tools/injectable"; + +import { + appPathsInjectionToken, + appPathsIpcChannel, +} from "../../common/app-paths/app-path-injection-token"; + +import registerChannelInjectable from "./register-channel/register-channel.injectable"; +import { getAppPaths } from "./get-app-paths"; +import getElectronAppPathInjectable from "./get-electron-app-path/get-electron-app-path.injectable"; +import setElectronAppPathInjectable from "./set-electron-app-path/set-electron-app-path.injectable"; +import path from "path"; +import appNameInjectable from "./app-name/app-name.injectable"; +import directoryForIntegrationTestingInjectable from "./directory-for-integration-testing/directory-for-integration-testing.injectable"; + +const appPathsInjectable = getInjectable({ + setup: (di) => { + const directoryForIntegrationTesting = di.inject( + directoryForIntegrationTestingInjectable, + ); + + if (directoryForIntegrationTesting) { + setupPathForAppDataInIntegrationTesting(di, directoryForIntegrationTesting); + } + + setupPathForUserData(di); + registerAppPathsChannel(di); + }, + + instantiate: (di) => + getAppPaths({ getAppPath: di.inject(getElectronAppPathInjectable) }), + + injectionToken: appPathsInjectionToken, + lifecycle: lifecycleEnum.singleton, +}); + +export default appPathsInjectable; + +const registerAppPathsChannel = (di: DependencyInjectionContainer) => { + const registerChannel = di.inject(registerChannelInjectable); + + registerChannel(appPathsIpcChannel, () => di.inject(appPathsInjectable)); +}; + +const setupPathForUserData = (di: DependencyInjectionContainer) => { + const setElectronAppPath = di.inject(setElectronAppPathInjectable); + const appName = di.inject(appNameInjectable); + const getAppPath = di.inject(getElectronAppPathInjectable); + + const appDataPath = getAppPath("appData"); + + setElectronAppPath("userData", path.join(appDataPath, appName)); +}; + +// Todo: this kludge is here only until we have a proper place to setup integration testing. +const setupPathForAppDataInIntegrationTesting = (di: DependencyInjectionContainer, appDataPath: string) => { + const setElectronAppPath = di.inject(setElectronAppPathInjectable); + + setElectronAppPath("appData", appDataPath); +}; diff --git a/src/main/app-paths/directory-for-integration-testing/directory-for-integration-testing.injectable.ts b/src/main/app-paths/directory-for-integration-testing/directory-for-integration-testing.injectable.ts new file mode 100644 index 000000000000..0588fc9f82c9 --- /dev/null +++ b/src/main/app-paths/directory-for-integration-testing/directory-for-integration-testing.injectable.ts @@ -0,0 +1,28 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; + +const directoryForIntegrationTestingInjectable = getInjectable({ + instantiate: () => process.env.CICD, + lifecycle: lifecycleEnum.singleton, +}); + +export default directoryForIntegrationTestingInjectable; diff --git a/src/main/app-paths/get-app-paths.ts b/src/main/app-paths/get-app-paths.ts new file mode 100644 index 000000000000..f2f8f50b65ba --- /dev/null +++ b/src/main/app-paths/get-app-paths.ts @@ -0,0 +1,30 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +import { fromPairs } from "lodash/fp"; +import { pathNames, PathName } from "../../common/app-paths/app-path-names"; +import type { AppPaths } from "../../common/app-paths/app-path-injection-token"; + +interface Dependencies { + getAppPath: (name: PathName) => string +} + +export const getAppPaths = ({ getAppPath }: Dependencies) => + fromPairs(pathNames.map((name) => [name, getAppPath(name)])) as AppPaths; diff --git a/src/main/app-paths/get-electron-app-path/electron-app/electron-app.injectable.ts b/src/main/app-paths/get-electron-app-path/electron-app/electron-app.injectable.ts new file mode 100644 index 000000000000..891cb2486464 --- /dev/null +++ b/src/main/app-paths/get-electron-app-path/electron-app/electron-app.injectable.ts @@ -0,0 +1,30 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { app } from "electron"; + +const electronAppInjectable = getInjectable({ + instantiate: () => app, + lifecycle: lifecycleEnum.singleton, + causesSideEffects: true, +}); + +export default electronAppInjectable; diff --git a/src/main/app-paths/get-electron-app-path/get-electron-app-path.injectable.ts b/src/main/app-paths/get-electron-app-path/get-electron-app-path.injectable.ts new file mode 100644 index 000000000000..216daec6dd71 --- /dev/null +++ b/src/main/app-paths/get-electron-app-path/get-electron-app-path.injectable.ts @@ -0,0 +1,32 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import electronAppInjectable from "./electron-app/electron-app.injectable"; +import { getElectronAppPath } from "./get-electron-app-path"; + +const getElectronAppPathInjectable = getInjectable({ + instantiate: (di) => + getElectronAppPath({ app: di.inject(electronAppInjectable) }), + + lifecycle: lifecycleEnum.singleton, +}); + +export default getElectronAppPathInjectable; diff --git a/src/main/app-paths/get-electron-app-path/get-electron-app-path.test.ts b/src/main/app-paths/get-electron-app-path/get-electron-app-path.test.ts new file mode 100644 index 000000000000..164c2b80208b --- /dev/null +++ b/src/main/app-paths/get-electron-app-path/get-electron-app-path.test.ts @@ -0,0 +1,67 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +import electronAppInjectable from "./electron-app/electron-app.injectable"; +import getElectronAppPathInjectable from "./get-electron-app-path.injectable"; +import { getDiForUnitTesting } from "../../getDiForUnitTesting"; +import type { App } from "electron"; +import registerChannelInjectable from "../register-channel/register-channel.injectable"; + +describe("get-electron-app-path", () => { + let getElectronAppPath: (name: string) => string | null; + + beforeEach(async () => { + const di = getDiForUnitTesting({ doGeneralOverrides: false }); + + const appStub = { + name: "some-app-name", + + getPath: (name: string) => { + if (name !== "some-existing-name") { + throw new Error("irrelevant"); + } + + return "some-existing-app-path"; + }, + + // eslint-disable-next-line unused-imports/no-unused-vars-ts + setPath: (_, __) => undefined, + } as App; + + di.override(electronAppInjectable, () => appStub); + di.override(registerChannelInjectable, () => () => undefined); + + await di.runSetups(); + + getElectronAppPath = di.inject(getElectronAppPathInjectable); + }); + + it("given app path exists, when called, returns app path", () => { + const actual = getElectronAppPath("some-existing-name"); + + expect(actual).toBe("some-existing-app-path"); + }); + + it("given app path does not exist, when called, returns null", () => { + const actual = getElectronAppPath("some-non-existing-name"); + + expect(actual).toBe(""); + }); +}); diff --git a/src/main/app-paths/get-electron-app-path/get-electron-app-path.ts b/src/main/app-paths/get-electron-app-path/get-electron-app-path.ts new file mode 100644 index 000000000000..beec2ac74fa3 --- /dev/null +++ b/src/main/app-paths/get-electron-app-path/get-electron-app-path.ts @@ -0,0 +1,36 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +import type { App } from "electron"; +import type { PathName } from "../../../common/app-paths/app-path-names"; + +interface Dependencies { + app: App; +} + +export const getElectronAppPath = + ({ app }: Dependencies) => + (name: PathName) : string | null => { + try { + return app.getPath(name); + } catch (e) { + return ""; + } + }; diff --git a/src/main/app-paths/register-channel/ipc-main/ipc-main.injectable.ts b/src/main/app-paths/register-channel/ipc-main/ipc-main.injectable.ts new file mode 100644 index 000000000000..200dfbad92b8 --- /dev/null +++ b/src/main/app-paths/register-channel/ipc-main/ipc-main.injectable.ts @@ -0,0 +1,30 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { ipcMain } from "electron"; + +const ipcMainInjectable = getInjectable({ + instantiate: () => ipcMain, + lifecycle: lifecycleEnum.singleton, + causesSideEffects: true, +}); + +export default ipcMainInjectable; diff --git a/src/main/app-paths/register-channel/register-channel.injectable.ts b/src/main/app-paths/register-channel/register-channel.injectable.ts new file mode 100644 index 000000000000..e79d1157586b --- /dev/null +++ b/src/main/app-paths/register-channel/register-channel.injectable.ts @@ -0,0 +1,33 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import ipcMainInjectable from "./ipc-main/ipc-main.injectable"; +import { registerChannel } from "./register-channel"; + +const registerChannelInjectable = getInjectable({ + instantiate: (di) => registerChannel({ + ipcMain: di.inject(ipcMainInjectable), + }), + + lifecycle: lifecycleEnum.singleton, +}); + +export default registerChannelInjectable; diff --git a/src/main/app-paths/register-channel/register-channel.ts b/src/main/app-paths/register-channel/register-channel.ts new file mode 100644 index 000000000000..712f6b9473fb --- /dev/null +++ b/src/main/app-paths/register-channel/register-channel.ts @@ -0,0 +1,34 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +import type { IpcMain } from "electron"; +import type { Channel } from "../../../common/ipc-channel/channel"; + +interface Dependencies { + ipcMain: IpcMain; +} + +export const registerChannel = + ({ ipcMain }: Dependencies) => + , TInstance>( + channel: TChannel, + getValue: () => TInstance, + ) => + ipcMain.handle(channel.name, getValue); diff --git a/src/main/app-paths/set-electron-app-path/set-electron-app-path.injectable.ts b/src/main/app-paths/set-electron-app-path/set-electron-app-path.injectable.ts new file mode 100644 index 000000000000..4dfe123cb4ff --- /dev/null +++ b/src/main/app-paths/set-electron-app-path/set-electron-app-path.injectable.ts @@ -0,0 +1,32 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import type { PathName } from "../../../common/app-paths/app-path-names"; +import electronAppInjectable from "../get-electron-app-path/electron-app/electron-app.injectable"; + +const setElectronAppPathInjectable = getInjectable({ + instantiate: (di) => (name: PathName, path: string) : void => + di.inject(electronAppInjectable).setPath(name, path), + + lifecycle: lifecycleEnum.singleton, +}); + +export default setElectronAppPathInjectable; diff --git a/src/main/catalog-sources/__test__/kubeconfig-sync.test.ts b/src/main/catalog-sources/__test__/kubeconfig-sync.test.ts index 5c3a7094ae35..2c0ea4c4e384 100644 --- a/src/main/catalog-sources/__test__/kubeconfig-sync.test.ts +++ b/src/main/catalog-sources/__test__/kubeconfig-sync.test.ts @@ -22,13 +22,17 @@ import { ObservableMap } from "mobx"; import type { CatalogEntity } from "../../../common/catalog"; import { loadFromOptions } from "../../../common/kube-helpers"; -import type { Cluster } from "../../cluster"; -import { computeDiff, configToModels } from "../kubeconfig-sync"; +import type { Cluster } from "../../../common/cluster/cluster"; +import { computeDiff as computeDiffFor, configToModels } from "../kubeconfig-sync-manager/kubeconfig-sync-manager"; import mockFs from "mock-fs"; import fs from "fs"; -import { ClusterStore } from "../../../common/cluster-store"; import { ClusterManager } from "../../cluster-manager"; -import { AppPaths } from "../../../common/app-paths"; +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"; + jest.mock("electron", () => ({ app: { @@ -46,18 +50,28 @@ jest.mock("electron", () => ({ }, })); -AppPaths.init(); - describe("kubeconfig-sync.source tests", () => { - beforeEach(() => { + let computeDiff: ReturnType; + + beforeEach(async () => { + const di = getDiForUnitTesting({ doGeneralOverrides: true }); + mockFs(); - ClusterStore.createInstance(); + + await di.runSetups(); + + computeDiff = computeDiffFor({ + directoryForKubeConfigs: di.inject(directoryForKubeConfigsInjectable), + createCluster: di.inject(createClusterInjectionToken), + }); + + di.inject(clusterStoreInjectable); + ClusterManager.createInstance(); }); afterEach(() => { mockFs.restore(); - ClusterStore.resetInstance(); ClusterManager.resetInstance(); }); diff --git a/src/main/catalog-sources/index.ts b/src/main/catalog-sources/index.ts index 70c2d073fbf4..348f03bbd39a 100644 --- a/src/main/catalog-sources/index.ts +++ b/src/main/catalog-sources/index.ts @@ -20,5 +20,4 @@ */ export { syncWeblinks } from "./weblinks"; -export { KubeconfigSyncManager } from "./kubeconfig-sync"; 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 new file mode 100644 index 000000000000..c2b8b4818c13 --- /dev/null +++ b/src/main/catalog-sources/kubeconfig-sync-manager/kubeconfig-sync-manager.injectable.ts @@ -0,0 +1,35 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +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.ts b/src/main/catalog-sources/kubeconfig-sync-manager/kubeconfig-sync-manager.ts similarity index 86% rename from src/main/catalog-sources/kubeconfig-sync.ts rename to src/main/catalog-sources/kubeconfig-sync-manager/kubeconfig-sync-manager.ts index e011ff68605c..90639fbd45ed 100644 --- a/src/main/catalog-sources/kubeconfig-sync.ts +++ b/src/main/catalog-sources/kubeconfig-sync-manager/kubeconfig-sync-manager.ts @@ -20,25 +20,25 @@ */ import { action, observable, IComputedValue, computed, ObservableMap, runInAction, makeObservable, observe } from "mobx"; -import type { CatalogEntity } from "../../common/catalog"; -import { catalogEntityRegistry } from "../../main/catalog"; +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, Singleton, storedKubeConfigFolder } from "../../common/utils"; -import logger from "../logger"; +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 { Cluster } from "../cluster"; -import { catalogEntityFromCluster, ClusterManager } from "../cluster-manager"; -import { UserStore } from "../../common/user-store"; -import { ClusterStore } from "../../common/cluster-store"; +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 { UpdateClusterModel } from "../../common/cluster-types"; +import type { ClusterModel, UpdateClusterModel } from "../../../common/cluster-types"; +import type { Cluster } from "../../../common/cluster/cluster"; const logPrefix = "[KUBECONFIG-SYNC]:"; @@ -63,16 +63,19 @@ const ignoreGlobs = [ const folderSyncMaxAllowedFileReadSize = 2 * 1024 * 1024; // 2 MiB const fileSyncMaxAllowedFileReadSize = 16 * folderSyncMaxAllowedFileReadSize; // 32 MiB -export class KubeconfigSyncManager extends Singleton { +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; - protected static readonly syncName = "lens:kube-sync"; - - constructor() { - super(); - + constructor(private dependencies: Dependencies) { makeObservable(this); } @@ -86,7 +89,7 @@ export class KubeconfigSyncManager extends Singleton { logger.info(`${logPrefix} starting requested syncs`); - catalogEntityRegistry.addComputedSource(KubeconfigSyncManager.syncName, computed(() => ( + catalogEntityRegistry.addComputedSource(kubeConfigSyncName, computed(() => ( Array.from(iter.flatMap( this.sources.values(), ([entities]) => entities.get(), @@ -94,7 +97,7 @@ export class KubeconfigSyncManager extends Singleton { ))); // This must be done so that c&p-ed clusters are visible - this.startNewSync(storedKubeConfigFolder()); + this.startNewSync(this.dependencies.directoryForKubeConfigs); for (const filePath of UserStore.getInstance().syncKubeconfigEntries.keys()) { this.startNewSync(filePath); @@ -120,7 +123,7 @@ export class KubeconfigSyncManager extends Singleton { this.stopOldSync(filePath); } - catalogEntityRegistry.removeSource(KubeconfigSyncManager.syncName); + catalogEntityRegistry.removeSource(kubeConfigSyncName); this.syncing = false; } @@ -131,7 +134,11 @@ export class KubeconfigSyncManager extends Singleton { return void logger.debug(`${logPrefix} already syncing file/folder`, { filePath }); } - this.sources.set(filePath, watchFileChanges(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()) }); } @@ -170,7 +177,7 @@ type RootSourceValue = [Cluster, CatalogEntity]; type RootSource = ObservableMap; // exported for testing -export function computeDiff(contents: string, source: RootSource, filePath: string): void { +export const computeDiff = ({ directoryForKubeConfigs, createCluster }: Dependencies) => (contents: string, source: RootSource, filePath: string): void => { runInAction(() => { try { const { config, error } = loadConfigFromString(contents); @@ -212,7 +219,8 @@ export function computeDiff(contents: string, source: RootSource, filePath: stri // add new clusters to the source try { const clusterId = createHash("md5").update(`${filePath}:${contextName}`).digest("hex"); - const cluster = ClusterStore.getInstance().getById(clusterId) || new Cluster({ ...model, id: clusterId }); + + const cluster = ClusterStore.getInstance().getById(clusterId) || createCluster({ ...model, id: clusterId }); if (!cluster.apiUrl) { throw new Error("Cluster constructor failed, see above error"); @@ -220,7 +228,7 @@ export function computeDiff(contents: string, source: RootSource, filePath: stri const entity = catalogEntityFromCluster(cluster); - if (!filePath.startsWith(storedKubeConfigFolder())) { + if (!filePath.startsWith(directoryForKubeConfigs)) { entity.metadata.labels.file = filePath.replace(homedir(), "~"); } source.set(contextName, [cluster, entity]); @@ -231,11 +239,12 @@ export function computeDiff(contents: string, source: RootSource, filePath: stri } } } 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; @@ -244,7 +253,7 @@ interface DiffChangedConfigArgs { maxAllowedFileReadSize: number; } -function diffChangedConfig({ filePath, source, stats, maxAllowedFileReadSize }: DiffChangedConfigArgs): Disposer { +const diffChangedConfigFor = (dependencies: Dependencies) => ({ filePath, source, stats, maxAllowedFileReadSize }: DiffChangedConfigArgs): Disposer => { logger.debug(`${logPrefix} file changed`, { filePath }); if (stats.size >= maxAllowedFileReadSize) { @@ -293,14 +302,14 @@ function diffChangedConfig({ filePath, source, stats, maxAllowedFileReadSize }: }) .on("end", () => { if (!closed) { - computeDiff(fileString, source, filePath); + computeDiff(dependencies)(fileString, source, filePath); } }); return cleanup; -} +}; -function watchFileChanges(filePath: string): [IComputedValue, Disposer] { +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])))); @@ -328,6 +337,8 @@ function watchFileChanges(filePath: string): [IComputedValue, D atomic: 150, // for "atomic writes" }); + const diffChangedConfig = diffChangedConfigFor(dependencies); + watcher .on("change", (childFilePath, stats) => { const cleanup = cleanupFns.get(childFilePath); @@ -378,4 +389,4 @@ function watchFileChanges(filePath: string): [IComputedValue, D return [derivedSource, () => { watcher?.close(); }]; -} +}; diff --git a/src/main/cluster-detectors/base-cluster-detector.ts b/src/main/cluster-detectors/base-cluster-detector.ts index 1edd971cfd3d..8f60fbe112e6 100644 --- a/src/main/cluster-detectors/base-cluster-detector.ts +++ b/src/main/cluster-detectors/base-cluster-detector.ts @@ -20,7 +20,7 @@ */ import type { RequestPromiseOptions } from "request-promise-native"; -import type { Cluster } from "../cluster"; +import type { Cluster } from "../../common/cluster/cluster"; import { k8sRequest } from "../k8s-request"; export type ClusterDetectionResult = { diff --git a/src/main/cluster-detectors/detector-registry.ts b/src/main/cluster-detectors/detector-registry.ts index 5f4f12175e89..75d63f5b2ae9 100644 --- a/src/main/cluster-detectors/detector-registry.ts +++ b/src/main/cluster-detectors/detector-registry.ts @@ -22,7 +22,7 @@ import { observable } from "mobx"; import type { ClusterMetadata } from "../../common/cluster-types"; import { Singleton } from "../../common/utils"; -import type { Cluster } from "../cluster"; +import type { Cluster } from "../../common/cluster/cluster"; import type { BaseClusterDetector, ClusterDetectionResult } from "./base-cluster-detector"; export class DetectorRegistry extends Singleton { diff --git a/src/main/cluster-manager.ts b/src/main/cluster-manager.ts index 850b87c7b8dd..ecf06c975efc 100644 --- a/src/main/cluster-manager.ts +++ b/src/main/cluster-manager.ts @@ -22,7 +22,7 @@ import "../common/cluster-ipc"; import type http from "http"; import { action, makeObservable, observable, observe, reaction, toJS } from "mobx"; -import { Cluster } from "./cluster"; +import { Cluster } from "../common/cluster/cluster"; import logger from "./logger"; import { apiKubePrefix } from "../common/vars"; import { getClusterIdFromHost, Singleton } from "../common/utils"; @@ -30,7 +30,7 @@ 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"; +import { ClusterStore } from "../common/cluster-store/cluster-store"; import type { ClusterId } from "../common/cluster-types"; const logPrefix = "[CLUSTER-MANAGER]:"; diff --git a/src/main/context-handler.ts b/src/main/context-handler/context-handler.ts similarity index 88% rename from src/main/context-handler.ts rename to src/main/context-handler/context-handler.ts index 3042c0c4f340..f5b240589a45 100644 --- a/src/main/context-handler.ts +++ b/src/main/context-handler/context-handler.ts @@ -19,15 +19,15 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import type { PrometheusProvider, PrometheusService } from "./prometheus/provider-registry"; -import { PrometheusProviderRegistry } from "./prometheus/provider-registry"; -import type { ClusterPrometheusPreferences } from "../common/cluster-types"; -import type { Cluster } from "./cluster"; +import type { PrometheusProvider, PrometheusService } from "../prometheus/provider-registry"; +import { PrometheusProviderRegistry } from "../prometheus/provider-registry"; +import type { ClusterPrometheusPreferences } from "../../common/cluster-types"; +import type { Cluster } from "../../common/cluster/cluster"; import type httpProxy from "http-proxy"; import url, { UrlWithStringQuery } from "url"; import { CoreV1Api } from "@kubernetes/client-node"; -import logger from "./logger"; -import { KubeAuthProxy } from "./kube-auth-proxy"; +import logger from "../logger"; +import type { KubeAuthProxy } from "../kube-auth-proxy/kube-auth-proxy"; export interface PrometheusDetails { prometheusPath: string; @@ -41,6 +41,10 @@ interface PrometheusServicePreferences { prefix: string; } +interface Dependencies { + createKubeAuthProxy: (cluster: Cluster, environmentVariables: NodeJS.ProcessEnv) => KubeAuthProxy +} + export class ContextHandler { public clusterUrl: UrlWithStringQuery; protected kubeAuthProxy?: KubeAuthProxy; @@ -48,7 +52,7 @@ export class ContextHandler { protected prometheusProvider?: string; protected prometheus?: PrometheusServicePreferences; - constructor(protected cluster: Cluster) { + constructor(private dependencies: Dependencies, protected cluster: Cluster) { this.clusterUrl = url.parse(cluster.apiUrl); this.setupPrometheus(cluster.preferences); } @@ -161,7 +165,7 @@ export class ContextHandler { if (this.cluster.preferences.httpsProxy) { proxyEnv.HTTPS_PROXY = this.cluster.preferences.httpsProxy; } - this.kubeAuthProxy = new KubeAuthProxy(this.cluster, proxyEnv); + this.kubeAuthProxy = this.dependencies.createKubeAuthProxy(this.cluster, proxyEnv); await this.kubeAuthProxy.run(); } diff --git a/src/main/context-handler/create-context-handler.injectable.ts b/src/main/context-handler/create-context-handler.injectable.ts new file mode 100644 index 000000000000..3c92d75fb97a --- /dev/null +++ b/src/main/context-handler/create-context-handler.injectable.ts @@ -0,0 +1,38 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +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"; + +const createContextHandlerInjectable = getInjectable({ + instantiate: (di) => { + const dependencies = { + createKubeAuthProxy: di.inject(createKubeAuthProxyInjectable), + }; + + return (cluster: Cluster) => new ContextHandler(dependencies, cluster); + }, + + lifecycle: lifecycleEnum.singleton, +}); + +export default createContextHandlerInjectable; diff --git a/src/main/create-cluster/create-cluster.injectable.ts b/src/main/create-cluster/create-cluster.injectable.ts new file mode 100644 index 000000000000..7c98bea3eb9f --- /dev/null +++ b/src/main/create-cluster/create-cluster.injectable.ts @@ -0,0 +1,47 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +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 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"; + +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); + }, + + injectionToken: createClusterInjectionToken, + + lifecycle: lifecycleEnum.singleton, +}); + +export default createClusterInjectable; diff --git a/src/main/exit-app.ts b/src/main/exit-app.ts index 8f9f85d2a47f..3f0320506c72 100644 --- a/src/main/exit-app.ts +++ b/src/main/exit-app.ts @@ -21,7 +21,7 @@ import { app } from "electron"; import { WindowManager } from "./window-manager"; -import { appEventBus } from "../common/event-bus"; +import { appEventBus } from "../common/app-event-bus/event-bus"; import { ClusterManager } from "./cluster-manager"; import logger from "./logger"; diff --git a/src/main/getDi.ts b/src/main/getDi.ts index c5fad1d470e3..dea3f2b8a64b 100644 --- a/src/main/getDi.ts +++ b/src/main/getDi.ts @@ -20,19 +20,25 @@ */ 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 = () => - createContainer( +export const getDi = () => { + const di = createContainer( getRequireContextForMainCode, - getRequireContextForCommonCode, getRequireContextForCommonExtensionCode, + getRequireContextForCommonCode, ); + setLegacyGlobalDiForExtensionApi(di); + + return di; +}; + const getRequireContextForMainCode = () => require.context("./", true, /\.injectable\.(ts|tsx)$/); -const getRequireContextForCommonCode = () => - require.context("../common", 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/getDiForUnitTesting.ts b/src/main/getDiForUnitTesting.ts index 06b0588ae20d..c19f016db2fc 100644 --- a/src/main/getDiForUnitTesting.ts +++ b/src/main/getDiForUnitTesting.ts @@ -20,36 +20,61 @@ */ import glob from "glob"; -import { memoize } from "lodash/fp"; +import { memoize, kebabCase } from "lodash/fp"; +import { createContainer } from "@ogre-tools/injectable"; -import { - createContainer, - ConfigurableDependencyInjectionContainer, -} from "@ogre-tools/injectable"; +import { setLegacyGlobalDiForExtensionApi } from "../extensions/as-legacy-globals-for-extension-api/legacy-global-di-for-extension-api"; +import getElectronAppPathInjectable from "./app-paths/get-electron-app-path/get-electron-app-path.injectable"; +import setElectronAppPathInjectable from "./app-paths/set-electron-app-path/set-electron-app-path.injectable"; +import appNameInjectable from "./app-paths/app-name/app-name.injectable"; +import registerChannelInjectable from "./app-paths/register-channel/register-channel.injectable"; +import writeJsonFileInjectable from "../common/fs/write-json-file/write-json-file.injectable"; +import readJsonFileInjectable from "../common/fs/read-json-file/read-json-file.injectable"; -export const getDiForUnitTesting = () => { - const di: ConfigurableDependencyInjectionContainer = createContainer(); +export const getDiForUnitTesting = ( + { doGeneralOverrides } = { doGeneralOverrides: false }, +) => { + const di = createContainer(); - getInjectableFilePaths() - .map(key => { - // eslint-disable-next-line @typescript-eslint/no-var-requires - const injectable = require(key).default; + setLegacyGlobalDiForExtensionApi(di); - return { - id: key, - ...injectable, - aliases: [injectable, ...(injectable.aliases || [])], - }; - }) + for (const filePath of getInjectableFilePaths()) { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const injectableInstance = require(filePath).default; - .forEach(injectable => di.register(injectable)); + di.register({ + id: filePath, + ...injectableInstance, + aliases: [injectableInstance, ...(injectableInstance.aliases || [])], + }); + } di.preventSideEffects(); + if (doGeneralOverrides) { + di.override( + getElectronAppPathInjectable, + () => (name: string) => `some-electron-app-path-for-${kebabCase(name)}`, + ); + + di.override(setElectronAppPathInjectable, () => () => undefined); + di.override(appNameInjectable, () => "some-electron-app-name"); + di.override(registerChannelInjectable, () => () => undefined); + + di.override(writeJsonFileInjectable, () => () => { + throw new Error("Tried to write JSON file to file system without specifying explicit override."); + }); + + di.override(readJsonFileInjectable, () => () => { + throw new Error("Tried to read JSON file from file system without specifying explicit override."); + }); + } + return di; }; const getInjectableFilePaths = memoize(() => [ ...glob.sync("./**/*.injectable.{ts,tsx}", { cwd: __dirname }), ...glob.sync("../extensions/**/*.injectable.{ts,tsx}", { cwd: __dirname }), + ...glob.sync("../common/**/*.injectable.{ts,tsx}", { cwd: __dirname }), ]); diff --git a/src/main/helm/helm-service.ts b/src/main/helm/helm-service.ts index 4d4637b3b1da..4357db4f8592 100644 --- a/src/main/helm/helm-service.ts +++ b/src/main/helm/helm-service.ts @@ -19,7 +19,7 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import type { Cluster } from "../cluster"; +import type { Cluster } from "../../common/cluster/cluster"; import logger from "../logger"; import { HelmRepoManager } from "./helm-repo-manager"; import { HelmChartManager } from "./helm-chart-manager"; diff --git a/src/main/index.ts b/src/main/index.ts index f40b92aa2142..96c3ef42f1d1 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -35,317 +35,340 @@ import { shellSync } from "./shell-sync"; import { mangleProxyEnv } from "./proxy-env"; import { registerFileProtocol } from "../common/register-protocol"; import logger from "./logger"; -import { appEventBus } from "../common/event-bus"; -import { InstalledExtension, ExtensionDiscovery } from "../extensions/extension-discovery"; +import { appEventBus } from "../common/app-event-bus/event-bus"; +import type { InstalledExtension } from "../extensions/extension-discovery/extension-discovery"; import type { LensExtensionId } from "../extensions/lens-extension"; import { installDeveloperTools } from "./developer-tools"; -import { disposer, getAppVersion, getAppVersionFromProxyServer, storedKubeConfigFolder } from "../common/utils"; +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, KubeconfigSyncManager } from "./catalog-sources"; +import { syncGeneralEntities, syncWeblinks } from "./catalog-sources"; import configurePackages from "../common/configure-packages"; import { PrometheusProviderRegistry } from "./prometheus"; import * as initializers from "./initializers"; -import { ClusterStore } from "../common/cluster-store"; import { HotbarStore } from "../common/hotbar-store"; -import { UserStore } from "../common/user-store"; import { WeblinkStore } from "../common/weblink-store"; -import { ExtensionsStore } from "../extensions/extensions-store"; -import { FilesystemProvisionerStore } from "./extension-filesystem"; import { SentryInit } from "../common/sentry"; import { ensureDir } from "fs-extra"; -import { Router } from "./router"; import { initMenu } from "./menu/menu"; +import { kubeApiRequest } from "./proxy-functions"; import { initTray } from "./tray/tray"; -import { kubeApiRequest, shellApiRequest, ShellRequestAuthenticator } from "./proxy-functions"; -import { AppPaths } from "../common/app-paths"; import { ShellSession } from "./shell-session/shell-session"; import { getDi } from "./getDi"; -import electronMenuItemsInjectable from "./menu/electron-menu-items.injectable"; import extensionLoaderInjectable from "../extensions/extension-loader/extension-loader.injectable"; import lensProtocolRouterMainInjectable from "./protocol-handler/lens-protocol-router-main/lens-protocol-router-main.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(); -injectSystemCAs(); - -const onCloseCleanup = disposer(); -const onQuitCleanup = disposer(); - -SentryInit(); app.setName(appName); -logger.info(`📟 Setting ${productName} as protocol client for lens://`); +di.runSetups().then(() => { + injectSystemCAs(); -if (app.setAsDefaultProtocolClient("lens")) { - logger.info("📟 Protocol client register succeeded ✅"); -} else { - logger.info("📟 Protocol client register failed ❗"); -} + const onCloseCleanup = disposer(); + const onQuitCleanup = disposer(); -AppPaths.init(); + SentryInit(); -if (process.env.LENS_DISABLE_GPU) { - app.disableHardwareAcceleration(); -} + logger.info(`📟 Setting ${productName} as protocol client for lens://`); -logger.debug("[APP-MAIN] initializing remote"); -initializeRemote(); + if (app.setAsDefaultProtocolClient("lens")) { + logger.info("📟 Protocol client register succeeded ✅"); + } else { + logger.info("📟 Protocol client register failed ❗"); + } + + if (process.env.LENS_DISABLE_GPU) { + app.disableHardwareAcceleration(); + } -logger.debug("[APP-MAIN] configuring packages"); -configurePackages(); + logger.debug("[APP-MAIN] initializing remote"); + initializeRemote(); -mangleProxyEnv(); + logger.debug("[APP-MAIN] configuring packages"); + configurePackages(); -logger.debug("[APP-MAIN] initializing ipc main handlers"); + mangleProxyEnv(); -const menuItems = di.inject(electronMenuItemsInjectable); -const trayMenuItems = di.inject(trayMenuItemsInjectable); + const initIpcMainHandlers = di.inject(initIpcMainHandlersInjectable); -initializers.initIpcMainHandlers(menuItems); + logger.debug("[APP-MAIN] initializing ipc main handlers"); + initIpcMainHandlers(); -if (app.commandLine.getSwitchValue("proxy-server") !== "") { - process.env.HTTPS_PROXY = app.commandLine.getSwitchValue("proxy-server"); -} + if (app.commandLine.getSwitchValue("proxy-server") !== "") { + process.env.HTTPS_PROXY = app.commandLine.getSwitchValue("proxy-server"); + } -logger.debug("[APP-MAIN] Lens protocol routing main"); + logger.debug("[APP-MAIN] Lens protocol routing main"); -const lensProtocolRouterMain = di.inject(lensProtocolRouterMainInjectable); + const lensProtocolRouterMain = di.inject(lensProtocolRouterMainInjectable); -if (!app.requestSingleInstanceLock()) { - app.exit(); -} else { - for (const arg of process.argv) { - if (arg.toLowerCase().startsWith("lens://")) { - lensProtocolRouterMain.route(arg); + if (!app.requestSingleInstanceLock()) { + app.exit(); + } else { + for (const arg of process.argv) { + if (arg.toLowerCase().startsWith("lens://")) { + lensProtocolRouterMain.route(arg); + } } } -} -app.on("second-instance", (event, argv) => { - logger.debug("second-instance message"); + app.on("second-instance", (event, argv) => { + logger.debug("second-instance message"); - for (const arg of argv) { - if (arg.toLowerCase().startsWith("lens://")) { - lensProtocolRouterMain.route(arg); + for (const arg of argv) { + if (arg.toLowerCase().startsWith("lens://")) { + lensProtocolRouterMain.route(arg); + } } - } - WindowManager.getInstance(false)?.ensureMainWindow(); -}); + WindowManager.getInstance(false)?.ensureMainWindow(); + }); -app.on("ready", async () => { - logger.info(`🚀 Starting ${productName} from "${AppPaths.get("exe")}"`); - logger.info("🐚 Syncing shell environment"); - await shellSync(); + app.on("ready", async () => { + const directoryForExes = di.inject(directoryForExesInjectable); - bindBroadcastHandlers(); + logger.info(`🚀 Starting ${productName} from "${directoryForExes}"`); + logger.info("🐚 Syncing shell environment"); + await shellSync(); - powerMonitor.on("shutdown", () => app.exit()); + bindBroadcastHandlers(); - registerFileProtocol("static", __static); + powerMonitor.on("shutdown", () => app.exit()); - PrometheusProviderRegistry.createInstance(); - ShellRequestAuthenticator.createInstance().init(); - initializers.initPrometheusProviderRegistry(); + registerFileProtocol("static", __static); - /** - * 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(); + PrometheusProviderRegistry.createInstance(); + initializers.initPrometheusProviderRegistry(); - logger.info("💾 Loading stores"); + /** + * 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(); - UserStore.createInstance().startMainReactions(); + logger.info("💾 Loading stores"); - // ClusterStore depends on: UserStore - ClusterStore.createInstance().provideInitialFromMain(); + const userStore = di.inject(userStoreInjectable); - // HotbarStore depends on: ClusterStore - HotbarStore.createInstance(); + userStore.startMainReactions(); - ExtensionsStore.createInstance(); - FilesystemProvisionerStore.createInstance(); - WeblinkStore.createInstance(); + // ClusterStore depends on: UserStore + const clusterStore = di.inject(clusterStoreInjectable); - syncWeblinks(); + clusterStore.provideInitialFromMain(); - HelmRepoManager.createInstance(); // create the instance + // HotbarStore depends on: ClusterStore + HotbarStore.createInstance(); - const lensProxy = LensProxy.createInstance(new Router(), { - getClusterForRequest: req => ClusterManager.getInstance().getClusterForRequest(req), - kubeApiRequest, - shellApiRequest, - }); + WeblinkStore.createInstance(); - ClusterManager.createInstance().init(); - KubeconfigSyncManager.createInstance(); + syncWeblinks(); - initializers.initClusterMetadataDetectors(); + HelmRepoManager.createInstance(); // create the instance - try { - logger.info("🔌 Starting LensProxy"); - await lensProxy.listen(); - } catch (error) { - dialog.showErrorBox("Lens Error", `Could not start proxy: ${error?.message || "unknown error"}`); + const router = di.inject(routerInjectable); + const shellApiRequest = di.inject(shellApiRequestInjectable); - return app.exit(); - } + const lensProxy = LensProxy.createInstance(router, { + getClusterForRequest: (req) => ClusterManager.getInstance().getClusterForRequest(req), + kubeApiRequest, + shellApiRequest, + }); - // test proxy connection - try { - logger.info("🔎 Testing LensProxy connection ..."); - const versionFromProxy = await getAppVersionFromProxyServer(lensProxy.port); + ClusterManager.createInstance().init(); - if (getAppVersion() !== versionFromProxy) { - logger.error("Proxy server responded with invalid response"); + initializers.initClusterMetadataDetectors(); + + try { + logger.info("🔌 Starting LensProxy"); + await lensProxy.listen(); + } catch (error) { + dialog.showErrorBox("Lens Error", `Could not start proxy: ${error?.message || "unknown error"}`); return app.exit(); } - logger.info("⚡ LensProxy connection OK"); - } catch (error) { - logger.error(`🛑 LensProxy: failed connection test: ${error}`); + // test proxy connection + try { + logger.info("🔎 Testing LensProxy connection ..."); + const versionFromProxy = await getAppVersionFromProxyServer(lensProxy.port); - const hostsPath = isWindows - ? "C:\\windows\\system32\\drivers\\etc\\hosts" - : "/etc/hosts"; - const message = [ - `Failed connection test: ${error}`, - "Check to make sure that no other versions of Lens are running", - `Check ${hostsPath} to make sure that it is clean and that the localhost loopback is at the top and set to 127.0.0.1`, - "If you have HTTP_PROXY or http_proxy set in your environment, make sure that the localhost and the ipv4 loopback address 127.0.0.1 are added to the NO_PROXY environment variable.", - ]; + if (getAppVersion() !== versionFromProxy) { + logger.error("Proxy server responded with invalid response"); - dialog.showErrorBox("Lens Proxy Error", message.join("\n\n")); + return app.exit(); + } - return app.exit(); - } + logger.info("⚡ LensProxy connection OK"); + } catch (error) { + logger.error(`🛑 LensProxy: failed connection test: ${error}`); - const extensionLoader = di.inject(extensionLoaderInjectable); + const hostsPath = isWindows + ? "C:\\windows\\system32\\drivers\\etc\\hosts" + : "/etc/hosts"; + const message = [ + `Failed connection test: ${error}`, + "Check to make sure that no other versions of Lens are running", + `Check ${hostsPath} to make sure that it is clean and that the localhost loopback is at the top and set to 127.0.0.1`, + "If you have HTTP_PROXY or http_proxy set in your environment, make sure that the localhost and the ipv4 loopback address 127.0.0.1 are added to the NO_PROXY environment variable.", + ]; - extensionLoader.init(); + dialog.showErrorBox("Lens Proxy Error", message.join("\n\n")); - const extensionDiscovery = ExtensionDiscovery.createInstance(extensionLoader); + return app.exit(); + } - extensionDiscovery.init(); + const extensionLoader = di.inject(extensionLoaderInjectable); - // 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); + extensionLoader.init(); - logger.info("🖥️ Starting WindowManager"); - const windowManager = WindowManager.createInstance(); + const extensionDiscovery = di.inject(extensionDiscoveryInjectable); - onQuitCleanup.push( - initMenu(windowManager, menuItems), - initTray(windowManager, trayMenuItems), - () => ShellSession.cleanup(), - ); + extensionDiscovery.init(); - installDeveloperTools(); + // 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); - if (!startHidden) { - windowManager.ensureMainWindow(); - } + logger.info("🖥️ Starting WindowManager"); + const windowManager = WindowManager.createInstance(); + + const menuItems = di.inject(electronMenuItemsInjectable); + const trayMenuItems = di.inject(trayMenuItemsInjectable); + + onQuitCleanup.push( + initMenu(windowManager, menuItems), + initTray(windowManager, trayMenuItems), + () => ShellSession.cleanup(), + ); + + installDeveloperTools(); + + if (!startHidden) { + windowManager.ensureMainWindow(); + } + + ipcMainOn(IpcRendererNavigationEvents.LOADED, async () => { + onCloseCleanup.push(pushCatalogToRenderer(catalogEntityRegistry)); + + const directoryForKubeConfigs = di.inject(directoryForKubeConfigsInjectable); + + await ensureDir(directoryForKubeConfigs); + + const kubeConfigSyncManager = di.inject(kubeconfigSyncManagerInjectable); - ipcMainOn(IpcRendererNavigationEvents.LOADED, async () => { - onCloseCleanup.push(pushCatalogToRenderer(catalogEntityRegistry)); - await ensureDir(storedKubeConfigFolder()); - KubeconfigSyncManager.getInstance().startSync(); - startUpdateChecking(); - lensProtocolRouterMain.rendererLoaded = true; + kubeConfigSyncManager.startSync(); + + startUpdateChecking(); + lensProtocolRouterMain.rendererLoaded = true; + }); + + logger.info("🧩 Initializing extensions"); + + // call after windowManager to see splash earlier + try { + const extensions = await extensionDiscovery.load(); + + // Start watching after bundled extensions are loaded + extensionDiscovery.watchExtensions(); + + // Subscribe to extensions that are copied or deleted to/from the extensions folder + extensionDiscovery.events + .on("add", (extension: InstalledExtension) => { + extensionLoader.addExtension(extension); + }) + .on("remove", (lensExtensionId: LensExtensionId) => { + extensionLoader.removeExtension(lensExtensionId); + }); + + extensionLoader.initExtensions(extensions); + } catch (error) { + dialog.showErrorBox("Lens Error", `Could not load extensions${error?.message ? `: ${error.message}` : ""}`); + console.error(error); + console.trace(); + } + + setTimeout(() => { + appEventBus.emit({ name: "service", action: "start" }); + }, 1000); }); - logger.info("🧩 Initializing extensions"); - - // call after windowManager to see splash earlier - try { - const extensions = await extensionDiscovery.load(); - - // Start watching after bundled extensions are loaded - extensionDiscovery.watchExtensions(); - - // Subscribe to extensions that are copied or deleted to/from the extensions folder - extensionDiscovery.events - .on("add", (extension: InstalledExtension) => { - extensionLoader.addExtension(extension); - }) - .on("remove", (lensExtensionId: LensExtensionId) => { - extensionLoader.removeExtension(lensExtensionId); - }); - - extensionLoader.initExtensions(extensions); - } catch (error) { - dialog.showErrorBox("Lens Error", `Could not load extensions${error?.message ? `: ${error.message}` : ""}`); - console.error(error); - console.trace(); - } + app.on("activate", (event, hasVisibleWindows) => { + logger.info("APP:ACTIVATE", { hasVisibleWindows }); - setTimeout(() => { - appEventBus.emit({ name: "service", action: "start" }); - }, 1000); -}); + if (!hasVisibleWindows) { + WindowManager.getInstance(false)?.ensureMainWindow(false); + } + }); + + /** + * This variable should is used so that `autoUpdater.installAndQuit()` works + */ + let blockQuit = !isIntegrationTesting; -app.on("activate", (event, hasVisibleWindows) => { - logger.info("APP:ACTIVATE", { hasVisibleWindows }); + autoUpdater.on("before-quit-for-update", () => { + logger.debug("Unblocking quit for update"); + blockQuit = false; + }); - if (!hasVisibleWindows) { - WindowManager.getInstance(false)?.ensureMainWindow(false); - } -}); + app.on("will-quit", (event) => { + logger.debug("will-quit message"); -/** - * This variable should is used so that `autoUpdater.installAndQuit()` works - */ -let blockQuit = !isIntegrationTesting; + // This is called when the close button of the main window is clicked -autoUpdater.on("before-quit-for-update", () => { - logger.debug("Unblocking quit for update"); - blockQuit = false; -}); -app.on("will-quit", (event) => { - logger.debug("will-quit message"); + logger.info("APP:QUIT"); + appEventBus.emit({ name: "app", action: "close" }); + ClusterManager.getInstance(false)?.stop(); // close cluster connections - // This is called when the close button of the main window is clicked + const kubeConfigSyncManager = di.inject(kubeconfigSyncManagerInjectable); + kubeConfigSyncManager.stopSync(); - logger.info("APP:QUIT"); - appEventBus.emit({ name: "app", action: "close" }); - ClusterManager.getInstance(false)?.stop(); // close cluster connections - KubeconfigSyncManager.getInstance(false)?.stopSync(); - onCloseCleanup(); + onCloseCleanup(); - // This is set to false here so that LPRM can wait to send future lens:// - // requests until after it loads again - lensProtocolRouterMain.rendererLoaded = false; + // This is set to false here so that LPRM can wait to send future lens:// + // requests until after it loads again + lensProtocolRouterMain.rendererLoaded = false; - if (blockQuit) { - // Quit app on Cmd+Q (MacOS) + if (blockQuit) { + // Quit app on Cmd+Q (MacOS) - event.preventDefault(); // prevent app's default shutdown (e.g. required for telemetry, etc.) + event.preventDefault(); // prevent app's default shutdown (e.g. required for telemetry, etc.) - return; // skip exit to make tray work, to quit go to app's global menu or tray's menu - } + return; // skip exit to make tray work, to quit go to app's global menu or tray's menu + } - lensProtocolRouterMain.cleanup(); - onQuitCleanup(); -}); + lensProtocolRouterMain.cleanup(); + onQuitCleanup(); + }); -app.on("open-url", (event, rawUrl) => { - logger.debug("open-url message"); + app.on("open-url", (event, rawUrl) => { + logger.debug("open-url message"); - // lens:// protocol handler - event.preventDefault(); - lensProtocolRouterMain.route(rawUrl); + // lens:// protocol handler + event.preventDefault(); + lensProtocolRouterMain.route(rawUrl); + }); + + logger.debug("[APP-MAIN] waiting for 'ready' and other messages"); }); /** @@ -362,5 +385,3 @@ export { Mobx, LensExtensions, }; - -logger.debug("[APP-MAIN] waiting for 'ready' and other messages"); diff --git a/src/main/initializers/index.ts b/src/main/initializers/index.ts index 44c994789a53..e660a1277fb6 100644 --- a/src/main/initializers/index.ts +++ b/src/main/initializers/index.ts @@ -19,5 +19,4 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ export * from "./metrics-providers"; -export * from "./ipc"; export * from "./cluster-metadata-detectors"; 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 new file mode 100644 index 000000000000..6c1a78365901 --- /dev/null +++ b/src/main/initializers/init-ipc-main-handlers/init-ipc-main-handlers.injectable.ts @@ -0,0 +1,36 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +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/initializers/ipc.ts b/src/main/initializers/init-ipc-main-handlers/init-ipc-main-handlers.ts similarity index 83% rename from src/main/initializers/ipc.ts rename to src/main/initializers/init-ipc-main-handlers/init-ipc-main-handlers.ts index 0ad881f36eb2..5737cd20a534 100644 --- a/src/main/initializers/ipc.ts +++ b/src/main/initializers/init-ipc-main-handlers/init-ipc-main-handlers.ts @@ -20,25 +20,29 @@ */ 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"; -import { appEventBus } from "../../common/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 { 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 path from "path"; import { remove } from "fs-extra"; -import { AppPaths } from "../../common/app-paths"; -import { getAppMenu } from "../menu/menu"; -import type { MenuRegistration } from "../menu/menu-registration"; +import { getAppMenu } from "../../menu/menu"; +import type { MenuRegistration } from "../../menu/menu-registration"; import type { IComputedValue } from "mobx"; -export function initIpcMainHandlers(electronMenuItems: IComputedValue) { +interface Dependencies { + electronMenuItems: IComputedValue, + directoryForLensLocalStorage: string; +} + +export const initIpcMainHandlers = ({ electronMenuItems, directoryForLensLocalStorage }: Dependencies) => () => { ipcMainHandle(clusterActivateHandler, (event, clusterId: ClusterId, force = false) => { return ClusterStore.getInstance() .getById(clusterId) @@ -94,7 +98,7 @@ export function initIpcMainHandlers(electronMenuItems: IComputedValue(cluster: Cluster, path: string, options: RequestPromiseOptions = {}): Promise { const kubeProxyUrl = `http://localhost:${LensProxy.getInstance().port}${apiKubePrefix}`; 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 new file mode 100644 index 000000000000..b7dc8b0b4b17 --- /dev/null +++ b/src/main/kube-auth-proxy/create-kube-auth-proxy.injectable.ts @@ -0,0 +1,41 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +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"; + +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); + }, + + lifecycle: lifecycleEnum.singleton, +}); + +export default createKubeAuthProxyInjectable; diff --git a/src/main/kube-auth-proxy.ts b/src/main/kube-auth-proxy/kube-auth-proxy.ts similarity index 90% rename from src/main/kube-auth-proxy.ts rename to src/main/kube-auth-proxy/kube-auth-proxy.ts index 86275701385c..62062125d07b 100644 --- a/src/main/kube-auth-proxy.ts +++ b/src/main/kube-auth-proxy/kube-auth-proxy.ts @@ -22,15 +22,18 @@ import { ChildProcess, spawn } from "child_process"; import { waitUntilUsed } from "tcp-port-used"; import { randomBytes } from "crypto"; -import type { Cluster } from "./cluster"; -import { Kubectl } from "./kubectl"; -import logger from "./logger"; +import type { Cluster } from "../../common/cluster/cluster"; +import logger from "../logger"; import * as url from "url"; -import { getPortFrom } from "./utils/get-port"; +import { getPortFrom } from "../utils/get-port"; import { makeObservable, observable, when } from "mobx"; const startingServeRegex = /^starting to serve on (?
.+)/i; +interface Dependencies { + getProxyBinPath: () => Promise; +} + export class KubeAuthProxy { public readonly apiPrefix = `/${randomBytes(8).toString("hex")}`; @@ -43,7 +46,7 @@ export class KubeAuthProxy { protected readonly acceptHosts: string; @observable protected ready = false; - constructor(protected readonly cluster: Cluster, protected readonly env: NodeJS.ProcessEnv) { + constructor(private dependencies: Dependencies, protected readonly cluster: Cluster, protected readonly env: NodeJS.ProcessEnv) { makeObservable(this); this.acceptHosts = url.parse(this.cluster.apiUrl).hostname; @@ -58,7 +61,7 @@ export class KubeAuthProxy { return this.whenReady; } - const proxyBin = await Kubectl.bundled().getPath(); + const proxyBin = await this.dependencies.getProxyBinPath(); const args = [ "proxy", "-p", "0", diff --git a/src/main/kubeconfig-manager/create-kubeconfig-manager.injectable.ts b/src/main/kubeconfig-manager/create-kubeconfig-manager.injectable.ts new file mode 100644 index 000000000000..5caf47d54049 --- /dev/null +++ b/src/main/kubeconfig-manager/create-kubeconfig-manager.injectable.ts @@ -0,0 +1,42 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +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 { KubeconfigManager } from "./kubeconfig-manager"; + +export interface KubeConfigManagerInstantiationParameter { + cluster: Cluster; +} + +const createKubeconfigManagerInjectable = getInjectable({ + instantiate: (di) => { + const dependencies = { + directoryForTemp: di.inject(directoryForTempInjectable), + }; + + return (cluster: Cluster) => new KubeconfigManager(dependencies, cluster); + }, + + lifecycle: lifecycleEnum.singleton, +}); + +export default createKubeconfigManagerInjectable; diff --git a/src/main/kubeconfig-manager.ts b/src/main/kubeconfig-manager/kubeconfig-manager.ts similarity index 86% rename from src/main/kubeconfig-manager.ts rename to src/main/kubeconfig-manager/kubeconfig-manager.ts index 7f0cfb25ee25..1cc279815811 100644 --- a/src/main/kubeconfig-manager.ts +++ b/src/main/kubeconfig-manager/kubeconfig-manager.ts @@ -20,14 +20,17 @@ */ import type { KubeConfig } from "@kubernetes/client-node"; -import type { Cluster } from "./cluster"; -import type { ContextHandler } from "./context-handler"; +import type { Cluster } from "../../common/cluster/cluster"; +import type { ContextHandler } from "../context-handler/context-handler"; 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 { AppPaths } from "../common/app-paths"; +import { dumpConfigYaml } from "../../common/kube-helpers"; +import logger from "../logger"; +import { LensProxy } from "../lens-proxy"; + +interface Dependencies { + directoryForTemp: string +} export class KubeconfigManager { /** @@ -39,7 +42,11 @@ export class KubeconfigManager { */ protected tempFilePath: string | null | undefined = null; - constructor(protected cluster: Cluster, protected contextHandler: ContextHandler) { } + protected contextHandler: ContextHandler; + + constructor(private dependencies: Dependencies, protected cluster: Cluster) { + this.contextHandler = cluster.contextHandler; + } /** * @@ -98,7 +105,10 @@ export class KubeconfigManager { protected async createProxyKubeconfig(): Promise { const { cluster } = this; const { contextName, id } = cluster; - const tempFile = path.join(AppPaths.get("temp"), `kubeconfig-${id}`); + const tempFile = path.join( + this.dependencies.directoryForTemp, + `kubeconfig-${id}`, + ); const kubeConfig = await cluster.getKubeconfig(); const proxyConfig: Partial = { currentContext: contextName, diff --git a/src/main/kubectl/bundled-kubectl.injectable.ts b/src/main/kubectl/bundled-kubectl.injectable.ts new file mode 100644 index 000000000000..25199485deeb --- /dev/null +++ b/src/main/kubectl/bundled-kubectl.injectable.ts @@ -0,0 +1,37 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { getBundledKubectlVersion } from "../../common/utils"; +import createKubectlInjectable from "./create-kubectl.injectable"; + +const bundledKubectlInjectable = getInjectable({ + instantiate: (di) => { + const createKubectl = di.inject(createKubectlInjectable); + + const bundledKubectlVersion = getBundledKubectlVersion(); + + return createKubectl(bundledKubectlVersion); + }, + + lifecycle: lifecycleEnum.singleton, +}); + +export default bundledKubectlInjectable; diff --git a/src/main/kubectl/create-kubectl.injectable.ts b/src/main/kubectl/create-kubectl.injectable.ts new file mode 100644 index 000000000000..cb1de7e93dec --- /dev/null +++ b/src/main/kubectl/create-kubectl.injectable.ts @@ -0,0 +1,43 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +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"; + +const createKubectlInjectable = getInjectable({ + instantiate: (di) => { + const dependencies = { + userStore: di.inject(userStoreInjectable), + + directoryForKubectlBinaries: di.inject( + directoryForKubectlBinariesInjectable, + ), + }; + + return (clusterVersion: string) => + new Kubectl(dependencies, clusterVersion); + }, + + lifecycle: lifecycleEnum.singleton, +}); + +export default createKubectlInjectable; diff --git a/src/main/kubectl/directory-for-kubectl-binaries/directory-for-kubectl-binaries.injectable.ts b/src/main/kubectl/directory-for-kubectl-binaries/directory-for-kubectl-binaries.injectable.ts new file mode 100644 index 000000000000..8b6e708601c5 --- /dev/null +++ b/src/main/kubectl/directory-for-kubectl-binaries/directory-for-kubectl-binaries.injectable.ts @@ -0,0 +1,32 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import directoryForBinariesInjectable from "../../../common/app-paths/directory-for-binaries/directory-for-binaries.injectable"; +import path from "path"; + +const directoryForKubectlBinariesInjectable = getInjectable({ + instantiate: (di) => + path.join(di.inject(directoryForBinariesInjectable), "kubectl"), + + lifecycle: lifecycleEnum.singleton, +}); + +export default directoryForKubectlBinariesInjectable; diff --git a/src/main/kubectl.ts b/src/main/kubectl/kubectl.ts similarity index 88% rename from src/main/kubectl.ts rename to src/main/kubectl/kubectl.ts index 88daa10b805f..875b28ac7146 100644 --- a/src/main/kubectl.ts +++ b/src/main/kubectl/kubectl.ts @@ -21,21 +21,19 @@ import path from "path"; import fs from "fs"; -import { promiseExecFile } from "../common/utils/promise-exec"; -import logger from "./logger"; +import { promiseExecFile } from "../../common/utils/promise-exec"; +import logger from "../logger"; import { ensureDir, pathExists } from "fs-extra"; import * as lockFile from "proper-lockfile"; -import { helmCli } from "./helm/helm-cli"; -import { UserStore } from "../common/user-store"; -import { getBundledKubectlVersion } from "../common/utils/app-version"; -import { isDevelopment, isWindows, isTestEnv } from "../common/vars"; +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 { AppPaths } from "../common/app-paths"; +import { defaultPackageMirror, packageMirrors } from "../../common/user-store/preferences-helpers"; import got from "got/dist/source"; import { promisify } from "util"; import stream from "stream"; -import { noop } from "../renderer/utils"; +import { noop } from "lodash/fp"; const bundledVersion = getBundledKubectlVersion(); const kubectlMap: Map = new Map([ @@ -76,6 +74,17 @@ export function bundledKubectlPath(): string { return bundledPath; } +interface Dependencies { + directoryForKubectlBinaries: string; + + userStore: { + kubectlBinariesPath?: string + downloadBinariesPath?: string + downloadKubectlBinaries: boolean + downloadMirror: string + }; +} + export class Kubectl { public kubectlVersion: string; protected directory: string; @@ -83,20 +92,10 @@ export class Kubectl { protected path: string; protected dirname: string; - static get kubectlDir() { - return path.join(AppPaths.get("userData"), "binaries", "kubectl"); - } - public static readonly bundledKubectlVersion: string = bundledVersion; public static invalidBundle = false; - private static bundledInstance: Kubectl; - // Returns the single bundled Kubectl instance - public static bundled() { - return Kubectl.bundledInstance ??= new Kubectl(Kubectl.bundledKubectlVersion); - } - - constructor(clusterVersion: string) { + constructor(private dependencies: Dependencies, clusterVersion: string) { let version: SemVer; try { @@ -141,23 +140,23 @@ export class Kubectl { } public getPathFromPreferences() { - return UserStore.getInstance().kubectlBinariesPath || this.getBundledPath(); + return this.dependencies.userStore.kubectlBinariesPath || this.getBundledPath(); } protected getDownloadDir() { - if (UserStore.getInstance().downloadBinariesPath) { - return path.join(UserStore.getInstance().downloadBinariesPath, "kubectl"); + if (this.dependencies.userStore.downloadBinariesPath) { + return path.join(this.dependencies.userStore.downloadBinariesPath, "kubectl"); } - return Kubectl.kubectlDir; + return this.dependencies.directoryForKubectlBinaries; } - public async getPath(bundled = false): Promise { + public getPath = async (bundled = false): Promise => { if (bundled) { return this.getBundledPath(); } - if (UserStore.getInstance().downloadKubectlBinaries === false) { + if (this.dependencies.userStore.downloadKubectlBinaries === false) { return this.getPathFromPreferences(); } @@ -182,7 +181,7 @@ export class Kubectl { return this.getBundledPath(); } - } + }; public async binDir() { try { @@ -256,7 +255,7 @@ export class Kubectl { } public async ensureKubectl(): Promise { - if (UserStore.getInstance().downloadKubectlBinaries === false) { + if (this.dependencies.userStore.downloadKubectlBinaries === false) { return true; } @@ -327,9 +326,10 @@ export class Kubectl { } protected async writeInitScripts() { - const kubectlPath = UserStore.getInstance().downloadKubectlBinaries + const kubectlPath = this.dependencies.userStore.downloadKubectlBinaries ? this.dirname : path.dirname(this.getPathFromPreferences()); + const helmPath = helmCli.getBinaryDir(); const bashScriptPath = path.join(this.dirname, ".bash_set_path"); @@ -395,7 +395,7 @@ export class Kubectl { protected getDownloadMirror(): string { // MacOS packages are only available from default - const { url } = packageMirrors.get(UserStore.getInstance().downloadMirror) + const { url } = packageMirrors.get(this.dependencies.userStore.downloadMirror) ?? packageMirrors.get(defaultPackageMirror); return url; diff --git a/src/main/kubectl_spec.ts b/src/main/kubectl_spec.ts deleted file mode 100644 index 974ced9f73b1..000000000000 --- a/src/main/kubectl_spec.ts +++ /dev/null @@ -1,72 +0,0 @@ -/** - * Copyright (c) 2021 OpenLens Authors - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to - * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of - * the Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER - * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN - * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ - -import packageInfo from "../../package.json"; -import path from "path"; -import { Kubectl } from "../../src/main/kubectl"; -import { isWindows } from "../common/vars"; - -jest.mock("../common/user-store"); - -describe("kubectlVersion", () => { - it("returns bundled version if exactly same version used", async () => { - const kubectl = new Kubectl(Kubectl.bundled().kubectlVersion); - - expect(kubectl.kubectlVersion).toBe(Kubectl.bundled().kubectlVersion); - }); - - it("returns bundled version if same major.minor version is used", async () => { - const { bundledKubectlVersion } = packageInfo.config; - const kubectl = new Kubectl(bundledKubectlVersion); - - expect(kubectl.kubectlVersion).toBe(Kubectl.bundled().kubectlVersion); - }); -}); - -describe("getPath()", () => { - it("returns path to downloaded kubectl binary", async () => { - const { bundledKubectlVersion } = packageInfo.config; - const kubectl = new Kubectl(bundledKubectlVersion); - const kubectlPath = await kubectl.getPath(); - let binaryName = "kubectl"; - - if (isWindows) { - binaryName += ".exe"; - } - const expectedPath = path.join(Kubectl.kubectlDir, Kubectl.bundledKubectlVersion, binaryName); - - expect(kubectlPath).toBe(expectedPath); - }); - - it("returns plain binary name if bundled kubectl is non-functional", async () => { - const { bundledKubectlVersion } = packageInfo.config; - const kubectl = new Kubectl(bundledKubectlVersion); - - jest.spyOn(kubectl, "getBundledPath").mockReturnValue("/invalid/path/kubectl"); - const kubectlPath = await kubectl.getPath(); - let binaryName = "kubectl"; - - if (isWindows) { - binaryName += ".exe"; - } - expect(kubectlPath).toBe(binaryName); - }); -}); diff --git a/src/main/lens-proxy.ts b/src/main/lens-proxy.ts index a9982d5cb727..f5f37732b020 100644 --- a/src/main/lens-proxy.ts +++ b/src/main/lens-proxy.ts @@ -25,12 +25,12 @@ 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"; +import type { ContextHandler } from "./context-handler/context-handler"; import logger from "./logger"; import { Singleton } from "../common/utils"; -import type { Cluster } from "./cluster"; +import type { Cluster } from "../common/cluster/cluster"; import type { ProxyApiRequestArgs } from "./proxy-functions"; -import { appEventBus } from "../common/event-bus"; +import { appEventBus } from "../common/app-event-bus/event-bus"; import { getBoolean } from "./utils/parse-query"; type GetClusterForRequest = (req: http.IncomingMessage) => Cluster | null; diff --git a/src/main/menu/electron-menu-items.test.ts b/src/main/menu/electron-menu-items.test.ts index edd885d1c64c..d4837b3b610a 100644 --- a/src/main/menu/electron-menu-items.test.ts +++ b/src/main/menu/electron-menu-items.test.ts @@ -32,8 +32,8 @@ describe("electron-menu-items", () => { let electronMenuItems: IComputedValue; let extensionsStub: ObservableMap; - beforeEach(() => { - di = getDiForUnitTesting(); + beforeEach(async () => { + di = getDiForUnitTesting({ doGeneralOverrides: true }); extensionsStub = new ObservableMap(); @@ -42,6 +42,8 @@ describe("electron-menu-items", () => { () => computed(() => [...extensionsStub.values()]), ); + await di.runSetups(); + electronMenuItems = di.inject(electronMenuItemsInjectable); }); diff --git a/src/main/protocol-handler/__test__/router.test.ts b/src/main/protocol-handler/__test__/router.test.ts index 1d17625b6d6e..fc287c4bc729 100644 --- a/src/main/protocol-handler/__test__/router.test.ts +++ b/src/main/protocol-handler/__test__/router.test.ts @@ -25,36 +25,19 @@ 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"; +import { ExtensionsStore } from "../../../extensions/extensions-store/extensions-store"; import type { LensProtocolRouterMain } from "../lens-protocol-router-main/lens-protocol-router-main"; import mockFs from "mock-fs"; -import { AppPaths } from "../../../common/app-paths"; 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"; jest.mock("../../../common/ipc"); -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(), - }, -})); - -AppPaths.init(); - function throwIfDefined(val: any): void { if (val != null) { throw val; @@ -66,16 +49,20 @@ describe("protocol router tests", () => { // Unit tests are allowed to only public interfaces. let extensionLoader: any; let lpr: LensProtocolRouterMain; + let extensionsStore: ExtensionsStore; - beforeEach(() => { - const di = getDiForUnitTesting(); - - extensionLoader = di.inject(extensionLoaderInjectable); + beforeEach(async () => { + const di = getDiForUnitTesting({ doGeneralOverrides: true }); mockFs({ "tmp": {}, }); - ExtensionsStore.createInstance(); + + await di.runSetups(); + + extensionLoader = di.inject(extensionLoaderInjectable); + extensionsStore = di.inject(extensionsStoreInjectable); + lpr = di.inject(lensProtocolRouterMainInjectable); @@ -85,7 +72,9 @@ describe("protocol router tests", () => { afterEach(() => { jest.clearAllMocks(); + // TODO: Remove Singleton from BaseStore to achieve independent unit testing ExtensionsStore.resetInstance(); + mockFs.restore(); }); @@ -126,7 +115,7 @@ describe("protocol router tests", () => { }); extensionLoader.instances.set(extId, ext); - (ExtensionsStore.getInstance() as any).state.set(extId, { enabled: true, name: "@mirantis/minikube" }); + (extensionsStore as any).state.set(extId, { enabled: true, name: "@mirantis/minikube" }); lpr.addInternalHandler("/", noop); @@ -205,7 +194,7 @@ describe("protocol router tests", () => { }); extensionLoader.instances.set(extId, ext); - (ExtensionsStore.getInstance() as any).state.set(extId, { enabled: true, name: "@foobar/icecream" }); + (extensionsStore as any).state.set(extId, { enabled: true, name: "@foobar/icecream" }); try { expect(await lpr.route("lens://extension/@foobar/icecream/page/foob")).toBeUndefined(); @@ -243,7 +232,7 @@ describe("protocol router tests", () => { }); extensionLoader.instances.set(extId, ext); - (ExtensionsStore.getInstance() as any).state.set(extId, { enabled: true, name: "@foobar/icecream" }); + (extensionsStore as any).state.set(extId, { enabled: true, name: "@foobar/icecream" }); } { @@ -268,11 +257,11 @@ describe("protocol router tests", () => { }); extensionLoader.instances.set(extId, ext); - (ExtensionsStore.getInstance() as any).state.set(extId, { enabled: true, name: "icecream" }); + (extensionsStore as any).state.set(extId, { enabled: true, name: "icecream" }); } - (ExtensionsStore.getInstance() as any).state.set("@foobar/icecream", { enabled: true, name: "@foobar/icecream" }); - (ExtensionsStore.getInstance() as any).state.set("icecream", { enabled: true, name: "icecream" }); + (extensionsStore as any).state.set("@foobar/icecream", { enabled: true, name: "@foobar/icecream" }); + (extensionsStore as any).state.set("icecream", { enabled: true, name: "icecream" }); try { expect(await lpr.route("lens://extension/icecream/page")).toBeUndefined(); diff --git a/src/main/protocol-handler/lens-protocol-router-main/lens-protocol-router-main.injectable.ts b/src/main/protocol-handler/lens-protocol-router-main/lens-protocol-router-main.injectable.ts index 9f26ab6a4c69..c674ad42d789 100644 --- a/src/main/protocol-handler/lens-protocol-router-main/lens-protocol-router-main.injectable.ts +++ b/src/main/protocol-handler/lens-protocol-router-main/lens-protocol-router-main.injectable.ts @@ -21,11 +21,13 @@ 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"; const lensProtocolRouterMainInjectable = getInjectable({ instantiate: (di) => new LensProtocolRouterMain({ extensionLoader: di.inject(extensionLoaderInjectable), + extensionsStore: di.inject(extensionsStoreInjectable), }), lifecycle: lifecycleEnum.singleton, diff --git a/src/main/protocol-handler/lens-protocol-router-main/lens-protocol-router-main.ts b/src/main/protocol-handler/lens-protocol-router-main/lens-protocol-router-main.ts index cae62989f4e1..2b9a2dcf0b57 100644 --- a/src/main/protocol-handler/lens-protocol-router-main/lens-protocol-router-main.ts +++ b/src/main/protocol-handler/lens-protocol-router-main/lens-protocol-router-main.ts @@ -29,6 +29,7 @@ import { ProtocolHandlerInvalid, RouteAttempt } from "../../../common/protocol-h 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"; export interface FallbackHandler { (name: string): Promise; @@ -53,6 +54,7 @@ function checkHost(url: URLParse): boolean { interface Dependencies { extensionLoader: ExtensionLoader + extensionsStore: ExtensionsStore } export class LensProtocolRouterMain extends proto.LensProtocolRouter { diff --git a/src/main/proxy-functions/index.ts b/src/main/proxy-functions/index.ts index 2154d81014ba..3aca4e4d29a9 100644 --- a/src/main/proxy-functions/index.ts +++ b/src/main/proxy-functions/index.ts @@ -18,7 +18,5 @@ * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ - -export * from "./shell-api-request"; export * from "./kube-api-request"; export * from "./types"; 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 new file mode 100644 index 000000000000..a67e58d26d10 --- /dev/null +++ b/src/main/proxy-functions/shell-api-request/shell-api-request.injectable.ts @@ -0,0 +1,36 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +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, + }), + + lifecycle: lifecycleEnum.singleton, +}); + +export default shellApiRequestInjectable; 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 new file mode 100644 index 000000000000..5226b03631fb --- /dev/null +++ b/src/main/proxy-functions/shell-api-request/shell-api-request.ts @@ -0,0 +1,59 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +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/shell-request-authenticator.injectable.ts new file mode 100644 index 000000000000..8e15322802b1 --- /dev/null +++ b/src/main/proxy-functions/shell-api-request/shell-request-authenticator/shell-request-authenticator.injectable.ts @@ -0,0 +1,36 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { ShellRequestAuthenticator } from "./shell-request-authenticator"; + +const shellRequestAuthenticatorInjectable = getInjectable({ + instantiate: () => { + const authenticator = new ShellRequestAuthenticator(); + + authenticator.init(); + + return authenticator; + }, + + lifecycle: lifecycleEnum.singleton, +}); + +export default shellRequestAuthenticatorInjectable; diff --git a/src/main/proxy-functions/shell-api-request.ts b/src/main/proxy-functions/shell-api-request/shell-request-authenticator/shell-request-authenticator.ts similarity index 62% rename from src/main/proxy-functions/shell-api-request.ts rename to src/main/proxy-functions/shell-api-request/shell-request-authenticator/shell-request-authenticator.ts index 76340585e1de..30862544bdcc 100644 --- a/src/main/proxy-functions/shell-api-request.ts +++ b/src/main/proxy-functions/shell-api-request/shell-request-authenticator/shell-request-authenticator.ts @@ -18,22 +18,15 @@ * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ - -import logger from "../logger"; -import { Server as WebSocketServer } from "ws"; -import { NodeShellSession, LocalShellSession } from "../shell-session"; -import type { ProxyApiRequestArgs } from "./types"; -import { ClusterManager } from "../cluster-manager"; -import URLParse from "url-parse"; -import { ExtendedMap, Singleton } from "../../common/utils"; -import type { ClusterId } from "../../common/cluster-types"; -import { ipcMainHandle } from "../../common/ipc"; +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 extends Singleton { +export class ShellRequestAuthenticator { private tokens = new ExtendedMap>(); init() { @@ -55,7 +48,7 @@ export class ShellRequestAuthenticator extends Singleton { * @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 { + authenticate = (clusterId: ClusterId, tabId: string, token: string): boolean => { const clusterTokens = this.tokens.get(clusterId); if (!clusterTokens) { @@ -73,27 +66,5 @@ export class ShellRequestAuthenticator extends Singleton { } return false; - } -} - -export function shellApiRequest({ req, socket, head }: ProxyApiRequestArgs): void { - const cluster = ClusterManager.getInstance().getClusterForRequest(req); - const { query: { node, shellToken, id: tabId }} = new URLParse(req.url, true); - - if (!cluster || !ShellRequestAuthenticator.getInstance().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) => { - const shell = node - ? new NodeShellSession(webSocket, cluster, node, tabId) - : new LocalShellSession(webSocket, cluster, tabId); - - shell.open() - .catch(error => logger.error(`[SHELL-SESSION]: failed to open a ${node ? "node" : "local"} shell`, error)); - }); + }; } diff --git a/src/main/proxy-functions/types.ts b/src/main/proxy-functions/types.ts index 2a41b9b97f1a..c9f8f7895851 100644 --- a/src/main/proxy-functions/types.ts +++ b/src/main/proxy-functions/types.ts @@ -21,7 +21,7 @@ import type http from "http"; import type net from "net"; -import type { Cluster } from "../cluster"; +import type { Cluster } from "../../common/cluster/cluster"; export interface ProxyApiRequestArgs { req: http.IncomingMessage, diff --git a/src/main/resource-applier.ts b/src/main/resource-applier.ts index 61ca1dc1d0a1..6488dce84895 100644 --- a/src/main/resource-applier.ts +++ b/src/main/resource-applier.ts @@ -19,7 +19,7 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import type { Cluster } from "./cluster"; +import type { Cluster } from "../common/cluster/cluster"; import type { KubernetesObject } from "@kubernetes/client-node"; import { exec } from "child_process"; import fs from "fs-extra"; @@ -27,7 +27,7 @@ import * as yaml from "js-yaml"; import path from "path"; import tempy from "tempy"; import logger from "./logger"; -import { appEventBus } from "../common/event-bus"; +import { appEventBus } from "../common/app-event-bus/event-bus"; import { cloneJsonObject } from "../common/utils"; import type { Patch } from "rfc6902"; import { promiseExecFile } from "../common/utils/promise-exec"; diff --git a/src/main/router.ts b/src/main/router.ts index 082d710f68a5..991d0e5b30ad 100644 --- a/src/main/router.ts +++ b/src/main/router.ts @@ -24,7 +24,7 @@ import Subtext from "@hapi/subtext"; import type http from "http"; import path from "path"; import { readFile } from "fs-extra"; -import type { Cluster } from "./cluster"; +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"; @@ -76,11 +76,15 @@ function getMimeType(filename: string) { return mimeTypes[path.extname(filename).slice(1)] || "text/plain"; } +interface Dependencies { + routePortForward: (request: LensApiRequest) => Promise +} + export class Router { protected router = new Call.Router(); protected static rootPath = path.resolve(__static); - public constructor() { + public constructor(private dependencies: Dependencies) { this.addRoutes(); } @@ -180,7 +184,7 @@ export class Router { 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}` }, PortForwardRoute.routePortForward); + 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/router/router.injectable.ts b/src/main/router/router.injectable.ts new file mode 100644 index 000000000000..98d36902339c --- /dev/null +++ b/src/main/router/router.injectable.ts @@ -0,0 +1,34 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { Router } from "../router"; +import routePortForwardInjectable + from "../routes/port-forward/route-port-forward/route-port-forward.injectable"; + +const routerInjectable = getInjectable({ + instantiate: (di) => new Router({ + routePortForward: di.inject(routePortForwardInjectable), + }), + + lifecycle: lifecycleEnum.singleton, +}); + +export default routerInjectable; diff --git a/src/main/routes/kubeconfig-route.ts b/src/main/routes/kubeconfig-route.ts index 14b09489f09b..78a520244c05 100644 --- a/src/main/routes/kubeconfig-route.ts +++ b/src/main/routes/kubeconfig-route.ts @@ -21,7 +21,7 @@ import type { LensApiRequest } from "../router"; import { respondJson } from "../utils/http-responses"; -import type { Cluster } from "../cluster"; +import type { Cluster } from "../../common/cluster/cluster"; import { CoreV1Api, V1Secret } from "@kubernetes/client-node"; function generateKubeConfig(username: string, secret: V1Secret, cluster: Cluster) { diff --git a/src/main/routes/metrics-route.ts b/src/main/routes/metrics-route.ts index c323c06be957..bd00d58d1854 100644 --- a/src/main/routes/metrics-route.ts +++ b/src/main/routes/metrics-route.ts @@ -21,7 +21,7 @@ import type { LensApiRequest } from "../router"; import { respondJson } from "../utils/http-responses"; -import type { Cluster } from "../cluster"; +import type { Cluster } from "../../common/cluster/cluster"; import { ClusterMetadataKey, ClusterPrometheusMetadata } from "../../common/cluster-types"; import logger from "../logger"; import { getMetrics } from "../k8s-request"; diff --git a/src/main/routes/port-forward-route.ts b/src/main/routes/port-forward-route.ts index 030ec8ff071e..7cea1bd7b525 100644 --- a/src/main/routes/port-forward-route.ts +++ b/src/main/routes/port-forward-route.ts @@ -20,159 +20,11 @@ */ import type { LensApiRequest } from "../router"; -import { spawn, ChildProcessWithoutNullStreams } from "child_process"; -import { Kubectl } from "../kubectl"; -import * as tcpPortUsed from "tcp-port-used"; import logger from "../logger"; -import { getPortFrom } from "../utils/get-port"; import { respondJson } from "../utils/http-responses"; - -interface PortForwardArgs { - clusterId: string; - kind: string; - namespace: string; - name: string; - port: number; - forwardPort: number; - protocol?: string; -} - -const internalPortRegex = /^forwarding from (?
.+) ->/i; - -class PortForward { - public static portForwards: PortForward[] = []; - - static getPortforward(forward: PortForwardArgs) { - return PortForward.portForwards.find((pf) => ( - pf.clusterId == forward.clusterId && - pf.kind == forward.kind && - pf.name == forward.name && - pf.namespace == forward.namespace && - pf.port == forward.port && - (!forward.protocol || pf.protocol == forward.protocol) - )); - } - - public process: ChildProcessWithoutNullStreams; - public clusterId: string; - public kind: string; - public namespace: string; - public name: string; - public port: number; - public forwardPort: number; - public protocol: string; - - constructor(public kubeConfig: string, args: PortForwardArgs) { - this.clusterId = args.clusterId; - this.kind = args.kind; - this.namespace = args.namespace; - this.name = args.name; - this.port = args.port; - this.forwardPort = args.forwardPort; - this.protocol = args.protocol ?? "http"; - } - - public async start() { - const kubectlBin = await Kubectl.bundled().getPath(true); - const args = [ - "--kubeconfig", this.kubeConfig, - "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); - this.process.on("exit", () => { - const index = PortForward.portForwards.indexOf(this); - - if (index > -1) { - PortForward.portForwards.splice(index, 1); - } - }); - - this.process.stderr.on("data", (data) => { - logger.debug(`[PORT-FORWARD-ROUTE]: kubectl port-forward process stderr: ${data}`); - }); - - const internalPort = await getPortFrom(this.process.stdout, { - lineRegex: internalPortRegex, - }); - - try { - await tcpPortUsed.waitUntilUsed(internalPort, 500, 15000); - - // make sure this.forwardPort is set to the actual port used (if it was 0 then an available port is found by 'kubectl port-forward') - this.forwardPort = internalPort; - - return true; - } catch (error) { - this.process.kill(); - - return false; - } - } - - public async stop() { - this.process.kill(); - } -} +import { PortForward } from "./port-forward/port-forward"; export class PortForwardRoute { - static async routePortForward(request: LensApiRequest) { - const { params, query, response, cluster } = request; - const { namespace, resourceType, resourceName } = params; - const port = Number(query.get("port")); - const forwardPort = Number(query.get("forwardPort")); - const protocol = query.get("protocol"); - - try { - let portForward = PortForward.getPortforward({ - clusterId: cluster.id, kind: resourceType, name: resourceName, - namespace, port, forwardPort, protocol, - }); - - if (!portForward) { - logger.info(`Creating a new port-forward ${namespace}/${resourceType}/${resourceName}:${port}`); - - const thePort = 0 < forwardPort && forwardPort < 65536 - ? forwardPort - : 0; - - portForward = new PortForward(await cluster.getProxyKubeconfigPath(), { - clusterId: cluster.id, - kind: resourceType, - namespace, - name: resourceName, - port, - forwardPort: thePort, - protocol, - }); - - const started = await portForward.start(); - - if (!started) { - logger.error("[PORT-FORWARD-ROUTE]: failed to start a port-forward", { namespace, port, resourceType, resourceName }); - - return respondJson(response, { - message: `Failed to forward port ${port} to ${thePort ? forwardPort : "random port"}`, - }, 400); - } - } - - respondJson(response, { port: portForward.forwardPort }); - } catch (error) { - logger.error(`[PORT-FORWARD-ROUTE]: failed to open a port-forward: ${error}`, { namespace, port, resourceType, resourceName }); - - return respondJson(response, { - message: `Failed to forward port ${port}`, - }, 400); - } - } - static async routeCurrentPortForward(request: LensApiRequest) { const { params, query, response, cluster } = request; const { namespace, resourceType, resourceName } = params; diff --git a/src/main/routes/port-forward/create-port-forward.injectable.ts b/src/main/routes/port-forward/create-port-forward.injectable.ts new file mode 100644 index 000000000000..40deb1664187 --- /dev/null +++ b/src/main/routes/port-forward/create-port-forward.injectable.ts @@ -0,0 +1,40 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { PortForward, PortForwardArgs } from "./port-forward"; +import bundledKubectlInjectable from "../../kubectl/bundled-kubectl.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); + }, + + lifecycle: lifecycleEnum.singleton, +}); + +export default createPortForwardInjectable; diff --git a/src/main/routes/port-forward/port-forward.ts b/src/main/routes/port-forward/port-forward.ts new file mode 100644 index 000000000000..4b973d261caa --- /dev/null +++ b/src/main/routes/port-forward/port-forward.ts @@ -0,0 +1,122 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +import logger from "../../logger"; +import { getPortFrom } from "../../utils/get-port"; +import { spawn, ChildProcessWithoutNullStreams } from "child_process"; +import * as tcpPortUsed from "tcp-port-used"; + +const internalPortRegex = /^forwarding from (?
.+) ->/i; + +export interface PortForwardArgs { + clusterId: string; + kind: string; + namespace: string; + name: string; + port: number; + forwardPort: number; + protocol?: string; +} + +interface Dependencies { + getKubectlBinPath: (bundled: boolean) => Promise +} + +export class PortForward { + public static portForwards: PortForward[] = []; + + static getPortforward(forward: PortForwardArgs) { + return PortForward.portForwards.find((pf) => ( + pf.clusterId == forward.clusterId && + pf.kind == forward.kind && + pf.name == forward.name && + pf.namespace == forward.namespace && + pf.port == forward.port && + (!forward.protocol || pf.protocol == forward.protocol) + )); + } + + public process: ChildProcessWithoutNullStreams; + public clusterId: string; + public kind: string; + public namespace: string; + public name: string; + public port: number; + public forwardPort: number; + public protocol: string; + + constructor(private dependencies: Dependencies, public pathToKubeConfig: string, args: PortForwardArgs) { + this.clusterId = args.clusterId; + this.kind = args.kind; + this.namespace = args.namespace; + this.name = args.name; + this.port = args.port; + this.forwardPort = args.forwardPort; + this.protocol = args.protocol ?? "http"; + } + + public async start() { + const kubectlBin = await this.dependencies.getKubectlBinPath(true); + const args = [ + "--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); + this.process.on("exit", () => { + const index = PortForward.portForwards.indexOf(this); + + if (index > -1) { + PortForward.portForwards.splice(index, 1); + } + }); + + this.process.stderr.on("data", (data) => { + logger.debug(`[PORT-FORWARD-ROUTE]: kubectl port-forward process stderr: ${data}`); + }); + + const internalPort = await getPortFrom(this.process.stdout, { + lineRegex: internalPortRegex, + }); + + try { + await tcpPortUsed.waitUntilUsed(internalPort, 500, 15000); + + // make sure this.forwardPort is set to the actual port used (if it was 0 then an available port is found by 'kubectl port-forward') + this.forwardPort = internalPort; + + return true; + } catch (error) { + this.process.kill(); + + return false; + } + } + + public async stop() { + this.process.kill(); + } +} diff --git a/src/main/routes/port-forward/route-port-forward/route-port-forward.injectable.ts b/src/main/routes/port-forward/route-port-forward/route-port-forward.injectable.ts new file mode 100644 index 000000000000..db1130500f15 --- /dev/null +++ b/src/main/routes/port-forward/route-port-forward/route-port-forward.injectable.ts @@ -0,0 +1,33 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +import { routePortForward } from "./route-port-forward"; +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import createPortForwardInjectable from "../create-port-forward.injectable"; + +const routePortForwardInjectable = getInjectable({ + instantiate: (di) => routePortForward({ + createPortForward: di.inject(createPortForwardInjectable), + }), + + lifecycle: lifecycleEnum.singleton, +}); + +export default routePortForwardInjectable; 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 new file mode 100644 index 000000000000..738cb6679f66 --- /dev/null +++ b/src/main/routes/port-forward/route-port-forward/route-port-forward.ts @@ -0,0 +1,105 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +import type { LensApiRequest } from "../../../router"; +import logger from "../../../logger"; +import { respondJson } from "../../../utils/http-responses"; +import { PortForward, PortForwardArgs } from "../port-forward"; + +interface Dependencies { + createPortForward: (pathToKubeConfig: string, args: PortForwardArgs) => PortForward; +} + +export const routePortForward = + ({ createPortForward }: Dependencies) => + async (request: LensApiRequest) => { + const { params, query, response, cluster } = request; + const { namespace, resourceType, resourceName } = params; + const port = Number(query.get("port")); + const forwardPort = Number(query.get("forwardPort")); + const protocol = query.get("protocol"); + + try { + let portForward = PortForward.getPortforward({ + clusterId: cluster.id, + kind: resourceType, + name: resourceName, + namespace, + port, + forwardPort, + protocol, + }); + + if (!portForward) { + logger.info( + `Creating a new port-forward ${namespace}/${resourceType}/${resourceName}:${port}`, + ); + + const thePort = + 0 < forwardPort && forwardPort < 65536 ? forwardPort : 0; + + portForward = createPortForward(await cluster.getProxyKubeconfigPath(), { + clusterId: cluster.id, + kind: resourceType, + namespace, + name: resourceName, + port, + forwardPort: thePort, + protocol, + }); + + const started = await portForward.start(); + + if (!started) { + logger.error("[PORT-FORWARD-ROUTE]: failed to start a port-forward", { + namespace, + port, + resourceType, + resourceName, + }); + + return respondJson( + response, + { + message: `Failed to forward port ${port} to ${ + thePort ? forwardPort : "random port" + }`, + }, + 400, + ); + } + } + + respondJson(response, { port: portForward.forwardPort }); + } catch (error) { + logger.error( + `[PORT-FORWARD-ROUTE]: failed to open a port-forward: ${error}`, + { namespace, port, resourceType, resourceName }, + ); + + return respondJson( + response, + { + message: `Failed to forward port ${port}`, + }, + 400, + ); + } + }; diff --git a/src/main/shell-session/create-shell-session.injectable.ts b/src/main/shell-session/create-shell-session.injectable.ts new file mode 100644 index 000000000000..be600ca2f2e5 --- /dev/null +++ b/src/main/shell-session/create-shell-session.injectable.ts @@ -0,0 +1,45 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +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 index 1db6cb92177d..9311fc63c0d4 100644 --- a/src/main/shell-session/index.ts +++ b/src/main/shell-session/index.ts @@ -19,5 +19,5 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -export * from "./node-shell-session"; -export * from "./local-shell-session"; +export * from "./node-shell-session/node-shell-session"; +export * from "./local-shell-session/local-shell-session"; diff --git a/src/extensions/getDiForUnitTesting.ts b/src/main/shell-session/local-shell-session/local-shell-session.injectable.ts similarity index 58% rename from src/extensions/getDiForUnitTesting.ts rename to src/main/shell-session/local-shell-session/local-shell-session.injectable.ts index 81184da59a70..b08a009b5f83 100644 --- a/src/extensions/getDiForUnitTesting.ts +++ b/src/main/shell-session/local-shell-session/local-shell-session.injectable.ts @@ -18,36 +18,28 @@ * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ +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"; -import glob from "glob"; -import { memoize } from "lodash/fp"; +interface InstantiationParameter { + webSocket: WebSocket; + cluster: Cluster; + tabId: string; +} -import { - createContainer, - ConfigurableDependencyInjectionContainer, -} from "@ogre-tools/injectable"; +const localShellSessionInjectable = getInjectable({ + instantiate: (di, { cluster, tabId, webSocket }: InstantiationParameter) => { + const createKubectl = di.inject(createKubectlInjectable); -export const getDiForUnitTesting = () => { - const di: ConfigurableDependencyInjectionContainer = createContainer(); + const kubectl = createKubectl(cluster.version); - getInjectableFilePaths() - .map(key => { - // eslint-disable-next-line @typescript-eslint/no-var-requires - const injectable = require(key).default; + return new LocalShellSession(kubectl, webSocket, cluster, tabId); + }, - return { - id: key, - ...injectable, - aliases: [injectable, ...(injectable.aliases || [])], - }; - }) - .forEach(injectable => di.register(injectable)); + lifecycle: lifecycleEnum.transient, +}); - di.preventSideEffects(); - - return di; -}; - -const getInjectableFilePaths = memoize(() => [ - ...glob.sync("./**/*.injectable.{ts,tsx}", { cwd: __dirname }), -]); +export default localShellSessionInjectable; diff --git a/src/main/shell-session/local-shell-session.ts b/src/main/shell-session/local-shell-session/local-shell-session.ts similarity index 94% rename from src/main/shell-session/local-shell-session.ts rename to src/main/shell-session/local-shell-session/local-shell-session.ts index 855dfd3dc9e8..c5f2418394ea 100644 --- a/src/main/shell-session/local-shell-session.ts +++ b/src/main/shell-session/local-shell-session/local-shell-session.ts @@ -20,9 +20,9 @@ */ 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 { UserStore } from "../../../common/user-store"; +import { ShellSession } from "../shell-session"; export class LocalShellSession extends ShellSession { ShellType = "shell"; 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 new file mode 100644 index 000000000000..6356800ce6b3 --- /dev/null +++ b/src/main/shell-session/node-shell-session/node-shell-session.injectable.ts @@ -0,0 +1,46 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +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-session/node-shell-session.ts b/src/main/shell-session/node-shell-session/node-shell-session.ts similarity index 90% rename from src/main/shell-session/node-shell-session.ts rename to src/main/shell-session/node-shell-session/node-shell-session.ts index 0b7d673de970..d3f9f9d5ab4a 100644 --- a/src/main/shell-session/node-shell-session.ts +++ b/src/main/shell-session/node-shell-session/node-shell-session.ts @@ -23,13 +23,14 @@ 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 "../cluster"; -import { ShellOpenError, ShellSession } from "./shell-session"; +import type { Cluster } from "../../../common/cluster/cluster"; +import { ShellOpenError, ShellSession } 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 { 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"; export class NodeShellSession extends ShellSession { ShellType = "node-shell"; @@ -39,8 +40,8 @@ export class NodeShellSession extends ShellSession { protected readonly cwd: string | undefined = undefined; - constructor(socket: WebSocket, cluster: Cluster, protected nodeName: string, terminalId: string) { - super(socket, cluster, terminalId); + constructor(protected nodeName: string, kubectl: Kubectl, socket: WebSocket, cluster: Cluster, terminalId: string) { + super(kubectl, socket, cluster, terminalId); } public async open() { diff --git a/src/main/shell-session/shell-session.ts b/src/main/shell-session/shell-session.ts index b17ef22689a4..fbda5250df9f 100644 --- a/src/main/shell-session/shell-session.ts +++ b/src/main/shell-session/shell-session.ts @@ -19,8 +19,8 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import type { Cluster } from "../cluster"; -import { Kubectl } from "../kubectl"; +import type { Cluster } from "../../common/cluster/cluster"; +import type { Kubectl } from "../kubectl/kubectl"; import type WebSocket from "ws"; import { shellEnv } from "../utils/shell-env"; import { app } from "electron"; @@ -30,7 +30,7 @@ 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/event-bus"; +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"; @@ -142,7 +142,6 @@ export abstract class ShellSession { this.processes.clear(); } - protected kubectl: Kubectl; protected running = false; protected kubectlBinDirP: Promise; protected kubeconfigPathP: Promise; @@ -172,8 +171,7 @@ export abstract class ShellSession { return { shellProcess, resume }; } - constructor(protected websocket: WebSocket, protected cluster: Cluster, terminalId: string) { - this.kubectl = new Kubectl(cluster.version); + constructor(protected kubectl: Kubectl, protected websocket: WebSocket, protected cluster: Cluster, terminalId: string) { this.kubeconfigPathP = this.cluster.getProxyKubeconfigPath(); this.kubectlBinDirP = this.kubectl.binDir(); this.terminalId = `${cluster.id}:${terminalId}`; diff --git a/src/main/tray/tray-menu-items.test.ts b/src/main/tray/tray-menu-items.test.ts index b46bce367159..0cde4e43f26b 100644 --- a/src/main/tray/tray-menu-items.test.ts +++ b/src/main/tray/tray-menu-items.test.ts @@ -32,8 +32,10 @@ describe("tray-menu-items", () => { let trayMenuItems: IComputedValue; let extensionsStub: ObservableMap; - beforeEach(() => { - di = getDiForUnitTesting(); + beforeEach(async () => { + di = getDiForUnitTesting({ doGeneralOverrides: true }); + + await di.runSetups(); extensionsStub = new ObservableMap(); diff --git a/src/main/window-manager.ts b/src/main/window-manager.ts index 3987e29824b7..f435f4a83b1e 100644 --- a/src/main/window-manager.ts +++ b/src/main/window-manager.ts @@ -23,7 +23,7 @@ import type { ClusterId } from "../common/cluster-types"; import { makeObservable, observable } from "mobx"; import { app, BrowserWindow, dialog, ipcMain, shell, webContents } from "electron"; import windowStateKeeper from "electron-window-state"; -import { appEventBus } from "../common/event-bus"; +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"; diff --git a/src/migrations/cluster-store/3.6.0-beta.1.ts b/src/migrations/cluster-store/3.6.0-beta.1.ts index 55e2812b9ebd..143cfdad239b 100644 --- a/src/migrations/cluster-store/3.6.0-beta.1.ts +++ b/src/migrations/cluster-store/3.6.0-beta.1.ts @@ -27,8 +27,13 @@ import fse from "fs-extra"; import { loadConfigFromFileSync } from "../../common/kube-helpers"; import { MigrationDeclaration, migrationLog } from "../helpers"; import type { ClusterModel } from "../../common/cluster-types"; -import { getCustomKubeConfigPath, storedKubeConfigFolder } from "../../common/utils"; -import { AppPaths } from "../../common/app-paths"; +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; @@ -37,11 +42,16 @@ interface Pre360ClusterModel extends ClusterModel { export default { version: "3.6.0-beta.1", run(store) { - const userDataPath = AppPaths.get("userData"); + 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(storedKubeConfigFolder()); + fse.ensureDirSync(kubeConfigsPath); migrationLog("Number of clusters to migrate: ", storedClusters.length); @@ -50,7 +60,7 @@ export default { * migrate kubeconfig */ try { - const absPath = getCustomKubeConfigPath(clusterModel.id); + 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 }); diff --git a/src/migrations/cluster-store/5.0.0-beta.10.ts b/src/migrations/cluster-store/5.0.0-beta.10.ts index 287f2ecd7912..2587abaf7378 100644 --- a/src/migrations/cluster-store/5.0.0-beta.10.ts +++ b/src/migrations/cluster-store/5.0.0-beta.10.ts @@ -23,7 +23,9 @@ import path from "path"; import fse from "fs-extra"; import type { ClusterModel } from "../../common/cluster-types"; import type { MigrationDeclaration } from "../helpers"; -import { AppPaths } from "../../common/app-paths"; +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: { @@ -35,7 +37,9 @@ interface Pre500WorkspaceStoreModel { export default { version: "5.0.0-beta.10", run(store) { - const userDataPath = AppPaths.get("userData"); + const di = getLegacyGlobalDiForExtensionApi(); + + const userDataPath = di.inject(directoryForUserDataInjectable); try { const workspaceData: Pre500WorkspaceStoreModel = fse.readJsonSync(path.join(userDataPath, "lens-workspace-store.json")); diff --git a/src/migrations/cluster-store/5.0.0-beta.13.ts b/src/migrations/cluster-store/5.0.0-beta.13.ts index e5c81e90bc83..c239b73ce4d5 100644 --- a/src/migrations/cluster-store/5.0.0-beta.13.ts +++ b/src/migrations/cluster-store/5.0.0-beta.13.ts @@ -24,7 +24,9 @@ import { MigrationDeclaration, migrationLog } from "../helpers"; import { generateNewIdFor } from "../utils"; import path from "path"; import { moveSync, removeSync } from "fs-extra"; -import { AppPaths } from "../../common/app-paths"; +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"; function mergePrometheusPreferences(left: ClusterPrometheusPreferences, right: ClusterPrometheusPreferences): ClusterPrometheusPreferences { if (left.prometheus && left.prometheusProvider) { @@ -106,7 +108,11 @@ function moveStorageFolder({ folder, newId, oldId }: { folder: string, newId: st export default { version: "5.0.0-beta.13", run(store) { - const folder = path.resolve(AppPaths.get("userData"), "lens-local-storage"); + const di = getLegacyGlobalDiForExtensionApi(); + + const userDataPath = di.inject(directoryForUserDataInjectable); + + const folder = path.resolve(userDataPath, "lens-local-storage"); const oldClusters: ClusterModel[] = store.get("clusters") ?? []; const clusters = new Map(); diff --git a/src/migrations/hotbar-store/5.0.0-beta.10.ts b/src/migrations/hotbar-store/5.0.0-beta.10.ts index 882685e1b34f..e6ae4979d4de 100644 --- a/src/migrations/hotbar-store/5.0.0-beta.10.ts +++ b/src/migrations/hotbar-store/5.0.0-beta.10.ts @@ -23,12 +23,14 @@ import fse from "fs-extra"; import { isNull } from "lodash"; import path from "path"; import * as uuid from "uuid"; -import { AppPaths } from "../../common/app-paths"; -import type { ClusterStoreModel } from "../../common/cluster-store"; +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: { @@ -48,7 +50,10 @@ export default { run(store) { const rawHotbars = store.get("hotbars"); const hotbars: Hotbar[] = Array.isArray(rawHotbars) ? rawHotbars.filter(h => h && typeof h === "object") : []; - const userDataPath = AppPaths.get("userData"); + + 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) { diff --git a/src/migrations/user-store/5.0.3-beta.1.ts b/src/migrations/user-store/5.0.3-beta.1.ts index ec5de34d6650..ac70498a15ec 100644 --- a/src/migrations/user-store/5.0.3-beta.1.ts +++ b/src/migrations/user-store/5.0.3-beta.1.ts @@ -22,20 +22,29 @@ import { existsSync, readFileSync } from "fs"; import path from "path"; import os from "os"; -import type { ClusterStoreModel } from "../../common/cluster-store"; +import type { ClusterStoreModel } from "../../common/cluster-store/cluster-store"; import type { KubeconfigSyncEntry, UserPreferencesModel } from "../../common/user-store"; import { MigrationDeclaration, migrationLog } from "../helpers"; -import { isLogicalChildPath, storedKubeConfigFolder } from "../../common/utils"; -import { AppPaths } from "../../common/app-paths"; +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 userData = AppPaths.get("userData"); - const { clusters = [] }: ClusterStoreModel = JSON.parse(readFileSync(path.resolve(userData, "lens-cluster-store.json"), "utf-8")) ?? {}; - const extensionDataDir = path.resolve(userData, "extension_data"); + + 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")); @@ -46,7 +55,7 @@ export default { } const dirOfKubeconfig = path.dirname(cluster.kubeConfigPath); - if (dirOfKubeconfig === storedKubeConfigFolder()) { + if (dirOfKubeconfig === kubeConfigsPath) { migrationLog(`Skipping ${cluster.id} because kubeConfigPath is under the stored KubeConfig folder`); continue; } diff --git a/src/migrations/user-store/file-name-migration.ts b/src/migrations/user-store/file-name-migration.ts index 80bff6730261..930e5c528d69 100644 --- a/src/migrations/user-store/file-name-migration.ts +++ b/src/migrations/user-store/file-name-migration.ts @@ -21,10 +21,14 @@ import fse from "fs-extra"; import path from "path"; -import { AppPaths } from "../../common/app-paths"; +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 userDataPath = AppPaths.get("userData"); + const di = getLegacyGlobalDiForExtensionApi(); + + const userDataPath = di.inject(directoryForUserDataInjectable); const configJsonPath = path.join(userDataPath, "config.json"); const lensUserStoreJsonPath = path.join(userDataPath, "lens-user-store.json"); diff --git a/src/renderer/api/catalog-entity-registry.ts b/src/renderer/api/catalog-entity-registry.ts index 10eb124c4535..803fec0ece6a 100644 --- a/src/renderer/api/catalog-entity-registry.ts +++ b/src/renderer/api/catalog-entity-registry.ts @@ -23,8 +23,8 @@ import { computed, observable, makeObservable, action } from "mobx"; import { catalogEntityRunListener, ipcRendererOn } from "../../common/ipc"; import { CatalogCategory, CatalogEntity, CatalogEntityData, catalogCategoryRegistry, CatalogCategoryRegistry, CatalogEntityKindData } from "../../common/catalog"; import "../../common/catalog-entities"; -import type { Cluster } from "../../main/cluster"; -import { ClusterStore } from "../../common/cluster-store"; +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"; diff --git a/src/renderer/api/catalog-entity-registry/catalog-entity-registry.injectable.ts b/src/renderer/api/catalog-entity-registry/catalog-entity-registry.injectable.ts new file mode 100644 index 000000000000..69894a6d9ea0 --- /dev/null +++ b/src/renderer/api/catalog-entity-registry/catalog-entity-registry.injectable.ts @@ -0,0 +1,29 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { catalogEntityRegistry } from "../catalog-entity-registry"; + +const catalogEntityRegistryInjectable = getInjectable({ + instantiate: () => catalogEntityRegistry, + lifecycle: lifecycleEnum.singleton, +}); + +export default catalogEntityRegistryInjectable; diff --git a/src/renderer/app-paths/app-paths.injectable.ts b/src/renderer/app-paths/app-paths.injectable.ts new file mode 100644 index 000000000000..170a1cd86fb0 --- /dev/null +++ b/src/renderer/app-paths/app-paths.injectable.ts @@ -0,0 +1,43 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { AppPaths, appPathsInjectionToken, appPathsIpcChannel } from "../../common/app-paths/app-path-injection-token"; +import getValueFromRegisteredChannelInjectable from "./get-value-from-registered-channel/get-value-from-registered-channel.injectable"; + +let syncAppPaths: AppPaths; + +const appPathsInjectable = getInjectable({ + setup: async (di) => { + const getValueFromRegisteredChannel = di.inject( + getValueFromRegisteredChannelInjectable, + ); + + syncAppPaths = await getValueFromRegisteredChannel(appPathsIpcChannel); + }, + + instantiate: () => syncAppPaths, + + injectionToken: appPathsInjectionToken, + + lifecycle: lifecycleEnum.singleton, +}); + +export default appPathsInjectable; diff --git a/src/renderer/app-paths/get-value-from-registered-channel/get-value-from-registered-channel.injectable.ts b/src/renderer/app-paths/get-value-from-registered-channel/get-value-from-registered-channel.injectable.ts new file mode 100644 index 000000000000..2e354529340b --- /dev/null +++ b/src/renderer/app-paths/get-value-from-registered-channel/get-value-from-registered-channel.injectable.ts @@ -0,0 +1,32 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import ipcRendererInjectable from "./ipc-renderer/ipc-renderer.injectable"; +import { getValueFromRegisteredChannel } from "./get-value-from-registered-channel"; + +const getValueFromRegisteredChannelInjectable = getInjectable({ + instantiate: (di) => + getValueFromRegisteredChannel({ ipcRenderer: di.inject(ipcRendererInjectable) }), + + lifecycle: lifecycleEnum.singleton, +}); + +export default getValueFromRegisteredChannelInjectable; diff --git a/src/renderer/app-paths/get-value-from-registered-channel/get-value-from-registered-channel.ts b/src/renderer/app-paths/get-value-from-registered-channel/get-value-from-registered-channel.ts new file mode 100644 index 000000000000..939bc92a8d68 --- /dev/null +++ b/src/renderer/app-paths/get-value-from-registered-channel/get-value-from-registered-channel.ts @@ -0,0 +1,33 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +import type { IpcRenderer } from "electron"; +import type { Channel } from "../../../common/ipc-channel/channel"; + +interface Dependencies { + ipcRenderer: IpcRenderer; +} + +export const getValueFromRegisteredChannel = + ({ ipcRenderer }: Dependencies) => + , TInstance>( + channel: TChannel, + ): Promise => + ipcRenderer.invoke(channel.name); diff --git a/src/renderer/app-paths/get-value-from-registered-channel/ipc-renderer/ipc-renderer.injectable.ts b/src/renderer/app-paths/get-value-from-registered-channel/ipc-renderer/ipc-renderer.injectable.ts new file mode 100644 index 000000000000..2e72843ee83d --- /dev/null +++ b/src/renderer/app-paths/get-value-from-registered-channel/ipc-renderer/ipc-renderer.injectable.ts @@ -0,0 +1,30 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { ipcRenderer } from "electron"; + +const ipcRendererInjectable = getInjectable({ + instantiate: () => ipcRenderer, + lifecycle: lifecycleEnum.singleton, + causesSideEffects: true, +}); + +export default ipcRendererInjectable; diff --git a/src/renderer/bootstrap.tsx b/src/renderer/bootstrap.tsx index b9f48b2b8c2e..a131c4506eaf 100644 --- a/src/renderer/bootstrap.tsx +++ b/src/renderer/bootstrap.tsx @@ -31,34 +31,26 @@ import * as LensExtensionsRendererApi from "../extensions/renderer-api"; import { render } from "react-dom"; import { delay } from "../common/utils"; import { isMac, isDevelopment } from "../common/vars"; -import { ClusterStore } from "../common/cluster-store"; -import { UserStore } from "../common/user-store"; -import { ExtensionDiscovery } from "../extensions/extension-discovery"; import { HelmRepoManager } from "../main/helm/helm-repo-manager"; -import { ExtensionInstallationStateStore } from "./components/+extensions/extension-install.store"; 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 { ExtensionsStore } from "../extensions/extensions-store"; -import { FilesystemProvisionerStore } from "../main/extension-filesystem"; import { ThemeStore } from "./theme.store"; import { SentryInit } from "../common/sentry"; -import { TerminalStore } from "./components/dock/terminal.store"; -import { AppPaths } from "../common/app-paths"; 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 type { ExtensionLoader } from "../extensions/extension-loader"; -import bindProtocolAddRouteHandlersInjectable - from "./protocol-handler/bind-protocol-add-route-handlers/bind-protocol-add-route-handlers.injectable"; -import type { LensProtocolRouterRenderer } from "./protocol-handler"; -import lensProtocolRouterRendererInjectable - from "./protocol-handler/lens-protocol-router-renderer/lens-protocol-router-renderer.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 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) { @@ -79,23 +71,14 @@ async function attachChromeDebugger() { } } -type AppComponent = React.ComponentType & { +export async function bootstrap(di: DependencyInjectionContainer) { + await di.runSetups(); - // TODO: This static method is criminal as it has no direct relation with component - init( - rootElem: HTMLElement, - extensionLoader: ExtensionLoader, - bindProtocolAddRouteHandlers?: () => void, - lensProtocolRouterRendererInjectable?: LensProtocolRouterRenderer - ): Promise; -}; - -export async function bootstrap(comp: () => Promise, di: DependencyInjectionContainer) { const rootElem = document.getElementById("app"); const logPrefix = `[BOOTSTRAP-${process.isMainFrame ? "ROOT" : "CLUSTER"}-FRAME]:`; - await AppPaths.init(); - UserStore.createInstance(); + // TODO: Remove temporal dependencies to make timing of initialization not important + di.inject(userStoreInjectable); await attachChromeDebugger(); rootElem.classList.toggle("is-mac", isMac); @@ -136,38 +119,46 @@ export async function bootstrap(comp: () => Promise, di: Dependenc extensionLoader.init(); - ExtensionDiscovery.createInstance(extensionLoader).init(); + const extensionDiscovery = di.inject(extensionDiscoveryInjectable); + + extensionDiscovery.init(); // ClusterStore depends on: UserStore - const clusterStore = ClusterStore.createInstance(); + const clusterStore = di.inject(clusterStoreInjectable); await clusterStore.loadInitialOnRenderer(); // HotbarStore depends on: ClusterStore HotbarStore.createInstance(); - ExtensionsStore.createInstance(); - FilesystemProvisionerStore.createInstance(); // ThemeStore depends on: UserStore ThemeStore.createInstance(); - // TerminalStore depends on: ThemeStore - TerminalStore.createInstance(); WeblinkStore.createInstance(); - ExtensionInstallationStateStore.bindIpcListeners(); + const extensionInstallationStateStore = di.inject(extensionInstallationStateStoreInjectable); + + extensionInstallationStateStore.bindIpcListeners(); + HelmRepoManager.createInstance(); // initialize the manager // Register additional store listeners clusterStore.registerIpcListener(); - // init app's dependencies if any - const App = await comp(); + let App; + let initializeApp; + + // TODO: Introduce proper architectural boundaries between root and cluster iframes + if (process.isMainFrame) { + initializeApp = di.inject(initRootFrameInjectable); - const bindProtocolAddRouteHandlers = di.inject(bindProtocolAddRouteHandlersInjectable); - const lensProtocolRouterRenderer = di.inject(lensProtocolRouterRendererInjectable); + App = (await import("./frames/root-frame/root-frame")).RootFrame; + } else { + initializeApp = di.inject(initClusterFrameInjectable); + App = (await import("./frames/cluster-frame/cluster-frame")).ClusterFrame; + } - await App.init(rootElem, extensionLoader, bindProtocolAddRouteHandlers, lensProtocolRouterRenderer); + await initializeApp(rootElem); render( @@ -181,14 +172,7 @@ export async function bootstrap(comp: () => Promise, di: Dependenc const di = getDi(); // run -bootstrap( - async () => - process.isMainFrame - ? (await import("./root-frame")).RootFrame - : (await import("./cluster-frame")).ClusterFrame, - di, -); - +bootstrap(di); /** * Exports for virtual package "@k8slens/extensions" for renderer-process. diff --git a/src/renderer/cluster-frame-context/cluster-frame-context.injectable.ts b/src/renderer/cluster-frame-context/cluster-frame-context.injectable.ts new file mode 100644 index 000000000000..1d2e58b82b2b --- /dev/null +++ b/src/renderer/cluster-frame-context/cluster-frame-context.injectable.ts @@ -0,0 +1,42 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { ClusterFrameContext } from "./cluster-frame-context"; +import namespaceStoreInjectable from "../components/+namespaces/namespace-store/namespace-store.injectable"; +import hostedClusterInjectable from "../../common/cluster-store/hosted-cluster/hosted-cluster.injectable"; + +const clusterFrameContextInjectable = getInjectable({ + instantiate: (di) => { + const cluster = di.inject(hostedClusterInjectable); + + return new ClusterFrameContext( + cluster, + + { + namespaceStore: di.inject(namespaceStoreInjectable), + }, + ); + }, + + lifecycle: lifecycleEnum.singleton, +}); + +export default clusterFrameContextInjectable; diff --git a/src/renderer/components/context.ts b/src/renderer/cluster-frame-context/cluster-frame-context.ts similarity index 77% rename from src/renderer/components/context.ts rename to src/renderer/cluster-frame-context/cluster-frame-context.ts index fc494f5f37d7..104f58a854d0 100755 --- a/src/renderer/components/context.ts +++ b/src/renderer/cluster-frame-context/cluster-frame-context.ts @@ -19,13 +19,17 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import type { Cluster } from "../../main/cluster"; -import { namespaceStore } from "./+namespaces/namespace.store"; +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"; -export class FrameContext implements ClusterContext { - constructor(public cluster: Cluster) { +interface Dependencies { + namespaceStore: NamespaceStore +} + +export class ClusterFrameContext implements ClusterContext { + constructor(public cluster: Cluster, private dependencies: Dependencies) { makeObservable(this); } @@ -35,9 +39,9 @@ export class FrameContext implements ClusterContext { return this.cluster.accessibleNamespaces; } - if (namespaceStore.items.length > 0) { + if (this.dependencies.namespaceStore.items.length > 0) { // namespaces from kubernetes api - return namespaceStore.items.map((namespace) => namespace.getName()); + return this.dependencies.namespaceStore.items.map((namespace) => namespace.getName()); } else { // fallback to cluster resolved namespaces because we could not load list return this.cluster.allowedNamespaces || []; @@ -45,7 +49,7 @@ export class FrameContext implements ClusterContext { } @computed get contextNamespaces(): string[] { - return namespaceStore.contextNamespaces; + return this.dependencies.namespaceStore.contextNamespaces; } @computed get hasSelectedAll(): boolean { diff --git a/src/renderer/cluster-frame.tsx b/src/renderer/cluster-frame.tsx deleted file mode 100755 index bcade8cf5dab..000000000000 --- a/src/renderer/cluster-frame.tsx +++ /dev/null @@ -1,254 +0,0 @@ -/** - * Copyright (c) 2021 OpenLens Authors - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to - * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of - * the Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER - * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN - * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -import React from "react"; -import { observable, makeObservable, when } 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 { 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 logger from "../main/logger"; -import { webFrame } from "electron"; -import { ClusterPageRegistry, getExtensionPageUrl } from "../extensions/registries/page-registry"; -import type { ExtensionLoader } from "../extensions/extension-loader"; -import { appEventBus } from "../common/event-bus"; -import { requestMain } from "../common/ipc"; -import { clusterSetFrameIdHandler } from "../common/cluster-ipc"; -import { ClusterPageMenuRegistration, ClusterPageMenuRegistry } from "../extensions/registries"; -import { StatefulSetScaleDialog } from "./components/+workloads-statefulsets/statefulset-scale-dialog"; -import { KubeWatchApi, kubeWatchApi } from "../common/k8s-api/kube-watch-api"; -import { ReplicaSetScaleDialog } from "./components/+workloads-replicasets/replicaset-scale-dialog"; -import { CommandContainer } from "./components/command-palette/command-container"; -import { KubeObjectStore } from "../common/k8s-api/kube-object.store"; -import { FrameContext } from "./components/context"; -import * as routes from "../common/routes"; -import { TabLayout, TabLayoutRoute } from "./components/layout/tab-layout"; -import { ErrorBoundary } from "./components/error-boundary"; -import { MainLayout } from "./components/layout/main-layout"; -import { Notifications } from "./components/notifications"; -import { KubeObjectDetails } from "./components/kube-object-details"; -import { KubeConfigDialog } from "./components/kubeconfig-dialog"; -import { Terminal } from "./components/dock/terminal"; -import { namespaceStore } from "./components/+namespaces/namespace.store"; -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 { catalogEntityRegistry } from "./api/catalog-entity-registry"; -import { getHostedClusterId } from "./utils"; -import { ClusterStore } from "../common/cluster-store"; -import type { ClusterId } from "../common/cluster-types"; -import { watchHistoryState } from "./remote-helpers/history-updater"; -import { unmountComponentAtNode } from "react-dom"; -import { PortForwardDialog } from "./port-forward"; -import { DeleteClusterDialog } from "./components/delete-cluster-dialog"; -import { WorkloadsOverview } from "./components/+workloads-overview/overview"; -import { KubeObjectListLayout } from "./components/kube-object-list-layout"; -import type { KubernetesCluster } from "../common/catalog-entities"; - -@observer -export class ClusterFrame extends React.Component { - static clusterId: ClusterId; - static readonly logPrefix = "[CLUSTER-FRAME]:"; - static displayName = "ClusterFrame"; - - constructor(props: {}) { - super(props); - makeObservable(this); - } - - static async init(rootElem: HTMLElement, extensionLoader: ExtensionLoader) { - catalogEntityRegistry.init(); - const frameId = webFrame.routingId; - - ClusterFrame.clusterId = getHostedClusterId(); - - const cluster = ClusterStore.getInstance().getById(ClusterFrame.clusterId); - - logger.info(`${ClusterFrame.logPrefix} Init dashboard, clusterId=${ClusterFrame.clusterId}, frameId=${frameId}`); - await Terminal.preloadFonts(); - await requestMain(clusterSetFrameIdHandler, ClusterFrame.clusterId); - await cluster.whenReady; // cluster.activate() is done at this point - - catalogEntityRegistry.activeEntity = ClusterFrame.clusterId; - - // Only load the extensions once the catalog has been populated - when( - () => Boolean(catalogEntityRegistry.activeEntity), - () => extensionLoader.loadOnClusterRenderer(catalogEntityRegistry.activeEntity as KubernetesCluster), - { - timeout: 15_000, - onError: (error) => { - console.warn("[CLUSTER-FRAME]: error from activeEntity when()", error); - Notifications.error("Failed to get KubernetesCluster for this view. Extensions will not be loaded."); - }, - }, - ); - - setTimeout(() => { - appEventBus.emit({ - name: "cluster", - action: "open", - params: { - clusterId: ClusterFrame.clusterId, - }, - }); - }); - window.addEventListener("online", () => { - window.location.reload(); - }); - - window.onbeforeunload = () => { - logger.info(`${ClusterFrame.logPrefix} Unload dashboard, clusterId=${ClusterFrame.clusterId}, frameId=${frameId}`); - - unmountComponentAtNode(rootElem); - }; - - const clusterContext = new FrameContext(cluster); - - // Setup hosted cluster context - KubeObjectStore.defaultContext.set(clusterContext); - WorkloadsOverview.clusterContext - = KubeObjectListLayout.clusterContext - = KubeWatchApi.context - = clusterContext; - } - - componentDidMount() { - disposeOnUnmount(this, [ - kubeWatchApi.subscribeStores([ - namespaceStore, - ]), - - watchHistoryState(), - ]); - } - - @observable startUrl = isAllowedResource(["events", "nodes", "pods"]) ? routes.clusterURL() : routes.workloadsURL(); - - getTabLayoutRoutes(menuItem: ClusterPageMenuRegistration) { - const routes: TabLayoutRoute[] = []; - - if (!menuItem.id) { - return routes; - } - ClusterPageMenuRegistry.getInstance().getSubItems(menuItem).forEach((subMenu) => { - const page = ClusterPageRegistry.getInstance().getByPageTarget(subMenu.target); - - if (page) { - routes.push({ - routePath: page.url, - url: getExtensionPageUrl(subMenu.target), - title: subMenu.title, - component: page.components.Page, - }); - } - }); - - return routes; - } - - renderExtensionTabLayoutRoutes() { - return ClusterPageMenuRegistry.getInstance().getRootItems().map((menu, index) => { - const tabRoutes = this.getTabLayoutRoutes(menu); - - if (tabRoutes.length > 0) { - const pageComponent = () => ; - - return tab.routePath)}/>; - } else { - const page = ClusterPageRegistry.getInstance().getByPageTarget(menu.target); - - if (page) { - return ; - } - } - - return null; - }); - } - - renderExtensionRoutes() { - return ClusterPageRegistry.getInstance().getItems().map((page, index) => { - const menu = ClusterPageMenuRegistry.getInstance().getByPage(page); - - if (!menu) { - return ; - } - - return null; - }); - } - - render() { - return ( - - - } footer={}> - - - - - - - - - - - - - {this.renderExtensionTabLayoutRoutes()} - {this.renderExtensionRoutes()} - - { - Notifications.error(`Unknown location ${location.pathname}, redirecting to main page.`); - - return ; - }} /> - - - - - - - - - - - - - - - - ); - } -} diff --git a/src/renderer/components/+add-cluster/add-cluster.tsx b/src/renderer/components/+add-cluster/add-cluster.tsx index 40257f65185e..e9bd599618e7 100644 --- a/src/renderer/components/+add-cluster/add-cluster.tsx +++ b/src/renderer/components/+add-cluster/add-cluster.tsx @@ -28,23 +28,31 @@ import { action, computed, makeObservable, observable } from "mobx"; import { observer } from "mobx-react"; import path from "path"; import React from "react"; +import * as uuid from "uuid"; import { catalogURL } from "../../../common/routes"; -import { appEventBus } from "../../../common/event-bus"; +import { appEventBus } from "../../../common/app-event-bus/event-bus"; import { loadConfigFromString, splitConfig } from "../../../common/kube-helpers"; import { docsUrl } from "../../../common/vars"; import { navigate } from "../../navigation"; -import { getCustomKubeConfigPath, iter } from "../../utils"; +import { iter } from "../../utils"; import { Button } from "../button"; 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"; interface Option { config: KubeConfig; error?: string; } +interface Dependencies { + getCustomKubeConfigDirectory: (directoryName: string) => string +} + function getContexts(config: KubeConfig): Map { return new Map( splitConfig(config) @@ -56,14 +64,14 @@ function getContexts(config: KubeConfig): Map { } @observer -export class AddCluster extends React.Component { +class NonInjectedAddCluster extends React.Component { @observable kubeContexts = observable.map(); @observable customConfig = ""; @observable isWaiting = false; @observable errors: string[] = []; - constructor(props: {}) { - super(props); + constructor(dependencies: Dependencies) { + super(dependencies); makeObservable(this); } @@ -99,7 +107,7 @@ export class AddCluster extends React.Component { appEventBus.emit({ name: "cluster-add", action: "click" }); try { - const absPath = getCustomKubeConfigPath(); + const absPath = this.props.getCustomKubeConfigDirectory(uuid.v4()); await fse.ensureDir(path.dirname(absPath)); await fse.writeFile(absPath, this.customConfig.trim(), { encoding: "utf-8", mode: 0o600 }); @@ -153,3 +161,11 @@ export class AddCluster extends React.Component { ); } } + +export const AddCluster = withInjectables(NonInjectedAddCluster, { + getProps: (di) => ({ + getCustomKubeConfigDirectory: di.inject( + getCustomKubeConfigDirectoryInjectable, + ), + }), +}); diff --git a/src/renderer/components/+apps-helm-charts/helm-chart-details.tsx b/src/renderer/components/+apps-helm-charts/helm-chart-details.tsx index 739d8082e34a..b0b3e1b44d2f 100644 --- a/src/renderer/components/+apps-helm-charts/helm-chart-details.tsx +++ b/src/renderer/components/+apps-helm-charts/helm-chart-details.tsx @@ -31,9 +31,11 @@ import { MarkdownViewer } from "../markdown-viewer"; import { Spinner } from "../spinner"; import { Button } from "../button"; import { Select, SelectOption } from "../select"; -import { createInstallChartTab } from "../dock/install-chart.store"; import { Badge } from "../badge"; import { Tooltip, withStyles } from "@material-ui/core"; +import { withInjectables } from "@ogre-tools/injectable-react"; +import createInstallChartTabInjectable + from "../dock/create-install-chart-tab/create-install-chart-tab.injectable"; interface Props { chart: HelmChart; @@ -46,8 +48,12 @@ const LargeTooltip = withStyles({ }, })(Tooltip); +interface Dependencies { + createInstallChartTab: (helmChart: HelmChart) => void +} + @observer -export class HelmChartDetails extends Component { +class NonInjectedHelmChartDetails extends Component { @observable chartVersions: HelmChart[]; @observable selectedChart?: HelmChart; @observable readme?: string; @@ -55,7 +61,7 @@ export class HelmChartDetails extends Component { private abortController?: AbortController; - constructor(props: Props) { + constructor(props: Props & Dependencies) { super(props); makeObservable(this); } @@ -106,7 +112,7 @@ export class HelmChartDetails extends Component { @boundMethod install() { - createInstallChartTab(this.selectedChart); + this.props.createInstallChartTab(this.selectedChart); this.props.hideDetails(); } @@ -215,3 +221,14 @@ export class HelmChartDetails extends Component { ); } } + +export const HelmChartDetails = withInjectables( + NonInjectedHelmChartDetails, + + { + getProps: (di, props) => ({ + createInstallChartTab: di.inject(createInstallChartTabInjectable), + ...props, + }), + }, +); diff --git a/src/renderer/components/+apps-releases/release-details.tsx b/src/renderer/components/+apps-releases/release-details.tsx index 0a3d947411b4..e0b2926d9919 100644 --- a/src/renderer/components/+apps-releases/release-details.tsx +++ b/src/renderer/components/+apps-releases/release-details.tsx @@ -36,9 +36,8 @@ import { disposeOnUnmount, observer } from "mobx-react"; import { Spinner } from "../spinner"; import { Table, TableCell, TableHead, TableRow } from "../table"; import { Button } from "../button"; -import { releaseStore } from "./release.store"; +import type { ReleaseStore } from "./release.store"; import { Notifications } from "../notifications"; -import { createUpgradeChartTab } from "../dock/upgrade-chart.store"; import { ThemeStore } from "../../theme.store"; import { apiManager } from "../../../common/k8s-api/api-manager"; import { SubTitle } from "../layout/sub-title"; @@ -47,14 +46,23 @@ 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 -export class ReleaseDetails extends Component { +class NonInjectedReleaseDetails extends Component { @observable details: IReleaseDetails | null = null; @observable values = ""; @observable valuesLoading = false; @@ -73,7 +81,7 @@ export class ReleaseDetails extends Component { }), reaction(() => secretsStore.getItems(), () => { if (!this.props.release) return; - const { getReleaseSecret } = releaseStore; + const { getReleaseSecret } = this.props.releaseStore; const { release } = this.props; const secret = getReleaseSecret(release); @@ -89,7 +97,7 @@ export class ReleaseDetails extends Component { ]); } - constructor(props: Props) { + constructor(props: Props & Dependencies) { super(props); makeObservable(this); } @@ -133,7 +141,7 @@ export class ReleaseDetails extends Component { this.saving = true; try { - await releaseStore.update(name, namespace, data); + await this.props.releaseStore.update(name, namespace, data); Notifications.ok(

Release {name} successfully updated!

, ); @@ -146,7 +154,7 @@ export class ReleaseDetails extends Component { upgradeVersion = () => { const { release, hideDetails } = this.props; - createUpgradeChartTab(release); + this.props.createUpgradeChartTab(release); hideDetails(); }; @@ -315,3 +323,15 @@ export class ReleaseDetails extends Component { ); } } + +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 index 13532c22e481..fc92fd754618 100644 --- a/src/renderer/components/+apps-releases/release-menu.tsx +++ b/src/renderer/components/+apps-releases/release-menu.tsx @@ -22,32 +22,42 @@ import React from "react"; import type { HelmRelease } from "../../../common/k8s-api/endpoints/helm-releases.api"; import { cssNames } from "../../utils"; -import { releaseStore } from "./release.store"; +import type { ReleaseStore } from "./release.store"; import { MenuActions, MenuActionsProps } from "../menu/menu-actions"; import { MenuItem } from "../menu"; import { Icon } from "../icon"; -import { ReleaseRollbackDialog } from "./release-rollback-dialog"; -import { createUpgradeChartTab } from "../dock/upgrade-chart.store"; +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; } -export class HelmReleaseMenu extends React.Component { +interface Dependencies { + releaseStore: ReleaseStore + createUpgradeChartTab: (release: HelmRelease) => void + openRollbackDialog: (release: HelmRelease) => void +} + +class NonInjectedHelmReleaseMenu extends React.Component { remove = () => { - return releaseStore.remove(this.props.release); + return this.props.releaseStore.remove(this.props.release); }; upgrade = () => { const { release, hideDetails } = this.props; - createUpgradeChartTab(release); + this.props.createUpgradeChartTab(release); hideDetails?.(); }; rollback = () => { - ReleaseRollbackDialog.open(this.props.release); + this.props.openRollbackDialog(this.props.release); }; renderContent() { @@ -87,3 +97,17 @@ export class HelmReleaseMenu extends React.Component { ); } } + +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.injectable.ts b/src/renderer/components/+apps-releases/release-rollback-dialog-model/release-rollback-dialog-model.injectable.ts new file mode 100644 index 000000000000..5ae114a10668 --- /dev/null +++ b/src/renderer/components/+apps-releases/release-rollback-dialog-model/release-rollback-dialog-model.injectable.ts @@ -0,0 +1,29 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { ReleaseRollbackDialogModel } from "./release-rollback-dialog-model"; + +const releaseRollbackDialogModelInjectable = getInjectable({ + instantiate: () => new ReleaseRollbackDialogModel(), + lifecycle: lifecycleEnum.singleton, +}); + +export default releaseRollbackDialogModelInjectable; 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 new file mode 100644 index 000000000000..bdd4a8262585 --- /dev/null +++ b/src/renderer/components/+apps-releases/release-rollback-dialog-model/release-rollback-dialog-model.ts @@ -0,0 +1,47 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +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 index 07de38232f32..2db946ea6ba5 100644 --- a/src/renderer/components/+apps-releases/release-rollback-dialog.tsx +++ b/src/renderer/components/+apps-releases/release-rollback-dialog.tsx @@ -27,41 +27,36 @@ 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 { releaseStore } from "./release.store"; 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 { } -const dialogState = observable.object({ - isOpen: false, - release: null as HelmRelease, -}); +interface Dependencies { + rollbackRelease: (releaseName: string, namespace: string, revisionNumber: number) => Promise + model: ReleaseRollbackDialogModel +} @observer -export class ReleaseRollbackDialog extends React.Component { +class NonInjectedReleaseRollbackDialog extends React.Component { @observable isLoading = false; @observable revision: IReleaseRevision; @observable revisions = observable.array(); - constructor(props: Props) { + constructor(props: Props & Dependencies) { super(props); makeObservable(this); } - static open(release: HelmRelease) { - dialogState.isOpen = true; - dialogState.release = release; - } - - static close() { - dialogState.isOpen = false; - } - get release(): HelmRelease { - return dialogState.release; + return this.props.model.release; } onOpen = async () => { @@ -78,17 +73,13 @@ export class ReleaseRollbackDialog extends React.Component { const revisionNumber = this.revision.revision; try { - await releaseStore.rollback(this.release.getName(), this.release.getNs(), revisionNumber); - this.close(); + await this.props.rollbackRelease(this.release.getName(), this.release.getNs(), revisionNumber); + this.props.model.close(); } catch (err) { Notifications.error(err); } }; - close = () => { - ReleaseRollbackDialog.close(); - }; - renderContent() { const { revision, revisions } = this; @@ -120,11 +111,11 @@ export class ReleaseRollbackDialog extends React.Component { - + { ); } } + +export const ReleaseRollbackDialog = withInjectables( + NonInjectedReleaseRollbackDialog, + + { + getProps: (di, props) => ({ + rollbackRelease: di.inject(releaseStoreInjectable).rollback, + model: di.inject(releaseRollbackDialogModelInjectable), + ...props, + }), + }, +); diff --git a/src/renderer/components/+apps-releases/release-store.injectable.ts b/src/renderer/components/+apps-releases/release-store.injectable.ts new file mode 100644 index 000000000000..3d64da383b23 --- /dev/null +++ b/src/renderer/components/+apps-releases/release-store.injectable.ts @@ -0,0 +1,33 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { ReleaseStore } from "./release.store"; +import namespaceStoreInjectable from "../+namespaces/namespace-store/namespace-store.injectable"; + +const releaseStoreInjectable = getInjectable({ + instantiate: (di) => new ReleaseStore({ + namespaceStore: di.inject(namespaceStoreInjectable), + }), + + lifecycle: lifecycleEnum.singleton, +}); + +export default releaseStoreInjectable; diff --git a/src/renderer/components/+apps-releases/release.store.ts b/src/renderer/components/+apps-releases/release.store.ts index 081b637447fa..db90b077a858 100644 --- a/src/renderer/components/+apps-releases/release.store.ts +++ b/src/renderer/components/+apps-releases/release.store.ts @@ -26,13 +26,17 @@ import { createRelease, deleteRelease, HelmRelease, IReleaseCreatePayload, IRele import { ItemStore } from "../../../common/item.store"; import type { Secret } from "../../../common/k8s-api/endpoints"; import { secretsStore } from "../+config-secrets/secrets.store"; -import { namespaceStore } from "../+namespaces/namespace.store"; +import type { NamespaceStore } from "../+namespaces/namespace-store/namespace.store"; import { Notifications } from "../notifications"; +interface Dependencies { + namespaceStore: NamespaceStore +} + export class ReleaseStore extends ItemStore { releaseSecrets = observable.map(); - constructor() { + constructor(private dependencies: Dependencies ) { super(); makeObservable(this); autoBind(this); @@ -61,7 +65,7 @@ export class ReleaseStore extends ItemStore { } watchSelectedNamespaces(): (() => void) { - return reaction(() => namespaceStore.context.contextNamespaces, namespaces => { + return reaction(() => this.dependencies.namespaceStore.context.contextNamespaces, namespaces => { this.loadAll(namespaces); }, { fireImmediately: true, @@ -106,13 +110,13 @@ export class ReleaseStore extends ItemStore { } async loadFromContextNamespaces(): Promise { - return this.loadAll(namespaceStore.context.contextNamespaces); + return this.loadAll(this.dependencies.namespaceStore.context.contextNamespaces); } async loadItems(namespaces: string[]) { - const isLoadingAll = namespaceStore.context.allNamespaces?.length > 1 - && namespaceStore.context.cluster.accessibleNamespaces.length === 0 - && namespaceStore.context.allNamespaces.every(ns => namespaces.includes(ns)); + const isLoadingAll = this.dependencies.namespaceStore.context.allNamespaces?.length > 1 + && this.dependencies.namespaceStore.context.cluster.accessibleNamespaces.length === 0 + && this.dependencies.namespaceStore.context.allNamespaces.every(ns => namespaces.includes(ns)); if (isLoadingAll) { return listReleases(); @@ -123,13 +127,13 @@ export class ReleaseStore extends ItemStore { .then(items => items.flat()); } - async create(payload: IReleaseCreatePayload) { + create = async (payload: IReleaseCreatePayload) => { const response = await createRelease(payload); if (this.isLoaded) this.loadFromContextNamespaces(); return response; - } + }; async update(name: string, namespace: string, payload: IReleaseUpdatePayload) { const response = await updateRelease(name, namespace, payload); @@ -139,13 +143,13 @@ export class ReleaseStore extends ItemStore { return response; } - async rollback(name: string, namespace: string, revision: number) { + rollback = async (name: string, namespace: string, revision: number) => { const response = await rollbackRelease(name, namespace, revision); if (this.isLoaded) this.loadFromContextNamespaces(); return response; - } + }; async remove(release: HelmRelease) { return super.removeItem(release, () => deleteRelease(release.getName(), release.getNs())); @@ -155,5 +159,3 @@ export class ReleaseStore extends ItemStore { return Promise.all(this.selectedItems.map(this.remove)); } } - -export const releaseStore = new ReleaseStore(); diff --git a/src/renderer/components/+apps-releases/releases.tsx b/src/renderer/components/+apps-releases/releases.tsx index 8b01baf47673..3d8f7b7ae44f 100644 --- a/src/renderer/components/+apps-releases/releases.tsx +++ b/src/renderer/components/+apps-releases/releases.tsx @@ -25,7 +25,7 @@ import React, { Component } from "react"; import kebabCase from "lodash/kebabCase"; import { disposeOnUnmount, observer } from "mobx-react"; import type { RouteComponentProps } from "react-router"; -import { releaseStore } from "./release.store"; +import type { ReleaseStore } from "./release.store"; import type { HelmRelease } from "../../../common/k8s-api/endpoints/helm-releases.api"; import { ReleaseDetails } from "./release-details"; import { ReleaseRollbackDialog } from "./release-rollback-dialog"; @@ -36,7 +36,9 @@ import { secretsStore } from "../+config-secrets/secrets.store"; import { NamespaceSelectFilter } from "../+namespaces/namespace-select-filter"; import type { ReleaseRouteParams } from "../../../common/routes"; import { releaseURL } from "../../../common/routes"; -import { namespaceStore } from "../+namespaces/namespace.store"; +import { withInjectables } from "@ogre-tools/injectable-react"; +import releaseStoreInjectable from "./release-store.injectable"; +import namespaceStoreInjectable from "../+namespaces/namespace-store/namespace-store.injectable"; enum columnId { name = "name", @@ -52,25 +54,30 @@ enum columnId { interface Props extends RouteComponentProps { } +interface Dependencies { + releaseStore: ReleaseStore + selectNamespace: (namespace: string) => void +} + @observer -export class HelmReleases extends Component { +class NonInjectedHelmReleases extends Component { componentDidMount() { const { match: { params: { namespace }}} = this.props; if (namespace) { - namespaceStore.selectNamespaces(namespace); + this.props.selectNamespace(namespace); } disposeOnUnmount(this, [ - releaseStore.watchAssociatedSecrets(), - releaseStore.watchSelectedNamespaces(), + this.props.releaseStore.watchAssociatedSecrets(), + this.props.releaseStore.watchSelectedNamespaces(), ]); } get selectedRelease() { const { match: { params: { name, namespace }}} = this.props; - return releaseStore.items.find(release => { + return this.props.releaseStore.items.find(release => { return release.getName() == name && release.getNs() == namespace; }); } @@ -116,7 +123,7 @@ export class HelmReleases extends Component { isConfigurable tableId="helm_releases" className="HelmReleases" - store={releaseStore} + store={this.props.releaseStore} dependentStores={[secretsStore]} sortingCallbacks={{ [columnId.name]: release => release.getName(), @@ -188,3 +195,15 @@ export class HelmReleases extends Component { ); } } + +export const HelmReleases = withInjectables( + NonInjectedHelmReleases, + + { + getProps: (di, props) => ({ + releaseStore: di.inject(releaseStoreInjectable), + selectNamespace: di.inject(namespaceStoreInjectable).selectNamespaces, + ...props, + }), + }, +); diff --git a/src/renderer/components/+catalog/catalog-entity-store/catalog-entity-store.injectable.ts b/src/renderer/components/+catalog/catalog-entity-store/catalog-entity-store.injectable.ts new file mode 100644 index 000000000000..6b559aca117e --- /dev/null +++ b/src/renderer/components/+catalog/catalog-entity-store/catalog-entity-store.injectable.ts @@ -0,0 +1,33 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { CatalogEntityStore } from "./catalog-entity.store"; +import catalogEntityRegistryInjectable from "../../../api/catalog-entity-registry/catalog-entity-registry.injectable"; + +const catalogEntityStoreInjectable = getInjectable({ + instantiate: (di) => new CatalogEntityStore({ + registry: di.inject(catalogEntityRegistryInjectable), + }), + + lifecycle: lifecycleEnum.singleton, +}); + +export default catalogEntityStoreInjectable; diff --git a/src/renderer/components/+catalog/catalog-entity.store.tsx b/src/renderer/components/+catalog/catalog-entity-store/catalog-entity.store.tsx similarity index 78% rename from src/renderer/components/+catalog/catalog-entity.store.tsx rename to src/renderer/components/+catalog/catalog-entity-store/catalog-entity.store.tsx index 8b7743f4705e..f833a9c310dc 100644 --- a/src/renderer/components/+catalog/catalog-entity.store.tsx +++ b/src/renderer/components/+catalog/catalog-entity-store/catalog-entity.store.tsx @@ -20,14 +20,18 @@ */ import { computed, makeObservable, observable, reaction } from "mobx"; -import { catalogEntityRegistry, CatalogEntityRegistry } from "../../api/catalog-entity-registry"; -import type { CatalogEntity } from "../../api/catalog-entity"; -import { ItemStore } from "../../../common/item.store"; -import { CatalogCategory, catalogCategoryRegistry } from "../../../common/catalog"; -import { autoBind, disposer } from "../../../common/utils"; +import type { CatalogEntityRegistry } from "../../../api/catalog-entity-registry"; +import type { CatalogEntity } from "../../../api/catalog-entity"; +import { ItemStore } from "../../../../common/item.store"; +import { CatalogCategory, catalogCategoryRegistry } from "../../../../common/catalog"; +import { autoBind, disposer } from "../../../../common/utils"; + +interface Dependencies { + registry: CatalogEntityRegistry +} export class CatalogEntityStore extends ItemStore { - constructor(private registry: CatalogEntityRegistry = catalogEntityRegistry) { + constructor(private dependencies: Dependencies) { super(); makeObservable(this); autoBind(this); @@ -38,10 +42,10 @@ export class CatalogEntityStore extends ItemStore { @computed get entities() { if (!this.activeCategory) { - return this.registry.filteredItems; + return this.dependencies.registry.filteredItems; } - return this.registry.getItemsForCategory(this.activeCategory, { filtered: true }); + return this.dependencies.registry.getItemsForCategory(this.activeCategory, { filtered: true }); } @computed get selectedItem() { @@ -69,6 +73,6 @@ export class CatalogEntityStore extends ItemStore { } onRun(entity: CatalogEntity): void { - this.registry.onRun(entity); + this.dependencies.registry.onRun(entity); } } diff --git a/src/renderer/components/+catalog/catalog-previous-active-tab-storage/catalog-previous-active-tab-storage.injectable.ts b/src/renderer/components/+catalog/catalog-previous-active-tab-storage/catalog-previous-active-tab-storage.injectable.ts new file mode 100644 index 000000000000..4f778fbea4fa --- /dev/null +++ b/src/renderer/components/+catalog/catalog-previous-active-tab-storage/catalog-previous-active-tab-storage.injectable.ts @@ -0,0 +1,38 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { browseCatalogTab } from "../../../../common/routes"; +import createStorageInjectable from "../../../utils/create-storage/create-storage.injectable"; + +const catalogPreviousActiveTabStorageInjectable = getInjectable({ + instantiate: (di) => { + const createStorage = di.inject(createStorageInjectable); + + return createStorage( + "catalog-previous-active-tab", + browseCatalogTab, + ); + }, + + lifecycle: lifecycleEnum.singleton, +}); + +export default catalogPreviousActiveTabStorageInjectable; diff --git a/src/renderer/components/+catalog/catalog.test.tsx b/src/renderer/components/+catalog/catalog.test.tsx index 0f80aa475b3d..e81bd88afdcb 100644 --- a/src/renderer/components/+catalog/catalog.test.tsx +++ b/src/renderer/components/+catalog/catalog.test.tsx @@ -20,17 +20,27 @@ */ import React from "react"; -import { render, screen } from "@testing-library/react"; +import { screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { Catalog } from "./catalog"; import { createMemoryHistory } from "history"; import { mockWindow } from "../../../../__mocks__/windowMock"; -import { kubernetesClusterCategory } from "../../../common/catalog-entities/kubernetes-cluster"; -import { catalogCategoryRegistry, CatalogCategoryRegistry, CatalogEntity, CatalogEntityActionContext, CatalogEntityData } from "../../../common/catalog"; -import { CatalogEntityRegistry } from "../../../renderer/api/catalog-entity-registry"; +import { CatalogCategoryRegistry, CatalogEntity, CatalogEntityActionContext, CatalogEntityData } from "../../../common/catalog"; +import { CatalogEntityRegistry } from "../../api/catalog-entity-registry"; import { CatalogEntityDetailRegistry } from "../../../extensions/registries"; -import { CatalogEntityStore } from "./catalog-entity.store"; -import { AppPaths } from "../../../common/app-paths"; +import type { CatalogEntityStore } from "./catalog-entity-store/catalog-entity.store"; +import { getDiForUnitTesting } from "../../getDiForUnitTesting"; +import type { DependencyInjectionContainer } from "@ogre-tools/injectable"; +import catalogEntityStoreInjectable from "./catalog-entity-store/catalog-entity-store.injectable"; +import catalogEntityRegistryInjectable + from "../../api/catalog-entity-registry/catalog-entity-registry.injectable"; +import type { DiRender } from "../test-utils/renderFor"; +import { renderFor } from "../test-utils/renderFor"; +import { ThemeStore } from "../../theme.store"; +import { UserStore } from "../../../common/user-store"; +import mockFs from "mock-fs"; +import directoryForUserDataInjectable + from "../../../common/app-paths/directory-for-user-data/directory-for-user-data.injectable"; mockWindow(); jest.mock("electron", () => ({ @@ -49,8 +59,6 @@ jest.mock("electron", () => ({ }, })); -AppPaths.init(); - jest.mock("./hotbar-toggle-menu-item", () => ({ HotbarToggleMenuItem: () =>
menu item
, })); @@ -103,31 +111,46 @@ describe("", () => { }, onRun); } - beforeEach(() => { + let di: DependencyInjectionContainer; + let catalogEntityStore: CatalogEntityStore; + let catalogEntityRegistry: CatalogEntityRegistry; + let render: DiRender; + + beforeEach(async () => { + di = getDiForUnitTesting({ doGeneralOverrides: true }); + + di.override(directoryForUserDataInjectable, () => "some-directory-for-user-data"); + + await di.runSetups(); + + mockFs(); + + UserStore.createInstance(); + ThemeStore.createInstance(); CatalogEntityDetailRegistry.createInstance(); - // mock the return of getting CatalogCategoryRegistry.filteredItems - jest - .spyOn(catalogCategoryRegistry, "filteredItems", "get") - .mockImplementation(() => { - return [kubernetesClusterCategory]; - }); - - // we don't care what this.renderList renders in this test case. - jest.spyOn(Catalog.prototype, "renderList").mockImplementation(() => { - return empty renderList; - }); + + render = renderFor(di); + + const catalogCategoryRegistry = new CatalogCategoryRegistry(); + + catalogEntityRegistry = new CatalogEntityRegistry(catalogCategoryRegistry); + + di.override(catalogEntityRegistryInjectable, () => catalogEntityRegistry); + + catalogEntityStore = di.inject(catalogEntityStoreInjectable); }); afterEach(() => { + UserStore.resetInstance(); + ThemeStore.resetInstance(); CatalogEntityDetailRegistry.resetInstance(); + jest.clearAllMocks(); jest.restoreAllMocks(); + mockFs.restore(); }); it("can use catalogEntityRegistry.addOnBeforeRun to add hooks for catalog entities", (done) => { - const catalogCategoryRegistry = new CatalogCategoryRegistry(); - const catalogEntityRegistry = new CatalogEntityRegistry(catalogCategoryRegistry); - const catalogEntityStore = new CatalogEntityStore(catalogEntityRegistry); const onRun = jest.fn(); const catalogEntityItem = createMockCatalogEntity(onRun); @@ -153,7 +176,6 @@ describe("", () => { history={history} location={mockLocation} match={mockMatch} - catalogEntityStore={catalogEntityStore} />, ); @@ -161,9 +183,6 @@ describe("", () => { }); it("onBeforeRun prevents event => onRun wont be triggered", (done) => { - const catalogCategoryRegistry = new CatalogCategoryRegistry(); - const catalogEntityRegistry = new CatalogEntityRegistry(catalogCategoryRegistry); - const catalogEntityStore = new CatalogEntityStore(catalogEntityRegistry); const onRun = jest.fn(); const catalogEntityItem = createMockCatalogEntity(onRun); @@ -187,7 +206,6 @@ describe("", () => { history={history} location={mockLocation} match={mockMatch} - catalogEntityStore={catalogEntityStore} />, ); @@ -195,9 +213,6 @@ describe("", () => { }); it("addOnBeforeRun throw an exception => onRun will be triggered", (done) => { - const catalogCategoryRegistry = new CatalogCategoryRegistry(); - const catalogEntityRegistry = new CatalogEntityRegistry(catalogCategoryRegistry); - const catalogEntityStore = new CatalogEntityStore(catalogEntityRegistry); const onRun = jest.fn(); const catalogEntityItem = createMockCatalogEntity(onRun); @@ -222,7 +237,6 @@ describe("", () => { history={history} location={mockLocation} match={mockMatch} - catalogEntityStore={catalogEntityStore} />, ); @@ -230,9 +244,6 @@ describe("", () => { }); it("addOnRunHook return a promise and does not prevent run event => onRun()", (done) => { - const catalogCategoryRegistry = new CatalogCategoryRegistry(); - const catalogEntityRegistry = new CatalogEntityRegistry(catalogCategoryRegistry); - const catalogEntityStore = new CatalogEntityStore(catalogEntityRegistry); const onRun = jest.fn(() => done()); const catalogEntityItem = createMockCatalogEntity(onRun); @@ -252,7 +263,6 @@ describe("", () => { history={history} location={mockLocation} match={mockMatch} - catalogEntityStore={catalogEntityStore} />, ); @@ -260,9 +270,6 @@ describe("", () => { }); it("addOnRunHook return a promise and prevents event wont be triggered", (done) => { - const catalogCategoryRegistry = new CatalogCategoryRegistry(); - const catalogEntityRegistry = new CatalogEntityRegistry(catalogCategoryRegistry); - const catalogEntityStore = new CatalogEntityStore(catalogEntityRegistry); const onRun = jest.fn(); const catalogEntityItem = createMockCatalogEntity(onRun); @@ -289,7 +296,6 @@ describe("", () => { history={history} location={mockLocation} match={mockMatch} - catalogEntityStore={catalogEntityStore} />, ); @@ -297,9 +303,6 @@ describe("", () => { }); it("addOnRunHook return a promise and reject => onRun will be triggered", (done) => { - const catalogCategoryRegistry = new CatalogCategoryRegistry(); - const catalogEntityRegistry = new CatalogEntityRegistry(catalogCategoryRegistry); - const catalogEntityStore = new CatalogEntityStore(catalogEntityRegistry); const onRun = jest.fn(); const catalogEntityItem = createMockCatalogEntity(onRun); @@ -324,7 +327,6 @@ describe("", () => { history={history} location={mockLocation} match={mockMatch} - catalogEntityStore={catalogEntityStore} />, ); diff --git a/src/renderer/components/+catalog/catalog.tsx b/src/renderer/components/+catalog/catalog.tsx index 595ba6a81c72..583a4e3c0e2f 100644 --- a/src/renderer/components/+catalog/catalog.tsx +++ b/src/renderer/components/+catalog/catalog.tsx @@ -25,7 +25,7 @@ import React from "react"; import { disposeOnUnmount, observer } from "mobx-react"; import { ItemListLayout } from "../item-object-list"; import { action, makeObservable, observable, reaction, runInAction, when } from "mobx"; -import { CatalogEntityStore } from "./catalog-entity.store"; +import type { CatalogEntityStore } from "./catalog-entity-store/catalog-entity.store"; import { navigate } from "../../navigation"; import { MenuItem, MenuActions } from "../menu"; import type { CatalogEntityContextMenu, CatalogEntityContextMenuContext } from "../../api/catalog-entity"; @@ -36,7 +36,7 @@ import { CatalogAddButton } from "./catalog-add-button"; import type { RouteComponentProps } from "react-router"; import { Notifications } from "../notifications"; import { MainLayout } from "../layout/main-layout"; -import { createStorage, prevDefault } from "../../utils"; +import { prevDefault } from "../../utils"; import { CatalogEntityDetails } from "./catalog-entity-details"; import { browseCatalogTab, catalogURL, CatalogViewRouteParam } from "../../../common/routes"; import { CatalogMenu } from "./catalog-menu"; @@ -46,8 +46,10 @@ import { HotbarToggleMenuItem } from "./hotbar-toggle-menu-item"; import { Avatar } from "../avatar"; import { KubeObject } from "../../../common/k8s-api/kube-object"; import { getLabelBadges } from "./helpers"; - -export const previousActiveTab = createStorage("catalog-previous-active-tab", browseCatalogTab); +import { withInjectables } from "@ogre-tools/injectable-react"; +import catalogPreviousActiveTabStorageInjectable + from "./catalog-previous-active-tab-storage/catalog-previous-active-tab-storage.injectable"; +import catalogEntityStoreInjectable from "./catalog-entity-store/catalog-entity-store.injectable"; enum sortBy { name = "name", @@ -56,26 +58,23 @@ enum sortBy { status = "status", } -interface Props extends RouteComponentProps { - catalogEntityStore?: CatalogEntityStore; +interface Props extends RouteComponentProps {} + +interface Dependencies { + catalogPreviousActiveTabStorage: { set: (value: string ) => void } + catalogEntityStore: CatalogEntityStore } @observer -export class Catalog extends React.Component { - @observable private catalogEntityStore?: CatalogEntityStore; +class NonInjectedCatalog extends React.Component { @observable private contextMenu: CatalogEntityContextMenuContext; @observable activeTab?: string; - constructor(props: Props) { + constructor(props: Props & Dependencies) { super(props); makeObservable(this); - this.catalogEntityStore = props.catalogEntityStore; } - - static defaultProps = { - catalogEntityStore: new CatalogEntityStore(), - }; - + get routeActiveTab(): string { const { group, kind } = this.props.match.params ?? {}; @@ -92,9 +91,9 @@ export class Catalog extends React.Component { navigate: (url: string) => navigate(url), }; disposeOnUnmount(this, [ - this.catalogEntityStore.watch(), + this.props.catalogEntityStore.watch(), reaction(() => this.routeActiveTab, async (routeTab) => { - previousActiveTab.set(this.routeActiveTab); + this.props.catalogPreviousActiveTabStorage.set(this.routeActiveTab); try { await when(() => (routeTab === browseCatalogTab || !!catalogCategoryRegistry.filteredItems.find(i => i.getId() === routeTab)), { timeout: 5_000 }); // we need to wait because extensions might take a while to load @@ -102,7 +101,7 @@ export class Catalog extends React.Component { runInAction(() => { this.activeTab = routeTab; - this.catalogEntityStore.activeCategory = item; + this.props.catalogEntityStore.activeCategory = item; }); } catch (error) { console.error(error); @@ -113,13 +112,13 @@ export class Catalog extends React.Component { // If active category is filtered out, automatically switch to the first category disposeOnUnmount(this, reaction(() => catalogCategoryRegistry.filteredItems, () => { - if (!catalogCategoryRegistry.filteredItems.find(item => item.getId() === this.catalogEntityStore.activeCategory.getId())) { + if (!catalogCategoryRegistry.filteredItems.find(item => item.getId() === this.props.catalogEntityStore.activeCategory.getId())) { const item = catalogCategoryRegistry.filteredItems[0]; runInAction(() => { if (item) { this.activeTab = item.getId(); - this.catalogEntityStore.activeCategory = item; + this.props.catalogEntityStore.activeCategory = item; } }); } @@ -135,10 +134,10 @@ export class Catalog extends React.Component { } onDetails = (entity: CatalogEntity) => { - if (this.catalogEntityStore.selectedItemId) { - this.catalogEntityStore.selectedItemId = null; + if (this.props.catalogEntityStore.selectedItemId) { + this.props.catalogEntityStore.selectedItemId = null; } else { - this.catalogEntityStore.onRun(entity); + this.props.catalogEntityStore.onRun(entity); } }; @@ -189,7 +188,7 @@ export class Catalog extends React.Component { return ( - this.catalogEntityStore.selectedItemId = entity.getId()}> + this.props.catalogEntityStore.selectedItemId = entity.getId()}> View Details { @@ -238,7 +237,7 @@ export class Catalog extends React.Component { } renderList() { - const { activeCategory } = this.catalogEntityStore; + const { activeCategory } = this.props.catalogEntityStore; const tableId = activeCategory ? `catalog-items-${activeCategory.metadata.name.replace(" ", "")}` : "catalog-items"; if (this.activeTab === undefined) { @@ -252,7 +251,7 @@ export class Catalog extends React.Component { renderHeaderTitle={activeCategory?.metadata.name || "Browse All"} isSelectable={false} isConfigurable={true} - store={this.catalogEntityStore} + store={this.props.catalogEntityStore} sortingCallbacks={{ [sortBy.name]: entity => entity.getName(), [sortBy.source]: entity => entity.getSource(), @@ -292,11 +291,11 @@ export class Catalog extends React.Component { } render() { - if (!this.catalogEntityStore) { + if (!this.props.catalogEntityStore) { return null; } - const selectedEntity = this.catalogEntityStore.selectedItem; + const selectedEntity = this.props.catalogEntityStore.selectedItem; return ( @@ -307,13 +306,13 @@ export class Catalog extends React.Component { selectedEntity ? this.catalogEntityStore.selectedItemId = null} - onRun={() => this.catalogEntityStore.onRun(selectedEntity)} + hideDetails={() => this.props.catalogEntityStore.selectedItemId = null} + onRun={() => this.props.catalogEntityStore.onRun(selectedEntity)} /> : ( ) @@ -322,3 +321,18 @@ export class Catalog extends React.Component { ); } } + +export const Catalog = withInjectables( + NonInjectedCatalog, + { + getProps: (di, props) => ({ + catalogEntityStore: di.inject(catalogEntityStoreInjectable), + + catalogPreviousActiveTabStorage: di.inject( + catalogPreviousActiveTabStorageInjectable, + ), + + ...props, + }), + }, +); diff --git a/src/renderer/components/+cluster/cluster-metric-switchers.tsx b/src/renderer/components/+cluster/cluster-metric-switchers.tsx index aa2ff2985faa..28cbef264a56 100644 --- a/src/renderer/components/+cluster/cluster-metric-switchers.tsx +++ b/src/renderer/components/+cluster/cluster-metric-switchers.tsx @@ -24,12 +24,17 @@ 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"; +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"; -export const ClusterMetricSwitchers = observer(() => { - const { metricType, metricNodeRole, getMetricsValues, metrics } = clusterOverviewStore; +interface Dependencies { + clusterOverviewStore: ClusterOverviewStore +} + +const NonInjectedClusterMetricSwitchers = observer(({ clusterOverviewStore }: Dependencies) => { const { masterNodes, workerNodes } = nodesStore; - const metricsValues = getMetricsValues(metrics); + const metricsValues = clusterOverviewStore.getMetricsValues(clusterOverviewStore.metrics); const disableRoles = !masterNodes.length || !workerNodes.length; const disableMetrics = !metricsValues.length; @@ -39,7 +44,7 @@ export const ClusterMetricSwitchers = observer(() => { clusterOverviewStore.metricNodeRole = metric} > @@ -50,7 +55,7 @@ export const ClusterMetricSwitchers = observer(() => { clusterOverviewStore.metricType = value} > @@ -60,3 +65,14 @@ export const ClusterMetricSwitchers = observer(() => { ); }); + +export const ClusterMetricSwitchers = withInjectables( + NonInjectedClusterMetricSwitchers, + + { + getProps: (di) => ({ + clusterOverviewStore: di.inject(clusterOverviewStoreInjectable), + }), + }, +); + diff --git a/src/renderer/components/+cluster/cluster-metrics.tsx b/src/renderer/components/+cluster/cluster-metrics.tsx index 919a96e774a8..654300db55d3 100644 --- a/src/renderer/components/+cluster/cluster-metrics.tsx +++ b/src/renderer/components/+cluster/cluster-metrics.tsx @@ -24,7 +24,7 @@ import styles from "./cluster-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"; +import { ClusterOverviewStore, MetricType } from "./cluster-overview-store/cluster-overview-store"; import { BarChart } from "../chart"; import { bytesToUnits, cssNames } from "../../utils"; import { Spinner } from "../spinner"; @@ -32,10 +32,16 @@ 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"; -export const ClusterMetrics = observer(() => { - const { metricType, metricNodeRole, getMetricsValues, metricsLoaded, metrics } = clusterOverviewStore; - const { memoryCapacity, cpuCapacity } = getMetricLastPoints(clusterOverviewStore.metrics); +interface Dependencies { + clusterOverviewStore: ClusterOverviewStore +} + +const NonInjectedClusterMetrics = observer(({ clusterOverviewStore: { metricType, metricNodeRole, getMetricsValues, metricsLoaded, metrics }}: Dependencies) => { + const { memoryCapacity, cpuCapacity } = getMetricLastPoints(metrics); const metricValues = getMetricsValues(metrics); const colors = { cpu: "#3D90CE", memory: "#C93DCE" }; const data = metricValues.map(value => ({ @@ -118,3 +124,13 @@ export const ClusterMetrics = observer(() => { ); }); + +export const ClusterMetrics = withInjectables( + NonInjectedClusterMetrics, + + { + getProps: (di) => ({ + clusterOverviewStore: di.inject(clusterOverviewStoreInjectable), + }), + }, +); 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 new file mode 100644 index 000000000000..7df5e3f6bb7c --- /dev/null +++ b/src/renderer/components/+cluster/cluster-overview-store/cluster-overview-store.injectable.ts @@ -0,0 +1,57 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +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.ts b/src/renderer/components/+cluster/cluster-overview-store/cluster-overview-store.ts similarity index 78% rename from src/renderer/components/+cluster/cluster-overview.store.ts rename to src/renderer/components/+cluster/cluster-overview-store/cluster-overview-store.ts index 130797c4f8d5..c57437fc8c65 100644 --- a/src/renderer/components/+cluster/cluster-overview.store.ts +++ b/src/renderer/components/+cluster/cluster-overview-store/cluster-overview-store.ts @@ -20,12 +20,11 @@ */ 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, createStorage } from "../../utils"; -import { IMetricsReqParams, normalizeMetrics } from "../../../common/k8s-api/endpoints/metrics.api"; -import { nodesStore } from "../+nodes/nodes.store"; -import { apiManager } from "../../../common/k8s-api/api-manager"; +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", @@ -42,34 +41,33 @@ export interface ClusterOverviewStorageState { metricNodeRole: MetricNodeRole, } +interface Dependencies { + storage: StorageHelper +} + export class ClusterOverviewStore extends KubeObjectStore implements ClusterOverviewStorageState { api = clusterApi; @observable metrics: Partial = {}; @observable metricsLoaded = false; - private storage = createStorage("cluster_overview", { - metricType: MetricType.CPU, // setup defaults - metricNodeRole: MetricNodeRole.WORKER, - }); - get metricType(): MetricType { - return this.storage.get().metricType; + return this.dependencies.storage.get().metricType; } set metricType(value: MetricType) { - this.storage.merge({ metricType: value }); + this.dependencies.storage.merge({ metricType: value }); } get metricNodeRole(): MetricNodeRole { - return this.storage.get().metricNodeRole; + return this.dependencies.storage.get().metricNodeRole; } set metricNodeRole(value: MetricNodeRole) { - this.storage.merge({ metricNodeRole: value }); + this.dependencies.storage.merge({ metricNodeRole: value }); } - constructor() { + constructor(private dependencies: Dependencies ) { super(); makeObservable(this); autoBind(this); @@ -125,9 +123,6 @@ export class ClusterOverviewStore extends KubeObjectStore implements Cl reset() { super.reset(); this.resetMetrics(); - this.storage?.reset(); + this.dependencies.storage?.reset(); } } - -export const clusterOverviewStore = new ClusterOverviewStore(); -apiManager.registerStore(clusterOverviewStore); diff --git a/src/renderer/components/+cluster/cluster-overview.tsx b/src/renderer/components/+cluster/cluster-overview.tsx index 064b716e7e4c..a0f9b05b9fcb 100644 --- a/src/renderer/components/+cluster/cluster-overview.tsx +++ b/src/renderer/components/+cluster/cluster-overview.tsx @@ -26,28 +26,37 @@ import { reaction } from "mobx"; import { disposeOnUnmount, observer } from "mobx-react"; import { nodesStore } from "../+nodes/nodes.store"; import { podsStore } from "../+workloads-pods/pods.store"; -import { getHostedClusterId, interval } from "../../utils"; +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 { clusterOverviewStore } from "./cluster-overview.store"; +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"; -import { kubeWatchApi } from "../../../common/k8s-api/kube-watch-api"; +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 -export class ClusterOverview extends React.Component { +class NonInjectedClusterOverview extends React.Component { private metricPoller = interval(60, () => this.loadMetrics()); loadMetrics() { const cluster = ClusterStore.getInstance().getById(getHostedClusterId()); if (cluster.available) { - clusterOverviewStore.loadMetrics(); + this.props.clusterOverviewStore.loadMetrics(); } } @@ -55,13 +64,14 @@ export class ClusterOverview extends React.Component { this.metricPoller.start(true); disposeOnUnmount(this, [ - kubeWatchApi.subscribeStores([ + this.props.subscribeStores([ podsStore, eventStore, nodesStore, ]), + reaction( - () => clusterOverviewStore.metricNodeRole, // Toggle Master/Worker node switcher + () => this.props.clusterOverviewStore.metricNodeRole, // Toggle Master/Worker node switcher () => this.metricPoller.restart(true), ), ]); @@ -110,3 +120,14 @@ export class ClusterOverview extends React.Component { ); } } + +export const ClusterOverview = withInjectables( + NonInjectedClusterOverview, + + { + getProps: (di) => ({ + subscribeStores: di.inject(kubeWatchApiInjectable).subscribeStores, + clusterOverviewStore: di.inject(clusterOverviewStoreInjectable), + }), + }, +); diff --git a/src/renderer/components/+cluster/cluster-pie-charts.tsx b/src/renderer/components/+cluster/cluster-pie-charts.tsx index c88202c4104c..b844057994e3 100644 --- a/src/renderer/components/+cluster/cluster-pie-charts.tsx +++ b/src/renderer/components/+cluster/cluster-pie-charts.tsx @@ -23,7 +23,7 @@ import styles from "./cluster-pie-charts.module.scss"; import React from "react"; import { observer } from "mobx-react"; -import { clusterOverviewStore, MetricNodeRole } from "./cluster-overview.store"; +import { ClusterOverviewStore, MetricNodeRole } from "./cluster-overview-store/cluster-overview-store"; import { Spinner } from "../spinner"; import { Icon } from "../icon"; import { nodesStore } from "../+nodes/nodes.store"; @@ -32,12 +32,18 @@ import { ClusterNoMetrics } from "./cluster-no-metrics"; import { bytesToUnits, cssNames } from "../../utils"; import { ThemeStore } from "../../theme.store"; 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"; function createLabels(rawLabelData: [string, number | undefined][]): string[] { return rawLabelData.map(([key, value]) => `${key}: ${value?.toFixed(2) || "N/A"}`); } -export const ClusterPieCharts = observer(() => { +interface Dependencies { + clusterOverviewStore: ClusterOverviewStore +} + +const NonInjectedClusterPieCharts = observer(({ clusterOverviewStore }: Dependencies) => { const renderLimitWarning = () => { return (
@@ -213,9 +219,8 @@ export const ClusterPieCharts = observer(() => { ); }; - const renderContent = () => { + const renderContent = ({ metricNodeRole, metricsLoaded }: ClusterOverviewStore) => { const { masterNodes, workerNodes } = nodesStore; - const { metricNodeRole, metricsLoaded } = clusterOverviewStore; const nodes = metricNodeRole === MetricNodeRole.MASTER ? masterNodes : workerNodes; if (!nodes.length) { @@ -245,7 +250,17 @@ export const ClusterPieCharts = observer(() => { return (
- {renderContent()} + {renderContent(clusterOverviewStore)}
); }); + +export const ClusterPieCharts = withInjectables( + NonInjectedClusterPieCharts, + + { + getProps: (di) => ({ + clusterOverviewStore: di.inject(clusterOverviewStoreInjectable), + }), + }, +); diff --git a/src/renderer/components/+events/kube-event-details.tsx b/src/renderer/components/+events/kube-event-details.tsx index e573f97303f1..65e87ae5a2e3 100644 --- a/src/renderer/components/+events/kube-event-details.tsx +++ b/src/renderer/components/+events/kube-event-details.tsx @@ -25,21 +25,28 @@ import React from "react"; import { disposeOnUnmount, observer } from "mobx-react"; import { KubeObject } from "../../../common/k8s-api/kube-object"; import { DrawerItem, DrawerTitle } from "../drawer"; -import { cssNames } from "../../utils"; +import { cssNames, Disposer } from "../../utils"; import { LocaleDate } from "../locale-date"; import { eventStore } from "./event.store"; import logger from "../../../common/logger"; -import { kubeWatchApi } from "../../../common/k8s-api/kube-watch-api"; +import type { KubeObjectStore } from "../../../common/k8s-api/kube-object.store"; +import { withInjectables } from "@ogre-tools/injectable-react"; +import kubeWatchApiInjectable + from "../../kube-watch-api/kube-watch-api.injectable"; export interface KubeEventDetailsProps { object: KubeObject; } +interface Dependencies { + subscribeStores: (stores: KubeObjectStore[]) => Disposer +} + @observer -export class KubeEventDetails extends React.Component { +class NonInjectedKubeEventDetails extends React.Component { componentDidMount() { disposeOnUnmount(this, [ - kubeWatchApi.subscribeStores([ + this.props.subscribeStores([ eventStore, ]), ]); @@ -102,3 +109,17 @@ export class KubeEventDetails extends React.Component { ); } } + +export const KubeEventDetails = withInjectables( + NonInjectedKubeEventDetails, + + { + getProps: (di, props) => ({ + subscribeStores: di.inject(kubeWatchApiInjectable).subscribeStores, + ...props, + }), + }, +); + + + diff --git a/src/renderer/components/+extensions/__tests__/extensions.test.tsx b/src/renderer/components/+extensions/__tests__/extensions.test.tsx index 1ad53d2fd62c..739935bf3750 100644 --- a/src/renderer/components/+extensions/__tests__/extensions.test.tsx +++ b/src/renderer/components/+extensions/__tests__/extensions.test.tsx @@ -24,18 +24,18 @@ import { fireEvent, waitFor } from "@testing-library/react"; import fse from "fs-extra"; import React from "react"; import { UserStore } from "../../../../common/user-store"; -import { ExtensionDiscovery } from "../../../../extensions/extension-discovery"; +import type { ExtensionDiscovery } from "../../../../extensions/extension-discovery/extension-discovery"; import type { ExtensionLoader } from "../../../../extensions/extension-loader"; import { ConfirmDialog } from "../../confirm-dialog"; -import { ExtensionInstallationStateStore } from "../extension-install.store"; import { Extensions } from "../extensions"; import mockFs from "mock-fs"; import { mockWindow } from "../../../../../__mocks__/windowMock"; -import { AppPaths } from "../../../../common/app-paths"; -import extensionLoaderInjectable - from "../../../../extensions/extension-loader/extension-loader.injectable"; import { getDiForUnitTesting } from "../../../getDiForUnitTesting"; +import extensionLoaderInjectable from "../../../../extensions/extension-loader/extension-loader.injectable"; import { DiRender, renderFor } from "../../test-utils/renderFor"; +import extensionDiscoveryInjectable from "../../../../extensions/extension-discovery/extension-discovery.injectable"; +import directoryForUserDataInjectable from "../../../../common/app-paths/directory-for-user-data/directory-for-user-data.injectable"; +import directoryForDownloadsInjectable from "../../../../common/app-paths/directory-for-downloads/directory-for-downloads.injectable"; mockWindow(); @@ -58,40 +58,27 @@ jest.mock("../../../../common/utils/downloadFile", () => ({ jest.mock("../../../../common/utils/tar"); -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(), - }, -})); - -AppPaths.init(); - describe("Extensions", () => { let extensionLoader: ExtensionLoader; + let extensionDiscovery: ExtensionDiscovery; let render: DiRender; beforeEach(async () => { - const di = getDiForUnitTesting(); + const di = getDiForUnitTesting({ doGeneralOverrides: true }); - render = renderFor(di); - - extensionLoader = di.inject(extensionLoaderInjectable); + di.override(directoryForUserDataInjectable, () => "some-directory-for-user-data"); + di.override(directoryForDownloadsInjectable, () => "some-directory-for-downloads"); mockFs({ - "tmp": {}, + "some-directory-for-user-data": {}, }); - ExtensionInstallationStateStore.reset(); + await di.runSetups(); + + render = renderFor(di); + + extensionLoader = di.inject(extensionLoaderInjectable); + extensionDiscovery = di.inject(extensionDiscoveryInjectable); extensionLoader.addExtension({ id: "extensionId", @@ -106,8 +93,6 @@ describe("Extensions", () => { isCompatible: true, }); - const extensionDiscovery = ExtensionDiscovery.createInstance(extensionLoader); - extensionDiscovery.uninstallExtension = jest.fn(() => Promise.resolve()); UserStore.createInstance(); @@ -116,11 +101,10 @@ describe("Extensions", () => { afterEach(() => { mockFs.restore(); UserStore.resetInstance(); - ExtensionDiscovery.resetInstance(); }); it("disables uninstall and disable buttons while uninstalling", async () => { - ExtensionDiscovery.getInstance().isLoaded = true; + extensionDiscovery.isLoaded = true; const res = render(<>); const table = res.getByTestId("extensions-table"); @@ -137,7 +121,7 @@ describe("Extensions", () => { fireEvent.click(res.getByText("Yes")); await waitFor(() => { - expect(ExtensionDiscovery.getInstance().uninstallExtension).toHaveBeenCalled(); + expect(extensionDiscovery.uninstallExtension).toHaveBeenCalled(); fireEvent.click(menuTrigger); expect(res.getByText("Disable")).toHaveAttribute("aria-disabled", "true"); expect(res.getByText("Uninstall")).toHaveAttribute("aria-disabled", "true"); @@ -164,14 +148,14 @@ describe("Extensions", () => { }); it("displays spinner while extensions are loading", () => { - ExtensionDiscovery.getInstance().isLoaded = false; + extensionDiscovery.isLoaded = false; const { container } = render(); expect(container.querySelector(".Spinner")).toBeInTheDocument(); }); it("does not display the spinner while extensions are not loading", async () => { - ExtensionDiscovery.getInstance().isLoaded = true; + extensionDiscovery.isLoaded = true; const { container } = render(); expect(container.querySelector(".Spinner")).not.toBeInTheDocument(); diff --git a/src/renderer/components/+extensions/attempt-install-by-info/attempt-install-by-info.injectable.ts b/src/renderer/components/+extensions/attempt-install-by-info/attempt-install-by-info.injectable.ts index 8c601230f144..31a29e75c5dc 100644 --- a/src/renderer/components/+extensions/attempt-install-by-info/attempt-install-by-info.injectable.ts +++ b/src/renderer/components/+extensions/attempt-install-by-info/attempt-install-by-info.injectable.ts @@ -22,12 +22,15 @@ import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; import { attemptInstallByInfo } from "./attempt-install-by-info"; import attemptInstallInjectable from "../attempt-install/attempt-install.injectable"; import getBaseRegistryUrlInjectable from "../get-base-registry-url/get-base-registry-url.injectable"; +import extensionInstallationStateStoreInjectable + from "../../../../extensions/extension-installation-state-store/extension-installation-state-store.injectable"; const attemptInstallByInfoInjectable = getInjectable({ instantiate: (di) => attemptInstallByInfo({ attemptInstall: di.inject(attemptInstallInjectable), getBaseRegistryUrl: di.inject(getBaseRegistryUrlInjectable), + extensionInstallationStateStore: di.inject(extensionInstallationStateStoreInjectable), }), lifecycle: lifecycleEnum.singleton, diff --git a/src/renderer/components/+extensions/attempt-install-by-info/attempt-install-by-info.tsx b/src/renderer/components/+extensions/attempt-install-by-info/attempt-install-by-info.tsx index 076737c0fea5..58cc8c45ad75 100644 --- a/src/renderer/components/+extensions/attempt-install-by-info/attempt-install-by-info.tsx +++ b/src/renderer/components/+extensions/attempt-install-by-info/attempt-install-by-info.tsx @@ -18,7 +18,6 @@ * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import { ExtensionInstallationStateStore } from "../extension-install.store"; import { downloadFile, downloadJson, ExtendableDisposer } from "../../../../common/utils"; import { Notifications } from "../../notifications"; import { ConfirmDialog } from "../../confirm-dialog"; @@ -28,6 +27,7 @@ import { SemVer } from "semver"; import URLParse from "url-parse"; import type { InstallRequest } from "../attempt-install/install-request"; import lodash from "lodash"; +import type { ExtensionInstallationStateStore } from "../../../../extensions/extension-installation-state-store/extension-installation-state-store"; export interface ExtensionInfo { name: string; @@ -38,14 +38,15 @@ export interface ExtensionInfo { interface Dependencies { attemptInstall: (request: InstallRequest, d: ExtendableDisposer) => Promise; getBaseRegistryUrl: () => Promise; + extensionInstallationStateStore: ExtensionInstallationStateStore } -export const attemptInstallByInfo = ({ attemptInstall, getBaseRegistryUrl }: Dependencies) => async ({ +export const attemptInstallByInfo = ({ attemptInstall, getBaseRegistryUrl, extensionInstallationStateStore }: Dependencies) => async ({ name, version, requireConfirmation = false, }: ExtensionInfo) => { - const disposer = ExtensionInstallationStateStore.startPreInstall(); + const disposer = extensionInstallationStateStore.startPreInstall(); const baseUrl = await getBaseRegistryUrl(); const registryUrl = new URLParse(baseUrl).set("pathname", name).toString(); let json: any; diff --git a/src/renderer/components/+extensions/attempt-install/attempt-install.injectable.ts b/src/renderer/components/+extensions/attempt-install/attempt-install.injectable.ts index acf21a17e456..0be35338450d 100644 --- a/src/renderer/components/+extensions/attempt-install/attempt-install.injectable.ts +++ b/src/renderer/components/+extensions/attempt-install/attempt-install.injectable.ts @@ -23,6 +23,11 @@ import extensionLoaderInjectable from "../../../../extensions/extension-loader/e import uninstallExtensionInjectable from "../uninstall-extension/uninstall-extension.injectable"; import { attemptInstall } from "./attempt-install"; import unpackExtensionInjectable from "./unpack-extension/unpack-extension.injectable"; +import getExtensionDestFolderInjectable + from "./get-extension-dest-folder/get-extension-dest-folder.injectable"; +import createTempFilesAndValidateInjectable from "./create-temp-files-and-validate/create-temp-files-and-validate.injectable"; +import extensionInstallationStateStoreInjectable + from "../../../../extensions/extension-installation-state-store/extension-installation-state-store.injectable"; const attemptInstallInjectable = getInjectable({ instantiate: (di) => @@ -30,6 +35,9 @@ const attemptInstallInjectable = getInjectable({ extensionLoader: di.inject(extensionLoaderInjectable), uninstallExtension: di.inject(uninstallExtensionInjectable), unpackExtension: di.inject(unpackExtensionInjectable), + createTempFilesAndValidate: di.inject(createTempFilesAndValidateInjectable), + getExtensionDestFolder: di.inject(getExtensionDestFolderInjectable), + extensionInstallationStateStore: di.inject(extensionInstallationStateStoreInjectable), }), lifecycle: lifecycleEnum.singleton, diff --git a/src/renderer/components/+extensions/attempt-install/attempt-install.tsx b/src/renderer/components/+extensions/attempt-install/attempt-install.tsx index f8aba4e5a9b2..d4ec51f461b8 100644 --- a/src/renderer/components/+extensions/attempt-install/attempt-install.tsx +++ b/src/renderer/components/+extensions/attempt-install/attempt-install.tsx @@ -23,10 +23,6 @@ import { disposer, ExtendableDisposer, } from "../../../../common/utils"; -import { - ExtensionInstallationState, - ExtensionInstallationStateStore, -} from "../extension-install.store"; import { Notifications } from "../../notifications"; import { Button } from "../../button"; import type { ExtensionLoader } from "../../../../extensions/extension-loader"; @@ -34,27 +30,43 @@ import type { LensExtensionId } from "../../../../extensions/lens-extension"; import React from "react"; import fse from "fs-extra"; import { shell } from "electron"; -import { - createTempFilesAndValidate, - InstallRequestValidated, -} from "./create-temp-files-and-validate/create-temp-files-and-validate"; -import { getExtensionDestFolder } from "./get-extension-dest-folder/get-extension-dest-folder"; +import type { InstallRequestValidated } from "./create-temp-files-and-validate/create-temp-files-and-validate"; import type { InstallRequest } from "./install-request"; +import { + ExtensionInstallationState, + ExtensionInstallationStateStore, +} from "../../../../extensions/extension-installation-state-store/extension-installation-state-store"; interface Dependencies { extensionLoader: ExtensionLoader; uninstallExtension: (id: LensExtensionId) => Promise; + unpackExtension: ( request: InstallRequestValidated, disposeDownloading: Disposer, ) => Promise; + + createTempFilesAndValidate: ( + installRequest: InstallRequest, + ) => Promise; + + getExtensionDestFolder: (name: string) => string + + extensionInstallationStateStore: ExtensionInstallationStateStore } export const attemptInstall = - ({ extensionLoader, uninstallExtension, unpackExtension }: Dependencies) => + ({ + extensionLoader, + uninstallExtension, + unpackExtension, + createTempFilesAndValidate, + getExtensionDestFolder, + extensionInstallationStateStore, + }: Dependencies) => async (request: InstallRequest, d?: ExtendableDisposer): Promise => { const dispose = disposer( - ExtensionInstallationStateStore.startPreInstall(), + extensionInstallationStateStore.startPreInstall(), d, ); @@ -65,7 +77,7 @@ export const attemptInstall = } const { name, version, description } = validatedRequest.manifest; - const curState = ExtensionInstallationStateStore.getInstallationState( + const curState = extensionInstallationStateStore.getInstallationState( validatedRequest.id, ); diff --git a/src/renderer/components/+extensions/attempt-install/create-temp-files-and-validate/create-temp-files-and-validate.injectable.tsx b/src/renderer/components/+extensions/attempt-install/create-temp-files-and-validate/create-temp-files-and-validate.injectable.tsx new file mode 100644 index 000000000000..7f5484822e12 --- /dev/null +++ b/src/renderer/components/+extensions/attempt-install/create-temp-files-and-validate/create-temp-files-and-validate.injectable.tsx @@ -0,0 +1,34 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { createTempFilesAndValidate } from "./create-temp-files-and-validate"; +import extensionDiscoveryInjectable from "../../../../../extensions/extension-discovery/extension-discovery.injectable"; + +const createTempFilesAndValidateInjectable = getInjectable({ + instantiate: (di) => + createTempFilesAndValidate({ + extensionDiscovery: di.inject(extensionDiscoveryInjectable), + }), + + lifecycle: lifecycleEnum.singleton, +}); + +export default createTempFilesAndValidateInjectable; diff --git a/src/renderer/components/+extensions/attempt-install/create-temp-files-and-validate/create-temp-files-and-validate.tsx b/src/renderer/components/+extensions/attempt-install/create-temp-files-and-validate/create-temp-files-and-validate.tsx index e692ace784e5..76481e9dac0d 100644 --- a/src/renderer/components/+extensions/attempt-install/create-temp-files-and-validate/create-temp-files-and-validate.tsx +++ b/src/renderer/components/+extensions/attempt-install/create-temp-files-and-validate/create-temp-files-and-validate.tsx @@ -19,7 +19,7 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ import { validatePackage } from "../validate-package/validate-package"; -import { ExtensionDiscovery } from "../../../../../extensions/extension-discovery"; +import type { ExtensionDiscovery } from "../../../../../extensions/extension-discovery/extension-discovery"; import { getMessageFromError } from "../../get-message-from-error/get-message-from-error"; import logger from "../../../../../main/logger"; import { Notifications } from "../../../notifications"; @@ -41,59 +41,65 @@ export interface InstallRequestValidated { tempFile: string; // temp system path to packed extension for unpacking } -export async function createTempFilesAndValidate({ - fileName, - dataP, -}: InstallRequest): Promise { - // copy files to temp - await fse.ensureDir(getExtensionPackageTemp()); +interface Dependencies { + extensionDiscovery: ExtensionDiscovery +} - // validate packages - const tempFile = getExtensionPackageTemp(fileName); +export const createTempFilesAndValidate = + ({ extensionDiscovery }: Dependencies) => + async ({ + fileName, + dataP, + }: InstallRequest): Promise => { + // copy files to temp + await fse.ensureDir(getExtensionPackageTemp()); - try { - const data = await dataP; + // validate packages + const tempFile = getExtensionPackageTemp(fileName); - if (!data) { - return null; - } + try { + const data = await dataP; - await fse.writeFile(tempFile, data); - const manifest = await validatePackage(tempFile); - const id = path.join( - ExtensionDiscovery.getInstance().nodeModulesPath, - manifest.name, - "package.json", - ); + if (!data) { + return null; + } - return { - fileName, - data, - manifest, - tempFile, - id, - }; - } catch (error) { - const message = getMessageFromError(error); + await fse.writeFile(tempFile, data); + const manifest = await validatePackage(tempFile); + const id = path.join( + extensionDiscovery.nodeModulesPath, + manifest.name, + "package.json", + ); - logger.info( - `[EXTENSION-INSTALLATION]: installing ${fileName} has failed: ${message}`, - { error }, - ); - Notifications.error( -
-

- Installing {fileName} has failed, skipping. -

-

- Reason: {message} -

-
, - ); - } + return { + fileName, + data, + manifest, + tempFile, + id, + }; + } catch (error) { + const message = getMessageFromError(error); - return null; -} + logger.info( + `[EXTENSION-INSTALLATION]: installing ${fileName} has failed: ${message}`, + { error }, + ); + Notifications.error( +
+

+ Installing {fileName} has failed, skipping. +

+

+ Reason: {message} +

+
, + ); + } + + return null; + }; function getExtensionPackageTemp(fileName = "") { diff --git a/src/renderer/components/+extensions/attempt-install/get-extension-dest-folder/get-extension-dest-folder.injectable.ts b/src/renderer/components/+extensions/attempt-install/get-extension-dest-folder/get-extension-dest-folder.injectable.ts new file mode 100644 index 000000000000..d589eda5e3e8 --- /dev/null +++ b/src/renderer/components/+extensions/attempt-install/get-extension-dest-folder/get-extension-dest-folder.injectable.ts @@ -0,0 +1,36 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; + +import extensionDiscoveryInjectable from "../../../../../extensions/extension-discovery/extension-discovery.injectable"; + +import { getExtensionDestFolder } from "./get-extension-dest-folder"; + +const getExtensionDestFolderInjectable = getInjectable({ + instantiate: (di) => + getExtensionDestFolder({ + extensionDiscovery: di.inject(extensionDiscoveryInjectable), + }), + + lifecycle: lifecycleEnum.singleton, +}); + +export default getExtensionDestFolderInjectable; diff --git a/src/renderer/components/+extensions/attempt-install/get-extension-dest-folder/get-extension-dest-folder.tsx b/src/renderer/components/+extensions/attempt-install/get-extension-dest-folder/get-extension-dest-folder.ts similarity index 77% rename from src/renderer/components/+extensions/attempt-install/get-extension-dest-folder/get-extension-dest-folder.tsx rename to src/renderer/components/+extensions/attempt-install/get-extension-dest-folder/get-extension-dest-folder.ts index 141b6d54ecfa..526531ac15f9 100644 --- a/src/renderer/components/+extensions/attempt-install/get-extension-dest-folder/get-extension-dest-folder.tsx +++ b/src/renderer/components/+extensions/attempt-install/get-extension-dest-folder/get-extension-dest-folder.ts @@ -18,11 +18,15 @@ * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import { ExtensionDiscovery } from "../../../../../extensions/extension-discovery"; +import type { ExtensionDiscovery } from "../../../../../extensions/extension-discovery/extension-discovery"; import { sanitizeExtensionName } from "../../../../../extensions/lens-extension"; import path from "path"; -export const getExtensionDestFolder = (name: string) => path.join( - ExtensionDiscovery.getInstance().localFolderPath, - sanitizeExtensionName(name), -); +interface Dependencies { + extensionDiscovery: ExtensionDiscovery; +} + +export const getExtensionDestFolder = + ({ extensionDiscovery }: Dependencies) => + (name: string) => + path.join(extensionDiscovery.localFolderPath, sanitizeExtensionName(name)); diff --git a/src/renderer/components/+extensions/attempt-install/unpack-extension/unpack-extension.injectable.tsx b/src/renderer/components/+extensions/attempt-install/unpack-extension/unpack-extension.injectable.tsx index 054adf45adba..80fe1097d144 100644 --- a/src/renderer/components/+extensions/attempt-install/unpack-extension/unpack-extension.injectable.tsx +++ b/src/renderer/components/+extensions/attempt-install/unpack-extension/unpack-extension.injectable.tsx @@ -21,11 +21,17 @@ import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; import { unpackExtension } from "./unpack-extension"; import extensionLoaderInjectable from "../../../../../extensions/extension-loader/extension-loader.injectable"; +import getExtensionDestFolderInjectable + from "../get-extension-dest-folder/get-extension-dest-folder.injectable"; +import extensionInstallationStateStoreInjectable + from "../../../../../extensions/extension-installation-state-store/extension-installation-state-store.injectable"; const unpackExtensionInjectable = getInjectable({ instantiate: (di) => unpackExtension({ extensionLoader: di.inject(extensionLoaderInjectable), + getExtensionDestFolder: di.inject(getExtensionDestFolderInjectable), + extensionInstallationStateStore: di.inject(extensionInstallationStateStoreInjectable), }), lifecycle: lifecycleEnum.singleton, diff --git a/src/renderer/components/+extensions/attempt-install/unpack-extension/unpack-extension.tsx b/src/renderer/components/+extensions/attempt-install/unpack-extension/unpack-extension.tsx index 73e9f1532843..c23c9cccc1ca 100644 --- a/src/renderer/components/+extensions/attempt-install/unpack-extension/unpack-extension.tsx +++ b/src/renderer/components/+extensions/attempt-install/unpack-extension/unpack-extension.tsx @@ -20,93 +20,98 @@ */ import type { InstallRequestValidated } from "../create-temp-files-and-validate/create-temp-files-and-validate"; import { Disposer, extractTar, noop } from "../../../../../common/utils"; -import { ExtensionInstallationStateStore } from "../../extension-install.store"; import { extensionDisplayName } from "../../../../../extensions/lens-extension"; import logger from "../../../../../main/logger"; import type { ExtensionLoader } from "../../../../../extensions/extension-loader"; import { Notifications } from "../../../notifications"; import { getMessageFromError } from "../../get-message-from-error/get-message-from-error"; -import { getExtensionDestFolder } from "../get-extension-dest-folder/get-extension-dest-folder"; import path from "path"; import fse from "fs-extra"; import { when } from "mobx"; import React from "react"; +import type { ExtensionInstallationStateStore } from "../../../../../extensions/extension-installation-state-store/extension-installation-state-store"; interface Dependencies { extensionLoader: ExtensionLoader + getExtensionDestFolder: (name: string) => string + extensionInstallationStateStore: ExtensionInstallationStateStore } -export const unpackExtension = ({ extensionLoader }: Dependencies) => async ( - request: InstallRequestValidated, - disposeDownloading?: Disposer, -) => { - const { - id, - fileName, - tempFile, - manifest: { name, version }, - } = request; +export const unpackExtension = + ({ + extensionLoader, + getExtensionDestFolder, + extensionInstallationStateStore, + }: Dependencies) => + async (request: InstallRequestValidated, disposeDownloading?: Disposer) => { + const { + id, + fileName, + tempFile, + manifest: { name, version }, + } = request; - ExtensionInstallationStateStore.setInstalling(id); - disposeDownloading?.(); + extensionInstallationStateStore.setInstalling(id); + disposeDownloading?.(); - const displayName = extensionDisplayName(name, version); - const extensionFolder = getExtensionDestFolder(name); - const unpackingTempFolder = path.join( - path.dirname(tempFile), - `${path.basename(tempFile)}-unpacked`, - ); + const displayName = extensionDisplayName(name, version); + const extensionFolder = getExtensionDestFolder(name); + const unpackingTempFolder = path.join( + path.dirname(tempFile), + `${path.basename(tempFile)}-unpacked`, + ); - logger.info(`Unpacking extension ${displayName}`, { fileName, tempFile }); + logger.info(`Unpacking extension ${displayName}`, { fileName, tempFile }); - try { - // extract to temp folder first - await fse.remove(unpackingTempFolder).catch(noop); - await fse.ensureDir(unpackingTempFolder); - await extractTar(tempFile, { cwd: unpackingTempFolder }); + try { + // extract to temp folder first + await fse.remove(unpackingTempFolder).catch(noop); + await fse.ensureDir(unpackingTempFolder); + await extractTar(tempFile, { cwd: unpackingTempFolder }); - // move contents to extensions folder - const unpackedFiles = await fse.readdir(unpackingTempFolder); - let unpackedRootFolder = unpackingTempFolder; + // move contents to extensions folder + const unpackedFiles = await fse.readdir(unpackingTempFolder); + let unpackedRootFolder = unpackingTempFolder; - if (unpackedFiles.length === 1) { - // check if %extension.tgz was packed with single top folder, - // e.g. "npm pack %ext_name" downloads file with "package" root folder within tarball - unpackedRootFolder = path.join(unpackingTempFolder, unpackedFiles[0]); - } + if (unpackedFiles.length === 1) { + // check if %extension.tgz was packed with single top folder, + // e.g. "npm pack %ext_name" downloads file with "package" root folder within tarball + unpackedRootFolder = path.join(unpackingTempFolder, unpackedFiles[0]); + } - await fse.ensureDir(extensionFolder); - await fse.move(unpackedRootFolder, extensionFolder, { overwrite: true }); + await fse.ensureDir(extensionFolder); + await fse.move(unpackedRootFolder, extensionFolder, { overwrite: true }); - // wait for the loader has actually install it - await when(() => extensionLoader.userExtensions.has(id)); + // wait for the loader has actually install it + await when(() => extensionLoader.userExtensions.has(id)); - // Enable installed extensions by default. - extensionLoader.setIsEnabled(id, true); + // Enable installed extensions by default. + extensionLoader.setIsEnabled(id, true); - Notifications.ok( -

- Extension {displayName} successfully installed! -

, - ); - } catch (error) { - const message = getMessageFromError(error); + Notifications.ok( +

+ Extension {displayName} successfully installed! +

, + ); + } catch (error) { + const message = getMessageFromError(error); - logger.info( - `[EXTENSION-INSTALLATION]: installing ${request.fileName} has failed: ${message}`, - { error }, - ); - Notifications.error( -

- Installing extension {displayName} has failed: {message} -

, - ); - } finally { - // Remove install state once finished - ExtensionInstallationStateStore.clearInstalling(id); + logger.info( + `[EXTENSION-INSTALLATION]: installing ${request.fileName} has failed: ${message}`, + { error }, + ); + Notifications.error( +

+ Installing extension {displayName} has failed:{" "} + {message} +

, + ); + } finally { + // Remove install state once finished + extensionInstallationStateStore.clearInstalling(id); - // clean up - fse.remove(unpackingTempFolder).catch(noop); - fse.unlink(tempFile).catch(noop); - } -}; + // clean up + fse.remove(unpackingTempFolder).catch(noop); + fse.unlink(tempFile).catch(noop); + } + }; diff --git a/src/renderer/components/+extensions/attempt-install/validate-package/validate-package.tsx b/src/renderer/components/+extensions/attempt-install/validate-package/validate-package.tsx index d50bd7fb9a6a..1aba57846695 100644 --- a/src/renderer/components/+extensions/attempt-install/validate-package/validate-package.tsx +++ b/src/renderer/components/+extensions/attempt-install/validate-package/validate-package.tsx @@ -20,7 +20,7 @@ */ import type { LensExtensionManifest } from "../../../../../extensions/lens-extension"; import { listTarEntries, readFileFromTar } from "../../../../../common/utils"; -import { manifestFilename } from "../../../../../extensions/extension-discovery"; +import { manifestFilename } from "../../../../../extensions/extension-discovery/extension-discovery"; import path from "path"; export const validatePackage = async ( diff --git a/src/renderer/components/+extensions/confirm-uninstall-extension/confirm-uninstall-extension.tsx b/src/renderer/components/+extensions/confirm-uninstall-extension/confirm-uninstall-extension.tsx index fbf1670e294e..2c67e1f2afcf 100644 --- a/src/renderer/components/+extensions/confirm-uninstall-extension/confirm-uninstall-extension.tsx +++ b/src/renderer/components/+extensions/confirm-uninstall-extension/confirm-uninstall-extension.tsx @@ -19,7 +19,7 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ import React from "react"; -import type { InstalledExtension } from "../../../../extensions/extension-discovery"; +import type { InstalledExtension } from "../../../../extensions/extension-discovery/extension-discovery"; import type { LensExtensionId } from "../../../../extensions/lens-extension"; import { extensionDisplayName } from "../../../../extensions/lens-extension"; import { ConfirmDialog } from "../../confirm-dialog"; diff --git a/src/renderer/components/+extensions/extension-install.store.ts b/src/renderer/components/+extensions/extension-install.store.ts deleted file mode 100644 index 4ca79ec337f8..000000000000 --- a/src/renderer/components/+extensions/extension-install.store.ts +++ /dev/null @@ -1,254 +0,0 @@ -/** - * Copyright (c) 2021 OpenLens Authors - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to - * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of - * the Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER - * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN - * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ - -import { action, computed, observable } from "mobx"; -import logger from "../../../main/logger"; -import { disposer } from "../../utils"; -import type { ExtendableDisposer } from "../../utils"; -import * as uuid from "uuid"; -import { broadcastMessage } from "../../../common/ipc"; -import { ipcRenderer } from "electron"; - -export enum ExtensionInstallationState { - INSTALLING = "installing", - UNINSTALLING = "uninstalling", - IDLE = "idle", -} - -const Prefix = "[ExtensionInstallationStore]"; - -export class ExtensionInstallationStateStore { - private static InstallingFromMainChannel = "extension-installation-state-store:install"; - private static ClearInstallingFromMainChannel = "extension-installation-state-store:clear-install"; - private static PreInstallIds = observable.set(); - private static UninstallingExtensions = observable.set(); - private static InstallingExtensions = observable.set(); - - static bindIpcListeners() { - ipcRenderer - .on(ExtensionInstallationStateStore.InstallingFromMainChannel, (event, extId) => { - ExtensionInstallationStateStore.setInstalling(extId); - }) - .on(ExtensionInstallationStateStore.ClearInstallingFromMainChannel, (event, extId) => { - ExtensionInstallationStateStore.clearInstalling(extId); - }); - } - - @action static reset() { - logger.warn(`${Prefix}: resetting, may throw errors`); - ExtensionInstallationStateStore.InstallingExtensions.clear(); - ExtensionInstallationStateStore.UninstallingExtensions.clear(); - ExtensionInstallationStateStore.PreInstallIds.clear(); - } - - /** - * Strictly transitions an extension from not installing to installing - * @param extId the ID of the extension - * @throws if state is not IDLE - */ - @action static setInstalling(extId: string): void { - logger.debug(`${Prefix}: trying to set ${extId} as installing`); - - const curState = ExtensionInstallationStateStore.getInstallationState(extId); - - if (curState !== ExtensionInstallationState.IDLE) { - throw new Error(`${Prefix}: cannot set ${extId} as installing. Is currently ${curState}.`); - } - - ExtensionInstallationStateStore.InstallingExtensions.add(extId); - } - - /** - * Broadcasts that an extension is being installed by the main process - * @param extId the ID of the extension - */ - static setInstallingFromMain(extId: string): void { - broadcastMessage(ExtensionInstallationStateStore.InstallingFromMainChannel, extId); - } - - /** - * Broadcasts that an extension is no longer being installed by the main process - * @param extId the ID of the extension - */ - static clearInstallingFromMain(extId: string): void { - broadcastMessage(ExtensionInstallationStateStore.ClearInstallingFromMainChannel, extId); - } - - /** - * Marks the start of a pre-install phase of an extension installation. The - * part of the installation before the tarball has been unpacked and the ID - * determined. - * @returns a disposer which should be called to mark the end of the install phase - */ - @action static startPreInstall(): ExtendableDisposer { - const preInstallStepId = uuid.v4(); - - logger.debug(`${Prefix}: starting a new preinstall phase: ${preInstallStepId}`); - ExtensionInstallationStateStore.PreInstallIds.add(preInstallStepId); - - return disposer(() => { - ExtensionInstallationStateStore.PreInstallIds.delete(preInstallStepId); - logger.debug(`${Prefix}: ending a preinstall phase: ${preInstallStepId}`); - }); - } - - /** - * Strictly transitions an extension from not uninstalling to uninstalling - * @param extId the ID of the extension - * @throws if state is not IDLE - */ - @action static setUninstalling(extId: string): void { - logger.debug(`${Prefix}: trying to set ${extId} as uninstalling`); - - const curState = ExtensionInstallationStateStore.getInstallationState(extId); - - if (curState !== ExtensionInstallationState.IDLE) { - throw new Error(`${Prefix}: cannot set ${extId} as uninstalling. Is currently ${curState}.`); - } - - ExtensionInstallationStateStore.UninstallingExtensions.add(extId); - } - - /** - * Strictly clears the INSTALLING state of an extension - * @param extId The ID of the extension - * @throws if state is not INSTALLING - */ - @action static clearInstalling(extId: string): void { - logger.debug(`${Prefix}: trying to clear ${extId} as installing`); - - const curState = ExtensionInstallationStateStore.getInstallationState(extId); - - switch (curState) { - case ExtensionInstallationState.INSTALLING: - return void ExtensionInstallationStateStore.InstallingExtensions.delete(extId); - default: - throw new Error(`${Prefix}: cannot clear INSTALLING state for ${extId}, it is currently ${curState}`); - } - } - - /** - * Strictly clears the UNINSTALLING state of an extension - * @param extId The ID of the extension - * @throws if state is not UNINSTALLING - */ - @action static clearUninstalling(extId: string): void { - logger.debug(`${Prefix}: trying to clear ${extId} as uninstalling`); - - const curState = ExtensionInstallationStateStore.getInstallationState(extId); - - switch (curState) { - case ExtensionInstallationState.UNINSTALLING: - return void ExtensionInstallationStateStore.UninstallingExtensions.delete(extId); - default: - throw new Error(`${Prefix}: cannot clear UNINSTALLING state for ${extId}, it is currently ${curState}`); - } - } - - /** - * Returns the current state of the extension. IDLE is default value. - * @param extId The ID of the extension - */ - static getInstallationState(extId: string): ExtensionInstallationState { - if (ExtensionInstallationStateStore.InstallingExtensions.has(extId)) { - return ExtensionInstallationState.INSTALLING; - } - - if (ExtensionInstallationStateStore.UninstallingExtensions.has(extId)) { - return ExtensionInstallationState.UNINSTALLING; - } - - return ExtensionInstallationState.IDLE; - } - - /** - * Returns true if the extension is currently INSTALLING - * @param extId The ID of the extension - */ - static isExtensionInstalling(extId: string): boolean { - return ExtensionInstallationStateStore.getInstallationState(extId) === ExtensionInstallationState.INSTALLING; - } - - /** - * Returns true if the extension is currently UNINSTALLING - * @param extId The ID of the extension - */ - static isExtensionUninstalling(extId: string): boolean { - return ExtensionInstallationStateStore.getInstallationState(extId) === ExtensionInstallationState.UNINSTALLING; - } - - /** - * Returns true if the extension is currently IDLE - * @param extId The ID of the extension - */ - static isExtensionIdle(extId: string): boolean { - return ExtensionInstallationStateStore.getInstallationState(extId) === ExtensionInstallationState.IDLE; - } - - /** - * The current number of extensions installing - */ - @computed static get installing(): number { - return ExtensionInstallationStateStore.InstallingExtensions.size; - } - - /** - * The current number of extensions uninstalling - */ - static get uninstalling(): number { - return ExtensionInstallationStateStore.UninstallingExtensions.size; - } - - /** - * If there is at least one extension currently installing - */ - static get anyInstalling(): boolean { - return ExtensionInstallationStateStore.installing > 0; - } - - /** - * If there is at least one extension currently uninstalling - */ - static get anyUninstalling(): boolean { - return ExtensionInstallationStateStore.uninstalling > 0; - } - - /** - * The current number of extensions preinstalling - */ - static get preinstalling(): number { - return ExtensionInstallationStateStore.PreInstallIds.size; - } - - /** - * If there is at least one extension currently downloading - */ - static get anyPreinstalling(): boolean { - return ExtensionInstallationStateStore.preinstalling > 0; - } - - /** - * If there is at least one installing or preinstalling step taking place - */ - static get anyPreInstallingOrInstalling(): boolean { - return ExtensionInstallationStateStore.anyInstalling || ExtensionInstallationStateStore.anyPreinstalling; - } -} diff --git a/src/renderer/components/+extensions/extensions.tsx b/src/renderer/components/+extensions/extensions.tsx index 0ba9deba654a..80d7449eeb1e 100644 --- a/src/renderer/components/+extensions/extensions.tsx +++ b/src/renderer/components/+extensions/extensions.tsx @@ -29,9 +29,8 @@ import { } from "mobx"; import { disposeOnUnmount, observer } from "mobx-react"; import React from "react"; -import type { InstalledExtension } from "../../../extensions/extension-discovery"; +import type { InstalledExtension } from "../../../extensions/extension-discovery/extension-discovery"; import { DropFileInput } from "../input"; -import { ExtensionInstallationStateStore } from "./extension-install.store"; import { Install } from "./install"; import { InstalledExtensions } from "./installed-extensions"; import { Notice } from "./notice"; @@ -48,6 +47,9 @@ import installFromSelectFileDialogInjectable from "./install-from-select-file-di import type { LensExtensionId } from "../../../extensions/lens-extension"; import installOnDropInjectable from "./install-on-drop/install-on-drop.injectable"; import { supportedExtensionFormats } from "./supported-extension-formats"; +import extensionInstallationStateStoreInjectable + from "../../../extensions/extension-installation-state-store/extension-installation-state-store.injectable"; +import type { ExtensionInstallationStateStore } from "../../../extensions/extension-installation-state-store/extension-installation-state-store"; interface Dependencies { userExtensions: IComputedValue; @@ -57,6 +59,7 @@ interface Dependencies { installFromInput: (input: string) => Promise; installFromSelectFileDialog: () => Promise; installOnDrop: (files: File[]) => Promise; + extensionInstallationStateStore: ExtensionInstallationStateStore } @observer @@ -73,7 +76,7 @@ class NonInjectedExtensions extends React.Component { reaction(() => this.props.userExtensions.get().length, (curSize, prevSize) => { if (curSize > prevSize) { disposeOnUnmount(this, [ - when(() => !ExtensionInstallationStateStore.anyInstalling, () => this.installPath = ""), + when(() => !this.props.extensionInstallationStateStore.anyInstalling, () => this.installPath = ""), ]); } }), @@ -134,6 +137,8 @@ export const Extensions = withInjectables( installFromSelectFileDialog: di.inject( installFromSelectFileDialogInjectable, ), + + extensionInstallationStateStore: di.inject(extensionInstallationStateStoreInjectable), }), }, ); diff --git a/src/renderer/components/+extensions/install-from-input/install-from-input.injectable.ts b/src/renderer/components/+extensions/install-from-input/install-from-input.injectable.ts index 2d0f26d78638..ed4e7142728b 100644 --- a/src/renderer/components/+extensions/install-from-input/install-from-input.injectable.ts +++ b/src/renderer/components/+extensions/install-from-input/install-from-input.injectable.ts @@ -22,12 +22,15 @@ import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; import attemptInstallInjectable from "../attempt-install/attempt-install.injectable"; import { installFromInput } from "./install-from-input"; import attemptInstallByInfoInjectable from "../attempt-install-by-info/attempt-install-by-info.injectable"; +import extensionInstallationStateStoreInjectable + from "../../../../extensions/extension-installation-state-store/extension-installation-state-store.injectable"; const installFromInputInjectable = getInjectable({ instantiate: (di) => installFromInput({ attemptInstall: di.inject(attemptInstallInjectable), attemptInstallByInfo: di.inject(attemptInstallByInfoInjectable), + extensionInstallationStateStore: di.inject(extensionInstallationStateStoreInjectable), }), lifecycle: lifecycleEnum.singleton, diff --git a/src/renderer/components/+extensions/install-from-input/install-from-input.tsx b/src/renderer/components/+extensions/install-from-input/install-from-input.tsx index 05ad14b087f5..e37f184af6e8 100644 --- a/src/renderer/components/+extensions/install-from-input/install-from-input.tsx +++ b/src/renderer/components/+extensions/install-from-input/install-from-input.tsx @@ -20,7 +20,6 @@ */ import { downloadFile, ExtendableDisposer } from "../../../../common/utils"; import { InputValidators } from "../../input"; -import { ExtensionInstallationStateStore } from "../extension-install.store"; import { getMessageFromError } from "../get-message-from-error/get-message-from-error"; import logger from "../../../../main/logger"; import { Notifications } from "../../notifications"; @@ -29,20 +28,22 @@ import React from "react"; import { readFileNotify } from "../read-file-notify/read-file-notify"; import type { InstallRequest } from "../attempt-install/install-request"; import type { ExtensionInfo } from "../attempt-install-by-info/attempt-install-by-info"; +import type { ExtensionInstallationStateStore } from "../../../../extensions/extension-installation-state-store/extension-installation-state-store"; interface Dependencies { attemptInstall: (request: InstallRequest, disposer?: ExtendableDisposer) => Promise, - attemptInstallByInfo: (extensionInfo: ExtensionInfo) => Promise + attemptInstallByInfo: (extensionInfo: ExtensionInfo) => Promise, + extensionInstallationStateStore: ExtensionInstallationStateStore } -export const installFromInput = ({ attemptInstall, attemptInstallByInfo }: Dependencies) => async (input: string) => { +export const installFromInput = ({ attemptInstall, attemptInstallByInfo, extensionInstallationStateStore }: Dependencies) => async (input: string) => { let disposer: ExtendableDisposer | undefined = undefined; try { // fixme: improve error messages for non-tar-file URLs if (InputValidators.isUrl.validate(input)) { // install via url - disposer = ExtensionInstallationStateStore.startPreInstall(); + disposer = extensionInstallationStateStore.startPreInstall(); const { promise } = downloadFile({ url: input, timeout: 10 * 60 * 1000 }); const fileName = path.basename(input); diff --git a/src/renderer/components/+extensions/install-from-select-file-dialog/install-from-select-file-dialog.injectable.ts b/src/renderer/components/+extensions/install-from-select-file-dialog/install-from-select-file-dialog.injectable.ts index 34af624c4604..84fe0475766a 100644 --- a/src/renderer/components/+extensions/install-from-select-file-dialog/install-from-select-file-dialog.injectable.ts +++ b/src/renderer/components/+extensions/install-from-select-file-dialog/install-from-select-file-dialog.injectable.ts @@ -21,11 +21,13 @@ import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; import { installFromSelectFileDialog } from "./install-from-select-file-dialog"; import attemptInstallsInjectable from "../attempt-installs/attempt-installs.injectable"; +import directoryForDownloadsInjectable from "../../../../common/app-paths/directory-for-downloads/directory-for-downloads.injectable"; const installFromSelectFileDialogInjectable = getInjectable({ instantiate: (di) => installFromSelectFileDialog({ attemptInstalls: di.inject(attemptInstallsInjectable), + directoryForDownloads: di.inject(directoryForDownloadsInjectable), }), lifecycle: lifecycleEnum.singleton, diff --git a/src/renderer/components/+extensions/install-from-select-file-dialog/install-from-select-file-dialog.ts b/src/renderer/components/+extensions/install-from-select-file-dialog/install-from-select-file-dialog.ts index bb8e12e17e93..d19efed9451a 100644 --- a/src/renderer/components/+extensions/install-from-select-file-dialog/install-from-select-file-dialog.ts +++ b/src/renderer/components/+extensions/install-from-select-file-dialog/install-from-select-file-dialog.ts @@ -19,18 +19,18 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ import { dialog } from "../../../remote-helpers"; -import { AppPaths } from "../../../../common/app-paths"; import { supportedExtensionFormats } from "../supported-extension-formats"; interface Dependencies { attemptInstalls: (filePaths: string[]) => Promise + directoryForDownloads: string } export const installFromSelectFileDialog = - ({ attemptInstalls }: Dependencies) => + ({ attemptInstalls, directoryForDownloads }: Dependencies) => async () => { const { canceled, filePaths } = await dialog.showOpenDialog({ - defaultPath: AppPaths.get("downloads"), + defaultPath: directoryForDownloads, properties: ["openFile", "multiSelections"], message: `Select extensions to install (formats: ${supportedExtensionFormats.join( ", ", diff --git a/src/renderer/components/+extensions/install.tsx b/src/renderer/components/+extensions/install.tsx index 2f81d7e95015..395e03ec16ef 100644 --- a/src/renderer/components/+extensions/install.tsx +++ b/src/renderer/components/+extensions/install.tsx @@ -24,11 +24,14 @@ import React from "react"; import { prevDefault } from "../../utils"; import { Button } from "../button"; import { Icon } from "../icon"; +import { observer } from "mobx-react"; import { Input, InputValidator, InputValidators } from "../input"; import { SubTitle } from "../layout/sub-title"; import { TooltipPosition } from "../tooltip"; -import { ExtensionInstallationStateStore } from "./extension-install.store"; -import { observer } from "mobx-react"; +import type { ExtensionInstallationStateStore } from "../../../extensions/extension-installation-state-store/extension-installation-state-store"; +import extensionInstallationStateStoreInjectable + from "../../../extensions/extension-installation-state-store/extension-installation-state-store.injectable"; +import { withInjectables } from "@ogre-tools/injectable-react"; interface Props { installPath: string; @@ -38,6 +41,10 @@ interface Props { installFromSelectFileDialog: () => void; } +interface Dependencies { + extensionInstallationStateStore: ExtensionInstallationStateStore; +} + const installInputValidators = [ InputValidators.isUrl, InputValidators.isPath, @@ -51,49 +58,72 @@ const installInputValidator: InputValidator = { ), }; -export const Install = observer((props: Props) => { - const { installPath, supportedFormats, onChange, installFromInput, installFromSelectFileDialog } = props; - - return ( -
- -
-
- - } - /> -
-
-
+const NonInjectedInstall: React.FC = ({ + installPath, + supportedFormats, + onChange, + installFromInput, + installFromSelectFileDialog, + extensionInstallationStateStore, +}) => ( +
+ +
+
+ + } + /> +
+
+
- - Pro-Tip: you can drag-n-drop tarball-file to this area - -
- ); -}); +
+ + Pro-Tip: you can drag-n-drop tarball-file to this area + +
+); + +export const Install = withInjectables( + observer(NonInjectedInstall), + { + getProps: (di, props) => ({ + extensionInstallationStateStore: di.inject( + extensionInstallationStateStoreInjectable, + ), + ...props, + }), + }, +); diff --git a/src/renderer/components/+extensions/installed-extensions.tsx b/src/renderer/components/+extensions/installed-extensions.tsx index 83c12b8603c6..dcc449afff23 100644 --- a/src/renderer/components/+extensions/installed-extensions.tsx +++ b/src/renderer/components/+extensions/installed-extensions.tsx @@ -21,16 +21,25 @@ import styles from "./installed-extensions.module.scss"; import React, { useMemo } from "react"; -import { ExtensionDiscovery, InstalledExtension } from "../../../extensions/extension-discovery"; +import type { + ExtensionDiscovery, + InstalledExtension, +} from "../../../extensions/extension-discovery/extension-discovery"; import { Icon } from "../icon"; import { List } from "../list/list"; import { MenuActions, MenuItem } from "../menu"; import { Spinner } from "../spinner"; -import { ExtensionInstallationStateStore } from "./extension-install.store"; import { cssNames } from "../../utils"; import { observer } from "mobx-react"; import type { Row } from "react-table"; import type { LensExtensionId } from "../../../extensions/lens-extension"; +import extensionDiscoveryInjectable + from "../../../extensions/extension-discovery/extension-discovery.injectable"; + +import { withInjectables } from "@ogre-tools/injectable-react"; +import extensionInstallationStateStoreInjectable + from "../../../extensions/extension-installation-state-store/extension-installation-state-store.injectable"; +import type { ExtensionInstallationStateStore } from "../../../extensions/extension-installation-state-store/extension-installation-state-store"; interface Props { extensions: InstalledExtension[]; @@ -39,6 +48,11 @@ interface Props { uninstall: (extension: InstalledExtension) => void; } +interface Dependencies { + extensionDiscovery: ExtensionDiscovery; + extensionInstallationStateStore: ExtensionInstallationStateStore; +} + function getStatus(extension: InstalledExtension) { if (!extension.isCompatible) { return "Incompatible"; @@ -47,7 +61,7 @@ function getStatus(extension: InstalledExtension) { return extension.isEnabled ? "Enabled" : "Disabled"; } -export const InstalledExtensions = observer(({ extensions, uninstall, enable, disable }: Props) => { +const NonInjectedInstalledExtensions : React.FC = (({ extensionDiscovery, extensionInstallationStateStore, extensions, uninstall, enable, disable }) => { const filters = [ (extension: InstalledExtension) => extension.manifest.name, (extension: InstalledExtension) => getStatus(extension), @@ -93,7 +107,7 @@ export const InstalledExtensions = observer(({ extensions, uninstall, enable, di return extensions.map(extension => { const { id, isEnabled, isCompatible, manifest } = extension; const { name, description, version } = manifest; - const isUninstalling = ExtensionInstallationStateStore.isExtensionUninstalling(id); + const isUninstalling = extensionInstallationStateStore.isExtensionUninstalling(id); return { extension: ( @@ -145,10 +159,10 @@ export const InstalledExtensions = observer(({ extensions, uninstall, enable, di ), }; }); - }, [extensions, ExtensionInstallationStateStore.anyUninstalling], + }, [extensions, extensionInstallationStateStore.anyUninstalling], ); - if (!ExtensionDiscovery.getInstance().isLoaded) { + if (!extensionDiscovery.isLoaded) { return
; } @@ -176,3 +190,16 @@ export const InstalledExtensions = observer(({ extensions, uninstall, enable, di ); }); + +export const InstalledExtensions = withInjectables( + observer(NonInjectedInstalledExtensions), + + { + getProps: (di, props) => ({ + extensionDiscovery: di.inject(extensionDiscoveryInjectable), + extensionInstallationStateStore: di.inject(extensionInstallationStateStoreInjectable), + + ...props, + }), + }, +); diff --git a/src/renderer/components/+extensions/uninstall-extension/uninstall-extension.injectable.ts b/src/renderer/components/+extensions/uninstall-extension/uninstall-extension.injectable.ts index 3660c58454ae..f9dfb4f26a22 100644 --- a/src/renderer/components/+extensions/uninstall-extension/uninstall-extension.injectable.ts +++ b/src/renderer/components/+extensions/uninstall-extension/uninstall-extension.injectable.ts @@ -21,11 +21,17 @@ import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; import extensionLoaderInjectable from "../../../../extensions/extension-loader/extension-loader.injectable"; import { uninstallExtension } from "./uninstall-extension"; +import extensionInstallationStateStoreInjectable + from "../../../../extensions/extension-installation-state-store/extension-installation-state-store.injectable"; +import extensionDiscoveryInjectable + from "../../../../extensions/extension-discovery/extension-discovery.injectable"; const uninstallExtensionInjectable = getInjectable({ instantiate: (di) => uninstallExtension({ extensionLoader: di.inject(extensionLoaderInjectable), + extensionDiscovery: di.inject(extensionDiscoveryInjectable), + extensionInstallationStateStore: di.inject(extensionInstallationStateStoreInjectable), }), lifecycle: lifecycleEnum.singleton, diff --git a/src/renderer/components/+extensions/uninstall-extension/uninstall-extension.tsx b/src/renderer/components/+extensions/uninstall-extension/uninstall-extension.tsx index 5e796e72def5..a6219da60642 100644 --- a/src/renderer/components/+extensions/uninstall-extension/uninstall-extension.tsx +++ b/src/renderer/components/+extensions/uninstall-extension/uninstall-extension.tsx @@ -21,28 +21,30 @@ import type { ExtensionLoader } from "../../../../extensions/extension-loader"; import { extensionDisplayName, LensExtensionId } from "../../../../extensions/lens-extension"; import logger from "../../../../main/logger"; -import { ExtensionInstallationStateStore } from "../extension-install.store"; -import { ExtensionDiscovery } from "../../../../extensions/extension-discovery"; +import type { ExtensionDiscovery } from "../../../../extensions/extension-discovery/extension-discovery"; import { Notifications } from "../../notifications"; import React from "react"; import { when } from "mobx"; import { getMessageFromError } from "../get-message-from-error/get-message-from-error"; +import type { ExtensionInstallationStateStore } from "../../../../extensions/extension-installation-state-store/extension-installation-state-store"; interface Dependencies { extensionLoader: ExtensionLoader + extensionDiscovery: ExtensionDiscovery + extensionInstallationStateStore: ExtensionInstallationStateStore } export const uninstallExtension = - ({ extensionLoader }: Dependencies) => + ({ extensionLoader, extensionDiscovery, extensionInstallationStateStore }: Dependencies) => async (extensionId: LensExtensionId): Promise => { const { manifest } = extensionLoader.getExtension(extensionId); const displayName = extensionDisplayName(manifest.name, manifest.version); try { logger.debug(`[EXTENSIONS]: trying to uninstall ${extensionId}`); - ExtensionInstallationStateStore.setUninstalling(extensionId); + extensionInstallationStateStore.setUninstalling(extensionId); - await ExtensionDiscovery.getInstance().uninstallExtension(extensionId); + await extensionDiscovery.uninstallExtension(extensionId); // wait for the ExtensionLoader to actually uninstall the extension await when(() => !extensionLoader.userExtensions.has(extensionId)); @@ -71,6 +73,6 @@ export const uninstallExtension = return false; } finally { // Remove uninstall state on uninstall failure - ExtensionInstallationStateStore.clearUninstalling(extensionId); + extensionInstallationStateStore.clearUninstalling(extensionId); } }; diff --git a/src/renderer/components/+namespaces/add-namespace-dialog-model/add-namespace-dialog-model.injectable.ts b/src/renderer/components/+namespaces/add-namespace-dialog-model/add-namespace-dialog-model.injectable.ts new file mode 100644 index 000000000000..d2667eca0627 --- /dev/null +++ b/src/renderer/components/+namespaces/add-namespace-dialog-model/add-namespace-dialog-model.injectable.ts @@ -0,0 +1,29 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { AddNamespaceDialogModel } from "./add-namespace-dialog-model"; + +const addNamespaceDialogModelInjectable = getInjectable({ + instantiate: () => new AddNamespaceDialogModel(), + lifecycle: lifecycleEnum.singleton, +}); + +export default addNamespaceDialogModelInjectable; diff --git a/src/renderer/hooks/useStorage.ts b/src/renderer/components/+namespaces/add-namespace-dialog-model/add-namespace-dialog-model.ts similarity index 73% rename from src/renderer/hooks/useStorage.ts rename to src/renderer/components/+namespaces/add-namespace-dialog-model/add-namespace-dialog-model.ts index 92ed73ac466c..5f4ff9f5314a 100644 --- a/src/renderer/hooks/useStorage.ts +++ b/src/renderer/components/+namespaces/add-namespace-dialog-model/add-namespace-dialog-model.ts @@ -18,17 +18,24 @@ * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ +import { observable, makeObservable, action } from "mobx"; -import { useState } from "react"; -import { createStorage } from "../utils"; +export class AddNamespaceDialogModel { + isOpen = false; -export function useStorage(key: string, initialValue: T) { - const storage = createStorage(key, initialValue); - const [storageValue, setStorageValue] = useState(storage.get()); - const setValue = (value: T) => { - setStorageValue(value); - storage.set(value); + constructor() { + makeObservable(this, { + isOpen: observable, + open: action, + close: action, + }); + } + + open = () => { + this.isOpen = true; }; - return [storageValue, setValue] as [T, (value: T) => void]; + close = () => { + this.isOpen = false; + }; } diff --git a/src/renderer/components/+namespaces/add-namespace-dialog.tsx b/src/renderer/components/+namespaces/add-namespace-dialog.tsx index e01381efe970..f064ae8c94ee 100644 --- a/src/renderer/components/+namespaces/add-namespace-dialog.tsx +++ b/src/renderer/components/+namespaces/add-namespace-dialog.tsx @@ -26,38 +26,35 @@ import { observable, makeObservable } from "mobx"; import { observer } from "mobx-react"; import { Dialog, DialogProps } from "../dialog"; import { Wizard, WizardStep } from "../wizard"; -import { namespaceStore } from "./namespace.store"; 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; } -const dialogState = observable.object({ - isOpen: false, -}); +interface Dependencies { + createNamespace: (params: { name: string }) => Promise, + model: AddNamespaceDialogModel +} @observer -export class AddNamespaceDialog extends React.Component { +class NonInjectedAddNamespaceDialog extends React.Component { @observable namespace = ""; - constructor(props: Props) { + constructor(props: Props & Dependencies) { super(props); makeObservable(this); } - static open() { - dialogState.isOpen = true; - } - - static close() { - dialogState.isOpen = false; - } - reset = () => { this.namespace = ""; }; @@ -67,10 +64,10 @@ export class AddNamespaceDialog extends React.Component { const { onSuccess, onError } = this.props; try { - const created = await namespaceStore.create({ name: namespace }); + const created = await this.props.createNamespace({ name: namespace }); onSuccess?.(created); - AddNamespaceDialog.close(); + this.props.model.close(); } catch (err) { Notifications.error(err); onError?.(err); @@ -78,7 +75,7 @@ export class AddNamespaceDialog extends React.Component { }; render() { - const { ...dialogProps } = this.props; + const { model, createNamespace, ...dialogProps } = this.props; const { namespace } = this; const header =
Create Namespace
; @@ -86,11 +83,11 @@ export class AddNamespaceDialog extends React.Component { - + { ); } } + +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/namespace-details.tsx b/src/renderer/components/+namespaces/namespace-details.tsx index 68136499ed0e..c2fb1d8c6ace 100644 --- a/src/renderer/components/+namespaces/namespace-details.tsx +++ b/src/renderer/components/+namespaces/namespace-details.tsx @@ -25,7 +25,7 @@ import React from "react"; import { computed, makeObservable, observable, reaction } from "mobx"; import { disposeOnUnmount, observer } from "mobx-react"; import { DrawerItem } from "../drawer"; -import { boundMethod, cssNames } from "../../utils"; +import { boundMethod, cssNames, Disposer } from "../../utils"; import { getMetricsForNamespace, IPodMetrics, Namespace } from "../../../common/k8s-api/endpoints"; import type { KubeObjectDetailsProps } from "../kube-object-details"; import { Link } from "react-router-dom"; @@ -39,16 +39,24 @@ 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 { kubeWatchApi } from "../../../common/k8s-api/kube-watch-api"; +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 -export class NamespaceDetails extends React.Component { +class NonInjectedNamespaceDetails extends React.Component { @observable metrics: IPodMetrics = null; - constructor(props: Props) { + constructor(props: Props & Dependencies) { super(props); makeObservable(this); } @@ -58,7 +66,8 @@ export class NamespaceDetails extends React.Component { reaction(() => this.props.object, () => { this.metrics = null; }), - kubeWatchApi.subscribeStores([ + + this.props.subscribeStores([ resourceQuotaStore, limitRangeStore, ]), @@ -138,3 +147,15 @@ export class NamespaceDetails extends React.Component { ); } } + +export const NamespaceDetails = withInjectables( + NonInjectedNamespaceDetails, + + { + getProps: (di, props) => ({ + subscribeStores: di.inject(kubeWatchApiInjectable).subscribeStores, + ...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 new file mode 100644 index 000000000000..0834781721cd --- /dev/null +++ b/src/renderer/components/+namespaces/namespace-select-filter-model/namespace-select-filter-model.injectable.ts @@ -0,0 +1,33 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +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 new file mode 100644 index 000000000000..4c83e365508a --- /dev/null +++ b/src/renderer/components/+namespaces/namespace-select-filter-model/namespace-select-filter-model.ts @@ -0,0 +1,111 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +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 929530aa9719..9c1e1e6b6d4f 100644 --- a/src/renderer/components/+namespaces/namespace-select-filter.tsx +++ b/src/renderer/components/+namespaces/namespace-select-filter.tsx @@ -22,171 +22,122 @@ import "./namespace-select-filter.scss"; import React from "react"; -import { disposeOnUnmount, observer } from "mobx-react"; +import { observer } from "mobx-react"; import { components, PlaceholderProps } from "react-select"; -import { action, computed, makeObservable, observable, reaction } from "mobx"; import { Icon } from "../icon"; import { NamespaceSelect } from "./namespace-select"; -import { namespaceStore } from "./namespace.store"; +import type { NamespaceStore } from "./namespace-store/namespace.store"; import type { SelectOption, SelectProps } from "../select"; -import { isMac } from "../../../common/vars"; - -const Placeholder = observer((props: PlaceholderProps) => { - const getPlaceholder = (): React.ReactNode => { - const namespaces = namespaceStore.contextNamespaces; - - if (namespaceStore.areAllSelectedImplicitly || !namespaces.length) { - return <>All namespaces; - } - - if (namespaces.length === 1) { - return <>Namespace: {namespaces[0]}; - } - - return <>Namespaces: {namespaces.join(", ")}; - }; - - return ( - - {getPlaceholder()} - - ); -}); - -@observer -export class NamespaceSelectFilter extends React.Component { - static isMultiSelection = observable.box(false); - static isMenuOpen = observable.box(false); - - /** - * Only updated on every open - */ - private selected = observable.set(); - private didToggle = false; - - constructor(props: SelectProps) { - super(props); - makeObservable(this); - } - - @computed get isMultiSelection() { - return NamespaceSelectFilter.isMultiSelection.get(); - } - - set isMultiSelection(val: boolean) { - NamespaceSelectFilter.isMultiSelection.set(val); - } - - @computed get isMenuOpen() { - return NamespaceSelectFilter.isMenuOpen.get(); - } - - set isMenuOpen(val: boolean) { - NamespaceSelectFilter.isMenuOpen.set(val); - } - - componentDidMount() { - disposeOnUnmount(this, [ - reaction(() => this.isMenuOpen, newVal => { - if (newVal) { // rising edge of selection - this.selected.replace(namespaceStore.selectedNames); - this.didToggle = false; - } - }), - ]); - } - - formatOptionLabel({ value: namespace, label }: SelectOption) { - if (namespace) { - const isSelected = namespaceStore.hasContext(namespace); - - return ( -
- - {namespace} - {isSelected && } -
- ); - } - - return label; - } - - @action - onChange = ([{ value: namespace }]: SelectOption[]) => { - if (namespace) { - if (this.isMultiSelection) { - this.didToggle = true; - namespaceStore.toggleSingle(namespace); - } else { - namespaceStore.selectSingle(namespace); - } - } else { - namespaceStore.selectAll(); - } - }; - - private isSelectionKey(e: React.KeyboardEvent): boolean { - if (isMac) { - return e.key === "Meta"; - } - - return e.key === "Control"; // windows or linux - } +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"; - @action - onKeyDown = (e: React.KeyboardEvent) => { - if (this.isSelectionKey(e)) { - this.isMultiSelection = true; - } - }; - - @action - onKeyUp = (e: React.KeyboardEvent) => { - if (this.isSelectionKey(e)) { - this.isMultiSelection = false; - } - - if (!this.isMultiSelection && this.didToggle) { - this.isMenuOpen = false; - } - }; - - @action - onClick = () => { - if (!this.isMenuOpen) { - this.isMenuOpen = true; - } else if (!this.isMultiSelection) { - this.isMenuOpen = !this.isMenuOpen; - } - }; - - reset = () => { - this.isMultiSelection = false; - this.isMenuOpen = false; - }; +interface Dependencies { + model: NamespaceSelectFilterModel; +} +class NonInjectedNamespaceSelectFilter extends React.Component< + SelectProps & Dependencies +> { render() { return ( -
+
+this.selected.has(right.value) - +this.selected.has(left.value)} + sort={(left, right) => + +this.props.model.selectedNames.has(right.value) - + +this.props.model.selectedNames.has(left.value) + } />
); } } + +const formatOptionLabelFor = + (model: NamespaceSelectFilterModel) => + ({ value: namespace, label }: SelectOption) => { + if (namespace) { + const isSelected = model.isSelected(namespace); + + 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; + + if (namespaceStore.areAllSelectedImplicitly || !namespaces.length) { + return <>All namespaces; + } + + if (namespaces.length === 1) { + return <>Namespace: {namespaces[0]}; + } + + return <>Namespaces: {namespaces.join(", ")}; + }; + + return ( + + {getPlaceholder()} + + ); + }, +); + +const Placeholder = withInjectables( + NonInjectedPlaceholder, + + { + getProps: (di, props) => ({ + namespaceStore: di.inject(namespaceStoreInjectable), + ...props, + }), + }, +); diff --git a/src/renderer/components/+namespaces/namespace-select.tsx b/src/renderer/components/+namespaces/namespace-select.tsx index 7974b25f797e..98055559571d 100644 --- a/src/renderer/components/+namespaces/namespace-select.tsx +++ b/src/renderer/components/+namespaces/namespace-select.tsx @@ -27,7 +27,9 @@ import { observer } from "mobx-react"; import { Select, SelectOption, SelectProps } from "../select"; import { cssNames } from "../../utils"; import { Icon } from "../icon"; -import { namespaceStore } from "./namespace.store"; +import type { NamespaceStore } from "./namespace-store/namespace.store"; +import { withInjectables } from "@ogre-tools/injectable-react"; +import namespaceStoreInjectable from "./namespace-store/namespace-store.injectable"; interface Props extends SelectProps { showIcons?: boolean; @@ -40,11 +42,15 @@ const defaultProps: Partial = { showIcons: true, }; +interface Dependencies { + namespaceStore: NamespaceStore +} + @observer -export class NamespaceSelect extends React.Component { +class NonInjectedNamespaceSelect extends React.Component { static defaultProps = defaultProps as object; - constructor(props: Props) { + constructor(props: Props & Dependencies) { super(props); makeObservable(this); } @@ -53,7 +59,7 @@ export class NamespaceSelect extends React.Component { @computed.struct get options(): SelectOption[] { const { customizeOptions, showAllNamespacesOption, sort } = this.props; - let options: SelectOption[] = namespaceStore.items.map(ns => ({ value: ns.getName() })); + let options: SelectOption[] = this.props.namespaceStore.items.map(ns => ({ value: ns.getName() })); if (sort) { options.sort(sort); @@ -83,7 +89,7 @@ export class NamespaceSelect extends React.Component { }; render() { - const { className, showIcons, customizeOptions, components = {}, ...selectProps } = this.props; + const { className, showIcons, customizeOptions, components = {}, namespaceStore, ...selectProps } = this.props; return ( { ); }); + +export const KubectlBinaries = withInjectables(NonInjectedKubectlBinaries, { + getProps: (di) => ({ + defaultPathForKubectlBinaries: di.inject(directoryForBinariesInjectable), + }), +}); diff --git a/src/renderer/components/+storage-classes/storage-class-details.tsx b/src/renderer/components/+storage-classes/storage-class-details.tsx index 30e0df6a6bed..acd50f1e744c 100644 --- a/src/renderer/components/+storage-classes/storage-class-details.tsx +++ b/src/renderer/components/+storage-classes/storage-class-details.tsx @@ -33,16 +33,25 @@ import { storageClassStore } from "./storage-class.store"; import { VolumeDetailsList } from "../+storage-volumes/volume-details-list"; import { volumesStore } from "../+storage-volumes/volumes.store"; import logger from "../../../common/logger"; -import { kubeWatchApi } from "../../../common/k8s-api/kube-watch-api"; +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 -export class StorageClassDetails extends React.Component { +class NonInjectedStorageClassDetails extends React.Component { componentDidMount() { disposeOnUnmount(this, [ - kubeWatchApi.subscribeStores([ + this.props.subscribeStores([ volumesStore, ]), ]); @@ -102,3 +111,15 @@ export class StorageClassDetails extends React.Component { ); } } + +export const StorageClassDetails = withInjectables( + NonInjectedStorageClassDetails, + + { + getProps: (di, props) => ({ + subscribeStores: di.inject(kubeWatchApiInjectable).subscribeStores, + ...props, + }), + }, +); + diff --git a/src/renderer/components/+user-management/+cluster-role-bindings/__tests__/dialog.test.tsx b/src/renderer/components/+user-management/+cluster-role-bindings/__tests__/dialog.test.tsx index 132f33868597..c6d85552bb4d 100644 --- a/src/renderer/components/+user-management/+cluster-role-bindings/__tests__/dialog.test.tsx +++ b/src/renderer/components/+user-management/+cluster-role-bindings/__tests__/dialog.test.tsx @@ -19,17 +19,26 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import { render } from "@testing-library/react"; import React from "react"; import { ClusterRoleBindingDialog } from "../dialog"; import { clusterRolesStore } from "../../+cluster-roles/store"; import { ClusterRole } from "../../../../../common/k8s-api/endpoints"; import userEvent from "@testing-library/user-event"; +import { getDiForUnitTesting } from "../../../../getDiForUnitTesting"; +import { DiRender, renderFor } from "../../../test-utils/renderFor"; jest.mock("../../+cluster-roles/store"); describe("ClusterRoleBindingDialog tests", () => { - beforeEach(() => { + let render: DiRender; + + beforeEach(async () => { + const di = getDiForUnitTesting({ doGeneralOverrides: true }); + + await di.runSetups(); + + render = renderFor(di); + (clusterRolesStore as any).items = [new ClusterRole({ apiVersion: "rbac.authorization.k8s.io/v1", kind: "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 index 5b66d7a8c700..544c353b7075 100644 --- a/src/renderer/components/+user-management/+role-bindings/__tests__/dialog.test.tsx +++ b/src/renderer/components/+user-management/+role-bindings/__tests__/dialog.test.tsx @@ -19,17 +19,31 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import { render } from "@testing-library/react"; 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", () => { - beforeEach(() => { + 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", diff --git a/src/renderer/components/+welcome/__test__/welcome.test.tsx b/src/renderer/components/+welcome/__test__/welcome.test.tsx index 3d1ba9b7ce8f..c27a39bd96b5 100644 --- a/src/renderer/components/+welcome/__test__/welcome.test.tsx +++ b/src/renderer/components/+welcome/__test__/welcome.test.tsx @@ -46,8 +46,10 @@ describe("", () => { let di: ConfigurableDependencyInjectionContainer; let welcomeBannersStub: WelcomeBannerRegistration[]; - beforeEach(() => { - di = getDiForUnitTesting(); + beforeEach(async () => { + di = getDiForUnitTesting({ doGeneralOverrides: true }); + + await di.runSetups(); render = renderFor(di); diff --git a/src/renderer/components/+workloads-cronjobs/cronjob-details.tsx b/src/renderer/components/+workloads-cronjobs/cronjob-details.tsx index a4ba0cbeea4a..58a91032df35 100644 --- a/src/renderer/components/+workloads-cronjobs/cronjob-details.tsx +++ b/src/renderer/components/+workloads-cronjobs/cronjob-details.tsx @@ -34,16 +34,25 @@ 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 { kubeWatchApi } from "../../../common/k8s-api/kube-watch-api"; +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 -export class CronJobDetails extends React.Component { +class NonInjectedCronJobDetails extends React.Component { componentDidMount() { disposeOnUnmount(this, [ - kubeWatchApi.subscribeStores([ + this.props.subscribeStores([ jobStore, ]), ]); @@ -119,3 +128,14 @@ export class CronJobDetails extends React.Component { ); } } + +export const CronJobDetails = withInjectables( + NonInjectedCronJobDetails, + + { + getProps: (di, props) => ({ + subscribeStores: di.inject(kubeWatchApiInjectable).subscribeStores, + ...props, + }), + }, +); diff --git a/src/renderer/components/+workloads-daemonsets/daemonset-details.tsx b/src/renderer/components/+workloads-daemonsets/daemonset-details.tsx index 3e431986c3b9..c7db2ebacb79 100644 --- a/src/renderer/components/+workloads-daemonsets/daemonset-details.tsx +++ b/src/renderer/components/+workloads-daemonsets/daemonset-details.tsx @@ -39,18 +39,26 @@ 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 } from "../../utils"; +import { boundMethod, Disposer } from "../../utils"; import logger from "../../../common/logger"; -import { kubeWatchApi } from "../../../common/k8s-api/kube-watch-api"; +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 -export class DaemonSetDetails extends React.Component { +class NonInjectedDaemonSetDetails extends React.Component { @observable metrics: IPodMetrics = null; - constructor(props: Props) { + constructor(props: Props & Dependencies) { super(props); makeObservable(this); } @@ -60,7 +68,7 @@ export class DaemonSetDetails extends React.Component { reaction(() => this.props.object, () => { this.metrics = null; }), - kubeWatchApi.subscribeStores([ + this.props.subscribeStores([ podsStore, ]), ]); @@ -139,3 +147,14 @@ export class DaemonSetDetails extends React.Component { ); } } + +export const DaemonSetDetails = withInjectables( + NonInjectedDaemonSetDetails, + + { + getProps: (di, props) => ({ + subscribeStores: di.inject(kubeWatchApiInjectable).subscribeStores, + ...props, + }), + }, +); diff --git a/src/renderer/components/+workloads-deployments/deployment-details.tsx b/src/renderer/components/+workloads-deployments/deployment-details.tsx index f0ae0c1d6ed5..d725a952325d 100644 --- a/src/renderer/components/+workloads-deployments/deployment-details.tsx +++ b/src/renderer/components/+workloads-deployments/deployment-details.tsx @@ -41,18 +41,26 @@ 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 } from "../../utils"; +import { boundMethod, Disposer } from "../../utils"; import logger from "../../../common/logger"; -import { kubeWatchApi } from "../../../common/k8s-api/kube-watch-api"; +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 -export class DeploymentDetails extends React.Component { +class NonInjectedDeploymentDetails extends React.Component { @observable metrics: IPodMetrics = null; - constructor(props: Props) { + constructor(props: Props & Dependencies) { super(props); makeObservable(this); } @@ -62,7 +70,8 @@ export class DeploymentDetails extends React.Component { reaction(() => this.props.object, () => { this.metrics = null; }), - kubeWatchApi.subscribeStores([ + + this.props.subscribeStores([ podsStore, replicaSetStore, ]), @@ -162,3 +171,15 @@ export class DeploymentDetails extends React.Component { ); } } + +export const DeploymentDetails = withInjectables( + NonInjectedDeploymentDetails, + + { + getProps: (di, props) => ({ + subscribeStores: di.inject(kubeWatchApiInjectable).subscribeStores, + ...props, + }), + }, +); + diff --git a/src/renderer/components/+workloads-jobs/job-details.tsx b/src/renderer/components/+workloads-jobs/job-details.tsx index a0445874d865..103a21ab4461 100644 --- a/src/renderer/components/+workloads-jobs/job-details.tsx +++ b/src/renderer/components/+workloads-jobs/job-details.tsx @@ -45,16 +45,25 @@ 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 { kubeWatchApi } from "../../../common/k8s-api/kube-watch-api"; +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 -export class JobDetails extends React.Component { +class NonInjectedJobDetails extends React.Component { @observable metrics: IPodMetrics = null; - constructor(props: Props) { + constructor(props: Props & Dependencies) { super(props); makeObservable(this); } @@ -64,7 +73,7 @@ export class JobDetails extends React.Component { reaction(() => this.props.object, () => { this.metrics = null; }), - kubeWatchApi.subscribeStores([ + this.props.subscribeStores([ podsStore, ]), ]); @@ -171,3 +180,15 @@ export class JobDetails extends React.Component { ); } } + +export const JobDetails = withInjectables( + NonInjectedJobDetails, + + { + getProps: (di, props) => ({ + subscribeStores: di.inject(kubeWatchApiInjectable).subscribeStores, + ...props, + }), + }, +); + diff --git a/src/renderer/components/+workloads-overview/overview-statuses.tsx b/src/renderer/components/+workloads-overview/overview-statuses.tsx index e48c4ff8b863..5ba044917340 100644 --- a/src/renderer/components/+workloads-overview/overview-statuses.tsx +++ b/src/renderer/components/+workloads-overview/overview-statuses.tsx @@ -26,12 +26,14 @@ import { observer } from "mobx-react"; import { OverviewWorkloadStatus } from "./overview-workload-status"; import { Link } from "react-router-dom"; import { workloadStores } from "../+workloads"; -import { namespaceStore } from "../+namespaces/namespace.store"; +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", @@ -43,8 +45,12 @@ const resources: KubeResource[] = [ "cronjobs", ]; +interface Dependencies { + namespaceStore: NamespaceStore +} + @observer -export class OverviewStatuses extends React.Component { +class NonInjectedOverviewStatuses extends React.Component { @boundMethod renderWorkload(resource: KubeResource): React.ReactElement { const store = workloadStores.get(resource); @@ -53,7 +59,7 @@ export class OverviewStatuses extends React.Component { return null; } - const items = store.getAllByNs(namespaceStore.contextNamespaces); + const items = store.getAllByNs(this.props.namespaceStore.contextNamespaces); return (
@@ -79,3 +85,13 @@ export class OverviewStatuses extends React.Component { ); } } + +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 fa7ad590b0bd..635444254e9e 100644 --- a/src/renderer/components/+workloads-overview/overview.tsx +++ b/src/renderer/components/+workloads-overview/overview.tsx @@ -32,32 +32,41 @@ 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 { kubeWatchApi } from "../../../common/k8s-api/kube-watch-api"; import { WorkloadsOverviewDetailRegistry } from "../../../extensions/registries"; import type { WorkloadsOverviewRouteParams } from "../../../common/routes"; import { makeObservable, observable, reaction } from "mobx"; import { NamespaceSelectFilter } from "../+namespaces/namespace-select-filter"; import { Icon } from "../icon"; import { TooltipPosition } from "../tooltip"; -import type { ClusterContext } from "../../../common/k8s-api/cluster-context"; +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 { 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"; interface Props extends RouteComponentProps { } -@observer -export class WorkloadsOverview extends React.Component { - static clusterContext: ClusterContext; +interface Dependencies { + clusterFrameContext: ClusterFrameContext + subscribeStores: (stores: KubeObjectStore[], options: KubeWatchSubscribeStoreOptions) => Disposer +} +@observer +class NonInjectedWorkloadsOverview extends React.Component { @observable loadErrors: string[] = []; - constructor(props: Props) { + constructor(props: Props & Dependencies) { super(props); makeObservable(this); } componentDidMount() { disposeOnUnmount(this, [ - kubeWatchApi.subscribeStores([ + this.props.subscribeStores([ cronJobStore, daemonSetStore, deploymentStore, @@ -69,7 +78,7 @@ export class WorkloadsOverview extends React.Component { ], { onLoadFailure: error => this.loadErrors.push(String(error)), }), - reaction(() => WorkloadsOverview.clusterContext.contextNamespaces.slice(), () => { + reaction(() => this.props.clusterFrameContext.contextNamespaces.slice(), () => { // clear load errors this.loadErrors.length = 0; }), @@ -117,3 +126,15 @@ export class WorkloadsOverview extends React.Component { ); } } + +export const WorkloadsOverview = withInjectables( + NonInjectedWorkloadsOverview, + + { + getProps: (di, props) => ({ + clusterFrameContext: di.inject(clusterFrameContextInjectable), + subscribeStores: di.inject(kubeWatchApiInjectable).subscribeStores, + ...props, + }), + }, +); diff --git a/src/renderer/components/+workloads-pods/__tests__/pod-tolerations.test.tsx b/src/renderer/components/+workloads-pods/__tests__/pod-tolerations.test.tsx index f7c77f14ad45..ad5ea3f26e88 100644 --- a/src/renderer/components/+workloads-pods/__tests__/pod-tolerations.test.tsx +++ b/src/renderer/components/+workloads-pods/__tests__/pod-tolerations.test.tsx @@ -21,9 +21,13 @@ import React from "react"; import "@testing-library/jest-dom/extend-expect"; -import { fireEvent, render } from "@testing-library/react"; +import { fireEvent } from "@testing-library/react"; import type { IToleration } from "../../../../common/k8s-api/workload-kube-object"; import { PodTolerations } from "../pod-tolerations"; +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: { @@ -47,6 +51,21 @@ const tolerations: IToleration[] =[ ]; describe("", () => { + let render: DiRender; + + beforeEach(async () => { + const di = getDiForUnitTesting({ doGeneralOverrides: true }); + + di.override( + directoryForLensLocalStorageInjectable, + () => "some-directory-for-lens-local-storage", + ); + + await di.runSetups(); + + render = renderFor(di); + }); + it("renders w/o errors", () => { const { container } = render(); diff --git a/src/renderer/components/+workloads-pods/pod-container-port.tsx b/src/renderer/components/+workloads-pods/pod-container-port.tsx index 768ea50220f6..012b125c7d19 100644 --- a/src/renderer/components/+workloads-pods/pod-container-port.tsx +++ b/src/renderer/components/+workloads-pods/pod-container-port.tsx @@ -24,13 +24,23 @@ 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, observable, makeObservable, reaction } from "mobx"; +import { action, makeObservable, observable, reaction } from "mobx"; import { cssNames } from "../../utils"; import { Notifications } from "../notifications"; import { Button } from "../button"; -import { aboutPortForwarding, addPortForward, getPortForward, getPortForwards, notifyErrorPortForwarding, openPortForward, PortForwardDialog, predictProtocol, removePortForward, startPortForward } from "../../port-forward"; 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 { @@ -39,17 +49,22 @@ interface Props { name?: string; containerPort: number; protocol: string; - } + }; +} + +interface Dependencies { + portForwardStore: PortForwardStore; + openPortForwardDialog: (item: ForwardedPort, options: { openInBrowser: boolean, onClose: () => void }) => void; } @observer -export class PodContainerPort extends React.Component { +class NonInjectedPodContainerPort extends React.Component { @observable waiting = false; @observable forwardPort = 0; @observable isPortForwarded = false; @observable isActive = false; - constructor(props: Props) { + constructor(props: Props & Dependencies) { super(props); makeObservable(this); this.checkExistingPortForwarding(); @@ -61,6 +76,10 @@ export class PodContainerPort extends React.Component { ]); } + get portForwardStore() { + return this.props.portForwardStore; + } + @action async checkExistingPortForwarding() { const { pod, port } = this.props; @@ -73,7 +92,7 @@ export class PodContainerPort extends React.Component { }; try { - portForward = await getPortForward(portForward); + portForward = await this.portForwardStore.getPortForward(portForward); } catch (error) { this.isPortForwarded = false; this.isActive = false; @@ -103,12 +122,12 @@ export class PodContainerPort extends React.Component { try { // determine how many port-forwards already exist - const { length } = getPortForwards(); + const { length } = this.portForwardStore.getPortForwards(); if (!this.isPortForwarded) { - portForward = await addPortForward(portForward); + portForward = await this.portForwardStore.add(portForward); } else if (!this.isActive) { - portForward = await startPortForward(portForward); + portForward = await this.portForwardStore.start(portForward); } if (portForward.status === "Active") { @@ -143,7 +162,7 @@ export class PodContainerPort extends React.Component { this.waiting = true; try { - await removePortForward(portForward); + await this.portForwardStore.remove(portForward); } catch (error) { Notifications.error(`Error occurred stopping the port-forward from port ${portForward.forwardPort}.`); } finally { @@ -171,7 +190,7 @@ export class PodContainerPort extends React.Component { protocol: predictProtocol(port.name), }; - PortForwardDialog.open(portForward, { openInBrowser: true, onClose: () => this.checkExistingPortForwarding() }); + this.props.openPortForwardDialog(portForward, { openInBrowser: true, onClose: () => this.checkExistingPortForwarding() }); } }); @@ -188,3 +207,15 @@ export class PodContainerPort extends React.Component { ); } } + +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 index b4a4c5eaf223..da78ff71b2c5 100644 --- a/src/renderer/components/+workloads-pods/pod-details-container.tsx +++ b/src/renderer/components/+workloads-pods/pod-details-container.tsx @@ -35,8 +35,10 @@ import { ContainerCharts } from "./container-charts"; import { LocaleDate } from "../locale-date"; import { getActiveClusterEntity } from "../../api/catalog-entity-registry"; import { ClusterMetricsResourceType } from "../../../common/cluster-types"; -import { portForwardStore } from "../../port-forward/port-forward.store"; +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; @@ -44,12 +46,16 @@ interface Props { metrics?: { [key: string]: IMetrics }; } +interface Dependencies { + portForwardStore: PortForwardStore +} + @observer -export class PodDetailsContainer extends React.Component { +class NonInjectedPodDetailsContainer extends React.Component { componentDidMount() { disposeOnUnmount(this, [ - portForwardStore.watch(), + this.props.portForwardStore.watch(), ]); } @@ -200,3 +206,14 @@ export class PodDetailsContainer extends React.Component { ); } } + +export const PodDetailsContainer = withInjectables( + NonInjectedPodDetailsContainer, + + { + getProps: (di, props) => ({ + portForwardStore: di.inject(portForwardStoreInjectable), + ...props, + }), + }, +); diff --git a/src/renderer/components/+workloads-replicasets/replicaset-details.tsx b/src/renderer/components/+workloads-replicasets/replicaset-details.tsx index 4e38ae9e1efa..d1ad5c14558c 100644 --- a/src/renderer/components/+workloads-replicasets/replicaset-details.tsx +++ b/src/renderer/components/+workloads-replicasets/replicaset-details.tsx @@ -38,18 +38,26 @@ 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 } from "../../utils"; +import { boundMethod, Disposer } from "../../utils"; import logger from "../../../common/logger"; -import { kubeWatchApi } from "../../../common/k8s-api/kube-watch-api"; +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 -export class ReplicaSetDetails extends React.Component { +class NonInjectedReplicaSetDetails extends React.Component { @observable metrics: IPodMetrics = null; - constructor(props: Props) { + constructor(props: Props & Dependencies) { super(props); makeObservable(this); } @@ -59,7 +67,8 @@ export class ReplicaSetDetails extends React.Component { reaction(() => this.props.object, () => { this.metrics = null; }), - kubeWatchApi.subscribeStores([ + + this.props.subscribeStores([ podsStore, ]), ]); @@ -140,3 +149,14 @@ export class ReplicaSetDetails extends React.Component { ); } } + +export const ReplicaSetDetails = withInjectables( + NonInjectedReplicaSetDetails, + + { + getProps: (di, props) => ({ + subscribeStores: di.inject(kubeWatchApiInjectable).subscribeStores, + ...props, + }), + }, +); diff --git a/src/renderer/components/+workloads-statefulsets/statefulset-details.tsx b/src/renderer/components/+workloads-statefulsets/statefulset-details.tsx index 7d2d66568c0e..fc95f4170392 100644 --- a/src/renderer/components/+workloads-statefulsets/statefulset-details.tsx +++ b/src/renderer/components/+workloads-statefulsets/statefulset-details.tsx @@ -39,18 +39,26 @@ 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 } from "../../utils"; +import { boundMethod, Disposer } from "../../utils"; import logger from "../../../common/logger"; -import { kubeWatchApi } from "../../../common/k8s-api/kube-watch-api"; +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 -export class StatefulSetDetails extends React.Component { +class NonInjectedStatefulSetDetails extends React.Component { @observable metrics: IPodMetrics = null; - constructor(props: Props) { + constructor(props: Props & Dependencies) { super(props); makeObservable(this); } @@ -60,7 +68,8 @@ export class StatefulSetDetails extends React.Component { reaction(() => this.props.object, () => { this.metrics = null; }), - kubeWatchApi.subscribeStores([ + + this.props.subscribeStores([ podsStore, ]), ]); @@ -137,3 +146,15 @@ export class StatefulSetDetails extends React.Component { ); } } + +export const StatefulSetDetails = withInjectables( + NonInjectedStatefulSetDetails, + + { + getProps: (di, props) => ({ + subscribeStores: di.inject(kubeWatchApiInjectable).subscribeStores, + ...props, + }), + }, +); + diff --git a/src/renderer/components/+workloads/workloads.tsx b/src/renderer/components/+workloads/workloads.tsx index 8d88efb38a7f..b742a74f1ab2 100644 --- a/src/renderer/components/+workloads/workloads.tsx +++ b/src/renderer/components/+workloads/workloads.tsx @@ -22,7 +22,6 @@ import "./workloads.scss"; import React from "react"; -import { observer } from "mobx-react"; import { TabLayout, TabLayoutRoute } from "../layout/tab-layout"; import { WorkloadsOverview } from "../+workloads-overview/overview"; import { Pods } from "../+workloads-pods"; @@ -35,7 +34,6 @@ import { isAllowedResource } from "../../../common/utils/allowed-resource"; import { ReplicaSets } from "../+workloads-replicasets"; import * as routes from "../../../common/routes"; -@observer export class Workloads extends React.Component { static get tabRoutes(): TabLayoutRoute[] { const tabs: TabLayoutRoute[] = [ diff --git a/src/renderer/components/cluster-manager/bottom-bar.test.tsx b/src/renderer/components/cluster-manager/bottom-bar.test.tsx index 0733747a97c4..1807b60d36b5 100644 --- a/src/renderer/components/cluster-manager/bottom-bar.test.tsx +++ b/src/renderer/components/cluster-manager/bottom-bar.test.tsx @@ -26,16 +26,14 @@ import "@testing-library/jest-dom/extend-expect"; import { BottomBar } from "./bottom-bar"; import { StatusBarRegistry } from "../../../extensions/registries"; import hotbarManagerInjectable from "../../../common/hotbar-store.injectable"; -import { AppPaths } from "../../../common/app-paths"; import { HotbarSwitchCommand } from "../hotbar/hotbar-switch-command"; import { ActiveHotbarName } from "./active-hotbar-name"; import { getDiForUnitTesting } from "../../getDiForUnitTesting"; import { DiRender, renderFor } from "../test-utils/renderFor"; -import type { ConfigurableDependencyInjectionContainer } from "@ogre-tools/injectable"; +import type { DependencyInjectionContainer } from "@ogre-tools/injectable"; import commandOverlayInjectable from "../command-palette/command-overlay.injectable"; import { getEmptyHotbar } from "../../../common/hotbar-types"; -AppPaths.init(); jest.mock("electron", () => ({ app: { @@ -56,25 +54,29 @@ jest.mock("electron", () => ({ const foobarHotbar = getEmptyHotbar("foobar"); describe("", () => { - let di: ConfigurableDependencyInjectionContainer; + let di: DependencyInjectionContainer; let render: DiRender; - beforeEach(() => { + beforeEach(async () => { const mockOpts = { "tmp": { "test-store.json": JSON.stringify({}), }, }; - di = getDiForUnitTesting(); - render = renderFor(di); + di = getDiForUnitTesting({ doGeneralOverrides: true }); mockFs(mockOpts); - StatusBarRegistry.createInstance(); + + render = renderFor(di); di.override(hotbarManagerInjectable, () => ({ getActive: () => foobarHotbar, } as any)); + + await di.runSetups(); + + StatusBarRegistry.createInstance(); }); afterEach(() => { diff --git a/src/renderer/components/cluster-manager/cluster-manager.tsx b/src/renderer/components/cluster-manager/cluster-manager.tsx index 95a21a747e09..4754c02ba257 100644 --- a/src/renderer/components/cluster-manager/cluster-manager.tsx +++ b/src/renderer/components/cluster-manager/cluster-manager.tsx @@ -25,7 +25,7 @@ import React from "react"; import { Redirect, Route, Switch } from "react-router"; import { disposeOnUnmount, observer } from "mobx-react"; import { BottomBar } from "./bottom-bar"; -import { Catalog, previousActiveTab } from "../+catalog"; +import { Catalog } from "../+catalog"; import { Preferences } from "../+preferences"; import { AddCluster } from "../+add-cluster"; import { ClusterView } from "./cluster-view"; @@ -40,10 +40,16 @@ import { reaction } from "mobx"; import { navigation } from "../../navigation"; import { setEntityOnRouteMatch } from "../../api/helpers/general-active-sync"; 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"; + +interface Dependencies { + catalogPreviousActiveTabStorage: { get: () => string } +} @observer -export class ClusterManager extends React.Component { +class NonInjectedClusterManager extends React.Component { componentDidMount() { disposeOnUnmount(this, [ reaction(() => navigation.location, () => setEntityOnRouteMatch(), { fireImmediately: true }), @@ -53,11 +59,18 @@ export class ClusterManager extends React.Component { render() { return (
- +
-
+
- + + @@ -65,19 +78,24 @@ export class ClusterManager extends React.Component { - { - GlobalPageRegistry.getInstance().getItems() - .map(({ url, components: { Page }}) => ( - - )) - } - + {GlobalPageRegistry.getInstance() + .getItems() + .map(({ url, components: { Page }}) => ( + + ))} +
- - - + + +
); } } + +export const ClusterManager = withInjectables(NonInjectedClusterManager, { + getProps: di => ({ + catalogPreviousActiveTabStorage: di.inject(catalogPreviousActiveTabStorageInjectable), + }), +}); diff --git a/src/renderer/components/cluster-manager/cluster-status.tsx b/src/renderer/components/cluster-manager/cluster-status.tsx index 5b7e73510c2d..954e413c7855 100644 --- a/src/renderer/components/cluster-manager/cluster-status.tsx +++ b/src/renderer/components/cluster-manager/cluster-status.tsx @@ -26,7 +26,7 @@ import { disposeOnUnmount, observer } from "mobx-react"; import React from "react"; import { clusterActivateHandler } from "../../../common/cluster-ipc"; import { ipcRendererOn, requestMain } from "../../../common/ipc"; -import type { Cluster } from "../../../main/cluster"; +import type { Cluster } from "../../../common/cluster/cluster"; import { cssNames, IClassName } from "../../utils"; import { Button } from "../button"; import { Icon } from "../icon"; diff --git a/src/renderer/components/cluster-manager/cluster-view.tsx b/src/renderer/components/cluster-manager/cluster-view.tsx index b22e3b8adf52..573df577d4f7 100644 --- a/src/renderer/components/cluster-manager/cluster-view.tsx +++ b/src/renderer/components/cluster-manager/cluster-view.tsx @@ -26,8 +26,8 @@ import { disposeOnUnmount, observer } from "mobx-react"; import type { RouteComponentProps } from "react-router"; import { ClusterStatus } from "./cluster-status"; import { ClusterFrameHandler } from "./lens-views"; -import type { Cluster } from "../../../main/cluster"; -import { ClusterStore } from "../../../common/cluster-store"; +import type { Cluster } from "../../../common/cluster/cluster"; +import { ClusterStore } from "../../../common/cluster-store/cluster-store"; import { requestMain } from "../../../common/ipc"; import { clusterActivateHandler } from "../../../common/cluster-ipc"; import { catalogEntityRegistry } from "../../api/catalog-entity-registry"; diff --git a/src/renderer/components/cluster-manager/lens-views.ts b/src/renderer/components/cluster-manager/lens-views.ts index 784e645e59e6..2179a85e4cc2 100644 --- a/src/renderer/components/cluster-manager/lens-views.ts +++ b/src/renderer/components/cluster-manager/lens-views.ts @@ -22,7 +22,7 @@ import { action, IReactionDisposer, makeObservable, observable, when } from "mobx"; import logger from "../../../main/logger"; import { clusterVisibilityHandler } from "../../../common/cluster-ipc"; -import { ClusterStore } from "../../../common/cluster-store"; +import { ClusterStore } from "../../../common/cluster-store/cluster-store"; import type { ClusterId } from "../../../common/cluster-types"; import { getClusterFrameUrl, Singleton } from "../../utils"; import { ipcRenderer } from "electron"; diff --git a/src/renderer/components/cluster-settings/cluster-settings.tsx b/src/renderer/components/cluster-settings/cluster-settings.tsx index 762d0789fc35..d7081b098b87 100644 --- a/src/renderer/components/cluster-settings/cluster-settings.tsx +++ b/src/renderer/components/cluster-settings/cluster-settings.tsx @@ -21,7 +21,7 @@ import React from "react"; import type { KubernetesCluster } from "../../../common/catalog-entities"; -import { ClusterStore } from "../../../common/cluster-store"; +import { ClusterStore } from "../../../common/cluster-store/cluster-store"; import type { EntitySettingViewProps } from "../../../extensions/registries"; import type { CatalogEntity } from "../../api/catalog-entity"; import * as components from "./components"; diff --git a/src/renderer/components/cluster-settings/components/cluster-accessible-namespaces.tsx b/src/renderer/components/cluster-settings/components/cluster-accessible-namespaces.tsx index 62f426efe163..4691f735389e 100644 --- a/src/renderer/components/cluster-settings/components/cluster-accessible-namespaces.tsx +++ b/src/renderer/components/cluster-settings/components/cluster-accessible-namespaces.tsx @@ -21,7 +21,7 @@ import React from "react"; import { observer } from "mobx-react"; -import type { Cluster } from "../../../../main/cluster"; +import type { Cluster } from "../../../../common/cluster/cluster"; import { SubTitle } from "../../layout/sub-title"; import { EditableList } from "../../editable-list"; import { observable, makeObservable } from "mobx"; diff --git a/src/renderer/components/cluster-settings/components/cluster-icon-settings.tsx b/src/renderer/components/cluster-settings/components/cluster-icon-settings.tsx index a27860863e59..bc17e6a24b2d 100644 --- a/src/renderer/components/cluster-settings/components/cluster-icon-settings.tsx +++ b/src/renderer/components/cluster-settings/components/cluster-icon-settings.tsx @@ -20,7 +20,7 @@ */ import React from "react"; -import type { Cluster } from "../../../../main/cluster"; +import type { Cluster } from "../../../../common/cluster/cluster"; import { boundMethod } from "../../../utils"; import { observable } from "mobx"; import { observer } from "mobx-react"; diff --git a/src/renderer/components/cluster-settings/components/cluster-kubeconfig.tsx b/src/renderer/components/cluster-settings/components/cluster-kubeconfig.tsx index 583b44237a5f..3e63988fb7b5 100644 --- a/src/renderer/components/cluster-settings/components/cluster-kubeconfig.tsx +++ b/src/renderer/components/cluster-settings/components/cluster-kubeconfig.tsx @@ -20,7 +20,7 @@ */ import React from "react"; -import type { Cluster } from "../../../../main/cluster"; +import type { Cluster } from "../../../../common/cluster/cluster"; import { observer } from "mobx-react"; import { SubTitle } from "../../layout/sub-title"; import { boundMethod } from "../../../../common/utils"; diff --git a/src/renderer/components/cluster-settings/components/cluster-local-terminal-settings.tsx b/src/renderer/components/cluster-settings/components/cluster-local-terminal-settings.tsx index 22774a5af829..da545b542f19 100644 --- a/src/renderer/components/cluster-settings/components/cluster-local-terminal-settings.tsx +++ b/src/renderer/components/cluster-settings/components/cluster-local-terminal-settings.tsx @@ -21,7 +21,7 @@ import React, { useEffect, useState } from "react"; import { observer } from "mobx-react"; -import type { Cluster } from "../../../../main/cluster"; +import type { Cluster } from "../../../../common/cluster/cluster"; import { Input } from "../../input"; import { SubTitle } from "../../layout/sub-title"; import { stat } from "fs/promises"; diff --git a/src/renderer/components/cluster-settings/components/cluster-metrics-setting.tsx b/src/renderer/components/cluster-settings/components/cluster-metrics-setting.tsx index 847d38ec774f..4a4b2899b2ee 100644 --- a/src/renderer/components/cluster-settings/components/cluster-metrics-setting.tsx +++ b/src/renderer/components/cluster-settings/components/cluster-metrics-setting.tsx @@ -25,7 +25,7 @@ import { Select, SelectOption } from "../../select/select"; import { Icon } from "../../icon/icon"; import { Button } from "../../button/button"; import { SubTitle } from "../../layout/sub-title"; -import type { Cluster } from "../../../../main/cluster"; +import type { Cluster } from "../../../../common/cluster/cluster"; import { observable, reaction, makeObservable } from "mobx"; import { ClusterMetricsResourceType } from "../../../../common/cluster-types"; diff --git a/src/renderer/components/cluster-settings/components/cluster-name-setting.tsx b/src/renderer/components/cluster-settings/components/cluster-name-setting.tsx index af556c29b4f5..95e4084cafcd 100644 --- a/src/renderer/components/cluster-settings/components/cluster-name-setting.tsx +++ b/src/renderer/components/cluster-settings/components/cluster-name-setting.tsx @@ -20,7 +20,7 @@ */ import React from "react"; -import type { Cluster } from "../../../../main/cluster"; +import type { Cluster } from "../../../../common/cluster/cluster"; import { Input } from "../../input"; import { observable, autorun, makeObservable } from "mobx"; import { observer, disposeOnUnmount } from "mobx-react"; diff --git a/src/renderer/components/cluster-settings/components/cluster-node-shell-setting.tsx b/src/renderer/components/cluster-settings/components/cluster-node-shell-setting.tsx index d43141271903..96bbaf599fd5 100644 --- a/src/renderer/components/cluster-settings/components/cluster-node-shell-setting.tsx +++ b/src/renderer/components/cluster-settings/components/cluster-node-shell-setting.tsx @@ -19,7 +19,7 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import type { Cluster } from "../../../../main/cluster"; +import type { Cluster } from "../../../../common/cluster/cluster"; import { makeObservable, observable } from "mobx"; import { SubTitle } from "../../layout/sub-title"; import React from "react"; diff --git a/src/renderer/components/cluster-settings/components/cluster-prometheus-setting.tsx b/src/renderer/components/cluster-settings/components/cluster-prometheus-setting.tsx index c9a1acccc969..946a1455e807 100644 --- a/src/renderer/components/cluster-settings/components/cluster-prometheus-setting.tsx +++ b/src/renderer/components/cluster-settings/components/cluster-prometheus-setting.tsx @@ -21,7 +21,7 @@ import React from "react"; import { observer, disposeOnUnmount } from "mobx-react"; -import type { Cluster } from "../../../../main/cluster"; +import type { Cluster } from "../../../../common/cluster/cluster"; import { SubTitle } from "../../layout/sub-title"; import { Select, SelectOption } from "../../select"; import { Input } from "../../input"; diff --git a/src/renderer/components/cluster-settings/components/cluster-proxy-setting.tsx b/src/renderer/components/cluster-settings/components/cluster-proxy-setting.tsx index 046c57e47c59..a7e5773d43b4 100644 --- a/src/renderer/components/cluster-settings/components/cluster-proxy-setting.tsx +++ b/src/renderer/components/cluster-settings/components/cluster-proxy-setting.tsx @@ -22,7 +22,7 @@ import React from "react"; import { observable, autorun, makeObservable } from "mobx"; import { observer, disposeOnUnmount } from "mobx-react"; -import type { Cluster } from "../../../../main/cluster"; +import type { Cluster } from "../../../../common/cluster/cluster"; import { Input, InputValidators } from "../../input"; import { SubTitle } from "../../layout/sub-title"; diff --git a/src/renderer/components/cluster-settings/components/cluster-show-metrics.tsx b/src/renderer/components/cluster-settings/components/cluster-show-metrics.tsx index effdc161602c..0d20f4012de0 100644 --- a/src/renderer/components/cluster-settings/components/cluster-show-metrics.tsx +++ b/src/renderer/components/cluster-settings/components/cluster-show-metrics.tsx @@ -21,7 +21,7 @@ import React from "react"; import { disposeOnUnmount, observer } from "mobx-react"; -import type { Cluster } from "../../../../main/cluster"; +import type { Cluster } from "../../../../common/cluster/cluster"; import { observable, reaction, makeObservable } from "mobx"; import { Badge } from "../../badge/badge"; import { Icon } from "../../icon/icon"; diff --git a/src/renderer/components/command-palette/registered-commands/internal-commands.injectable.tsx b/src/renderer/components/command-palette/registered-commands/internal-commands.injectable.tsx index 4fc37a902e57..25220f73a115 100644 --- a/src/renderer/components/command-palette/registered-commands/internal-commands.injectable.tsx +++ b/src/renderer/components/command-palette/registered-commands/internal-commands.injectable.tsx @@ -22,7 +22,6 @@ import React from "react"; import * as routes from "../../../../common/routes"; import { EntitySettingRegistry, RegisteredEntitySetting } from "../../../../extensions/registries"; -import { createTerminalTab } from "../../dock/terminal.store"; import { HotbarAddCommand } from "../../hotbar/hotbar-add-command"; import { HotbarRemoveCommand } from "../../hotbar/hotbar-remove-command"; import { HotbarSwitchCommand } from "../../hotbar/hotbar-switch-command"; @@ -31,6 +30,9 @@ import { ActivateEntityCommand } from "../../activate-entity-command"; import type { CommandContext, CommandRegistration } from "./commands"; import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; import commandOverlayInjectable from "../command-overlay.injectable"; +import createTerminalTabInjectable + from "../../dock/create-terminal-tab/create-terminal-tab.injectable"; +import type { DockTabCreate } from "../../dock/dock-store/dock.store"; export function isKubernetesClusterActive(context: CommandContext): boolean { return context.entity?.kind === "KubernetesCluster"; @@ -39,9 +41,10 @@ export function isKubernetesClusterActive(context: CommandContext): boolean { interface Dependencies { openCommandDialog: (component: React.ReactElement) => void; getEntitySettingItems: (kind: string, apiVersion: string, source?: string) => RegisteredEntitySetting[]; + createTerminalTab: () => DockTabCreate } -function getInternalCommands({ openCommandDialog, getEntitySettingItems }: Dependencies): CommandRegistration[] { +function getInternalCommands({ openCommandDialog, getEntitySettingItems, createTerminalTab }: Dependencies): CommandRegistration[] { return [ { id: "app.showPreferences", @@ -224,6 +227,7 @@ const internalCommandsInjectable = getInjectable({ getEntitySettingItems: EntitySettingRegistry .getInstance() .getItemsForKind, + createTerminalTab: di.inject(createTerminalTabInjectable), }), lifecycle: lifecycleEnum.singleton, }); 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 36c9ddbc2291..a26eac252f62 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 @@ -25,9 +25,12 @@ import mockFs from "mock-fs"; import React from "react"; import * as selectEvent from "react-select-event"; -import { Cluster } from "../../../../main/cluster"; +import type { Cluster } from "../../../../common/cluster/cluster"; import { DeleteClusterDialog } from "../delete-cluster-dialog"; -import { AppPaths } from "../../../../common/app-paths"; + +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"; jest.mock("electron", () => ({ app: { @@ -45,8 +48,6 @@ jest.mock("electron", () => ({ }, })); -AppPaths.init(); - const kubeconfig = ` apiVersion: v1 clusters: @@ -101,6 +102,22 @@ users: let config: KubeConfig; describe("", () => { + let createCluster: (model: ClusterModel) => Cluster; + + beforeEach(async () => { + const { mainDi, runSetups } = getDisForUnitTesting({ doGeneralOverrides: true }); + + mockFs(); + + await runSetups(); + + createCluster = mainDi.inject(createClusterInjectionToken); + }); + + afterEach(() => { + mockFs.restore(); + }); + describe("Kubeconfig with different clusters", () => { beforeEach(async () => { const mockOpts = { @@ -124,7 +141,7 @@ describe("", () => { }); it("shows warning when deleting non-current-context cluster", () => { - const cluster = new Cluster({ + const cluster = createCluster({ id: "test", contextName: "test", preferences: { @@ -142,7 +159,7 @@ describe("", () => { }); it("shows warning when deleting current-context cluster", () => { - const cluster = new Cluster({ + const cluster = createCluster({ id: "other-cluster", contextName: "other-context", preferences: { @@ -159,7 +176,7 @@ describe("", () => { }); it("shows context switcher when deleting current cluster", async () => { - const cluster = new Cluster({ + const cluster = createCluster({ id: "other-cluster", contextName: "other-context", preferences: { @@ -180,7 +197,7 @@ describe("", () => { }); it("shows context switcher after checkbox click", async () => { - const cluster = new Cluster({ + const cluster = createCluster({ id: "some-cluster", contextName: "test", preferences: { @@ -205,7 +222,7 @@ describe("", () => { }); it("shows warning for internal kubeconfig cluster", () => { - const cluster = new Cluster({ + const cluster = createCluster({ id: "some-cluster", contextName: "test", preferences: { @@ -243,7 +260,7 @@ describe("", () => { }); it("shows warning if no other contexts left", () => { - const cluster = new Cluster({ + const cluster = createCluster({ id: "other-cluster", contextName: "other-context", preferences: { 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 e6a03444b657..9f32dd59e980 100644 --- a/src/renderer/components/delete-cluster-dialog/delete-cluster-dialog.tsx +++ b/src/renderer/components/delete-cluster-dialog/delete-cluster-dialog.tsx @@ -26,7 +26,7 @@ import React from "react"; import { Button } from "../button"; import type { KubeConfig } from "@kubernetes/client-node"; -import type { Cluster } from "../../../main/cluster"; +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"; diff --git a/src/renderer/components/dock/__test__/dock-tabs.test.tsx b/src/renderer/components/dock/__test__/dock-tabs.test.tsx index 9d4ae2b345e4..4b95bc0c40fb 100644 --- a/src/renderer/components/dock/__test__/dock-tabs.test.tsx +++ b/src/renderer/components/dock/__test__/dock-tabs.test.tsx @@ -20,16 +20,20 @@ */ import React from "react"; -import { fireEvent, render } from "@testing-library/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"; +import { DockStore, DockTab, TabKind } from "../dock-store/dock.store"; import { noop } from "../../../utils"; import { ThemeStore } from "../../../theme.store"; -import { TerminalStore } from "../terminal.store"; import { UserStore } from "../../../../common/user-store"; -import { AppPaths } from "../../../../common/app-paths"; +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"; jest.mock("electron", () => ({ app: { @@ -46,7 +50,6 @@ jest.mock("electron", () => ({ handle: jest.fn(), }, })); -AppPaths.init(); const initialTabs: DockTab[] = [ { id: "terminal", kind: TabKind.TERMINAL, title: "Terminal", pinned: false }, @@ -56,7 +59,7 @@ const initialTabs: DockTab[] = [ { id: "logs", kind: TabKind.POD_LOGS, title: "Logs", pinned: false }, ]; -const getComponent = () => ( +const getComponent = (dockStore: DockStore) => ( ( /> ); -const renderTabs = () => render(getComponent()); -const getTabKinds = () => dockStore.tabs.map(tab => tab.kind); +const getTabKinds = (dockStore: DockStore) => dockStore.tabs.map((tab) => tab.kind); describe("", () => { + let dockStore: DockStore; + let render: DiRender; + beforeEach(async () => { + const di = getDiForUnitTesting({ doGeneralOverrides: true }); + + + render = renderFor(di); + + di.override( + directoryForUserDataInjectable, + () => "some-test-suite-specific-directory-for-user-data", + ); + + await di.runSetups(); + + dockStore = di.inject(dockStoreInjectable); + UserStore.createInstance(); ThemeStore.createInstance(); - TerminalStore.createInstance(); await dockStore.whenReady; dockStore.tabs = initialTabs; }); afterEach(() => { ThemeStore.resetInstance(); - TerminalStore.resetInstance(); UserStore.resetInstance(); - fse.remove("tmp"); + + // 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 } = renderTabs(); + const { container } = render(getComponent(dockStore)); expect(container).toBeInstanceOf(HTMLElement); }); it("has 6 tabs (1 tab is initial terminal)", () => { - const { container } = renderTabs(); + const { container } = render(getComponent(dockStore)); const tabs = container.querySelectorAll(".Tab"); expect(tabs.length).toBe(initialTabs.length); }); it("opens a context menu", () => { - const { container, getByText } = renderTabs(); + const { container, getByText } = render(getComponent(dockStore)); const tab = container.querySelector(".Tab"); fireEvent.contextMenu(tab); @@ -106,17 +125,22 @@ describe("", () => { }); it("closes selected tab", () => { - const { container, getByText, rerender } = renderTabs(); + const { container, getByText, rerender } = render( + getComponent(dockStore), + ); + const tab = container.querySelector(".Tab"); fireEvent.contextMenu(tab); fireEvent.click(getByText("Close")); - rerender(getComponent()); + + rerender(getComponent(dockStore)); const tabs = container.querySelectorAll(".Tab"); expect(tabs.length).toBe(initialTabs.length - 1); - expect(getTabKinds()).toEqual([ + + expect(getTabKinds(dockStore)).toEqual([ TabKind.CREATE_RESOURCE, TabKind.EDIT_RESOURCE, TabKind.INSTALL_CHART, @@ -125,42 +149,42 @@ describe("", () => { }); it("closes other tabs", () => { - const { container, getByText, rerender } = renderTabs(); + const { container, getByText, rerender } = render(getComponent(dockStore)); const tab = container.querySelectorAll(".Tab")[3]; fireEvent.contextMenu(tab); fireEvent.click(getByText("Close other tabs")); - rerender(getComponent()); + rerender(getComponent(dockStore)); const tabs = container.querySelectorAll(".Tab"); expect(tabs.length).toBe(1); - expect(getTabKinds()).toEqual([initialTabs[3].kind]); + expect(getTabKinds(dockStore)).toEqual([initialTabs[3].kind]); }); it("closes all tabs", () => { - const { container, getByText, rerender } = renderTabs(); + 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()); + rerender(getComponent(dockStore)); const tabs = container.querySelectorAll(".Tab"); expect(tabs.length).toBe(0); }); it("closes tabs to the right", () => { - const { container, getByText, rerender } = renderTabs(); + 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()); + rerender(getComponent(dockStore)); - expect(getTabKinds()).toEqual( + expect(getTabKinds(dockStore)).toEqual( initialTabs.slice(0, 4).map(tab => tab.kind), ); }); @@ -169,7 +193,7 @@ describe("", () => { dockStore.tabs = [{ id: "terminal", kind: TabKind.TERMINAL, title: "Terminal", pinned: false, }]; - const { container, getByText } = renderTabs(); + const { container, getByText } = render(getComponent(dockStore)); const tab = container.querySelector(".Tab"); fireEvent.contextMenu(tab); @@ -185,7 +209,7 @@ describe("", () => { { id: "terminal", kind: TabKind.TERMINAL, title: "Terminal", pinned: false }, { id: "logs", kind: TabKind.POD_LOGS, title: "Pod Logs", pinned: false }, ]; - const { container, getByText } = renderTabs(); + const { container, getByText } = render(getComponent(dockStore)); const tab = container.querySelectorAll(".Tab")[1]; fireEvent.contextMenu(tab); 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 9fe75a975606..e8e89bf775eb 100644 --- a/src/renderer/components/dock/__test__/log-resource-selector.test.tsx +++ b/src/renderer/components/dock/__test__/log-resource-selector.test.tsx @@ -21,17 +21,19 @@ import React from "react"; import "@testing-library/jest-dom/extend-expect"; -import { render } from "@testing-library/react"; 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"; +import type { LogTabData } from "../log-tab-store/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 { AppPaths } from "../../../../common/app-paths"; +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"; jest.mock("electron", () => ({ app: { @@ -49,15 +51,12 @@ jest.mock("electron", () => ({ }, })); -AppPaths.init(); - const getComponent = (tabData: LogTabData) => { return ( ); }; @@ -84,10 +83,22 @@ const getFewPodsTabData = (): LogTabData => { }; describe("", () => { - beforeEach(() => { + 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(); }); 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 a342ddce7985..88ad6f344b56 100644 --- a/src/renderer/components/dock/__test__/log-tab.store.test.ts +++ b/src/renderer/components/dock/__test__/log-tab.store.test.ts @@ -23,49 +23,47 @@ 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 { dockStore } from "../dock.store"; -import { logTabStore } from "../log-tab.store"; import { deploymentPod1, deploymentPod2, deploymentPod3, dockerPod } from "./pod.mock"; -import fse from "fs-extra"; import { mockWindow } from "../../../../../__mocks__/windowMock"; -import { AppPaths } from "../../../../common/app-paths"; +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(); -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(), - }, -})); - -AppPaths.init(); - podsStore.items.push(new Pod(dockerPod)); podsStore.items.push(new Pod(deploymentPod1)); podsStore.items.push(new Pod(deploymentPod2)); describe("log tab store", () => { - beforeEach(() => { + let logTabStore: LogTabStore; + let dockStore: DockStore; + + beforeEach(async () => { + const di = getDiForUnitTesting({ doGeneralOverrides: true }); + + mockFs(); + + di.override(directoryForUserDataInjectable, () => "some-directory-for-user-data"); + + await di.runSetups(); + + dockStore = di.inject(dockStoreInjectable); + logTabStore = di.inject(logTabStoreInjectable); + UserStore.createInstance(); ThemeStore.createInstance(); }); afterEach(() => { - logTabStore.reset(); - dockStore.reset(); UserStore.resetInstance(); ThemeStore.resetInstance(); - fse.remove("tmp"); + mockFs.restore(); }); it("creates log tab without sibling pods", () => { 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 new file mode 100644 index 000000000000..8eef85891218 --- /dev/null +++ b/src/renderer/components/dock/create-install-chart-tab/create-install-chart-tab.injectable.ts @@ -0,0 +1,35 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +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 new file mode 100644 index 000000000000..953ed58173c5 --- /dev/null +++ b/src/renderer/components/dock/create-install-chart-tab/create-install-chart-tab.ts @@ -0,0 +1,60 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +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 new file mode 100644 index 000000000000..741543bc8401 --- /dev/null +++ b/src/renderer/components/dock/create-resource-store/create-resource-store.injectable.ts @@ -0,0 +1,35 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +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.ts b/src/renderer/components/dock/create-resource-store/create-resource.store.ts similarity index 86% rename from src/renderer/components/dock/create-resource.store.ts rename to src/renderer/components/dock/create-resource-store/create-resource.store.ts index 0ba7ced44d94..a535dfa1e440 100644 --- a/src/renderer/components/dock/create-resource.store.ts +++ b/src/renderer/components/dock/create-resource-store/create-resource.store.ts @@ -25,15 +25,21 @@ import os from "os"; import groupBy from "lodash/groupBy"; import filehound from "filehound"; import { watch } from "chokidar"; -import { autoBind } from "../../utils"; -import { DockTabStore } from "./dock-tab.store"; -import { dockStore, DockTabCreateSpecific, TabKind } from "./dock.store"; +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() { - super({ + constructor(protected dependencies: Dependencies) { + super(dependencies, { storageKey: "create_resource", }); + autoBind(this); fs.ensureDirSync(this.userTemplatesFolder); } @@ -78,13 +84,3 @@ export class CreateResourceStore extends DockTabStore { }); } } - -export const createResourceStore = new CreateResourceStore(); - -export function createResourceTab(tabParams: DockTabCreateSpecific = {}) { - return dockStore.createTab({ - title: "Create resource", - ...tabParams, - kind: TabKind.CREATE_RESOURCE, - }); -} 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 new file mode 100644 index 000000000000..5eb4c62551a5 --- /dev/null +++ b/src/renderer/components/dock/create-resource-tab/create-resource-tab.injectable.ts @@ -0,0 +1,33 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +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 new file mode 100644 index 000000000000..0b9efabe4c1a --- /dev/null +++ b/src/renderer/components/dock/create-resource-tab/create-resource-tab.ts @@ -0,0 +1,34 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +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 index 513b7134bd88..c6689d786aea 100644 --- a/src/renderer/components/dock/create-resource.tsx +++ b/src/renderer/components/dock/create-resource.tsx @@ -28,8 +28,8 @@ import { GroupSelectOption, Select, SelectOption } from "../select"; import yaml from "js-yaml"; import { makeObservable, observable } from "mobx"; import { observer } from "mobx-react"; -import { createResourceStore } from "./create-resource.store"; -import type { DockTab } from "./dock.store"; +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"; @@ -40,25 +40,32 @@ 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 -export class CreateResource extends React.Component { +class NonInjectedCreateResource extends React.Component { @observable currentTemplates: Map = new Map(); @observable error = ""; @observable templates: GroupSelectOption[] = []; - constructor(props: Props) { + constructor(props: Props & Dependencies) { super(props); makeObservable(this); } componentDidMount() { - createResourceStore.getMergedTemplates().then(v => this.updateGroupSelectOptions(v)); - createResourceStore.watchUserTemplates(() => createResourceStore.getMergedTemplates().then(v => this.updateGroupSelectOptions(v))); + 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) { @@ -77,7 +84,7 @@ export class CreateResource extends React.Component { } get data() { - return createResourceStore.getData(this.tabId); + return this.props.createResourceStore.getData(this.tabId); } get currentTemplate() { @@ -86,7 +93,7 @@ export class CreateResource extends React.Component { onChange = (value: string) => { this.error = ""; // reset first, validation goes later - createResourceStore.setData(this.tabId, value); + this.props.createResourceStore.setData(this.tabId, value); }; onError = (error: Error | string) => { @@ -96,7 +103,7 @@ export class CreateResource extends React.Component { onSelectTemplate = (item: SelectOption) => { this.currentTemplates.set(this.tabId, item); fs.readFile(item.value, "utf8").then(v => { - createResourceStore.setData(this.tabId, v); + this.props.createResourceStore.setData(this.tabId, v); }); }; @@ -180,3 +187,14 @@ export class CreateResource extends React.Component { ); } } + +export const CreateResource = withInjectables( + NonInjectedCreateResource, + + { + getProps: (di, props) => ({ + createResourceStore: di.inject(createResourceStoreInjectable), + ...props, + }), + }, +); diff --git a/src/renderer/components/dock/create-terminal-tab/create-terminal-tab.injectable.ts b/src/renderer/components/dock/create-terminal-tab/create-terminal-tab.injectable.ts new file mode 100644 index 000000000000..1b50bfc3190f --- /dev/null +++ b/src/renderer/components/dock/create-terminal-tab/create-terminal-tab.injectable.ts @@ -0,0 +1,33 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +import { createTerminalTab } from "./create-terminal-tab"; +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import dockStoreInjectable from "../dock-store/dock-store.injectable"; + +const createTerminalTabInjectable = getInjectable({ + instantiate: (di) => createTerminalTab({ + dockStore: di.inject(dockStoreInjectable), + }), + + lifecycle: lifecycleEnum.singleton, +}); + +export default createTerminalTabInjectable; diff --git a/src/renderer/components/dock/create-terminal-tab/create-terminal-tab.ts b/src/renderer/components/dock/create-terminal-tab/create-terminal-tab.ts new file mode 100644 index 000000000000..f6835f82476b --- /dev/null +++ b/src/renderer/components/dock/create-terminal-tab/create-terminal-tab.ts @@ -0,0 +1,38 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +import { + DockStore, + DockTabCreateSpecific, + TabKind, +} from "../dock-store/dock.store"; + +interface Dependencies { + dockStore: DockStore; +} + +export const createTerminalTab = + ({ dockStore }: Dependencies) => + (tabParams: DockTabCreateSpecific = {}) => + dockStore.createTab({ + title: `Terminal`, + ...tabParams, + kind: TabKind.TERMINAL, + }); diff --git a/src/renderer/components/dock/create-upgrade-chart-tab/create-upgrade-chart-tab.injectable.ts b/src/renderer/components/dock/create-upgrade-chart-tab/create-upgrade-chart-tab.injectable.ts new file mode 100644 index 000000000000..e4057eb80b97 --- /dev/null +++ b/src/renderer/components/dock/create-upgrade-chart-tab/create-upgrade-chart-tab.injectable.ts @@ -0,0 +1,35 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { createUpgradeChartTab } from "./create-upgrade-chart-tab"; +import upgradeChartStoreInjectable from "../upgrade-chart-store/upgrade-chart-store.injectable"; +import dockStoreInjectable from "../dock-store/dock-store.injectable"; + +const createUpgradeChartTabInjectable = getInjectable({ + instantiate: (di) => createUpgradeChartTab({ + upgradeChartStore: di.inject(upgradeChartStoreInjectable), + dockStore: di.inject(dockStoreInjectable), + }), + + lifecycle: lifecycleEnum.singleton, +}); + +export default createUpgradeChartTabInjectable; diff --git a/src/renderer/components/dock/create-upgrade-chart-tab/create-upgrade-chart-tab.ts b/src/renderer/components/dock/create-upgrade-chart-tab/create-upgrade-chart-tab.ts new file mode 100644 index 000000000000..721ed5b2099c --- /dev/null +++ b/src/renderer/components/dock/create-upgrade-chart-tab/create-upgrade-chart-tab.ts @@ -0,0 +1,57 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +import type { HelmRelease } from "../../../../common/k8s-api/endpoints/helm-releases.api"; +import { DockStore, DockTabCreateSpecific, TabKind } from "../dock-store/dock.store"; +import type { UpgradeChartStore } from "../upgrade-chart-store/upgrade-chart.store"; + +interface Dependencies { + upgradeChartStore: UpgradeChartStore; + dockStore: DockStore +} + +export const createUpgradeChartTab = + ({ upgradeChartStore, dockStore }: Dependencies) => + (release: HelmRelease, tabParams: DockTabCreateSpecific = {}) => { + let tab = upgradeChartStore.getTabByRelease(release.getName()); + + if (tab) { + dockStore.open(); + dockStore.selectTab(tab.id); + } + + if (!tab) { + tab = dockStore.createTab( + { + title: `Helm Upgrade: ${release.getName()}`, + ...tabParams, + kind: TabKind.UPGRADE_CHART, + }, + false, + ); + + upgradeChartStore.setData(tab.id, { + releaseName: release.getName(), + releaseNamespace: release.getNs(), + }); + } + + return tab; + }; diff --git a/src/renderer/components/dock/dock-store/dock-storage/dock-storage.injectable.ts b/src/renderer/components/dock/dock-store/dock-storage/dock-storage.injectable.ts new file mode 100644 index 000000000000..59eb50a99ded --- /dev/null +++ b/src/renderer/components/dock/dock-store/dock-storage/dock-storage.injectable.ts @@ -0,0 +1,45 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import createStorageInjectable from "../../../../utils/create-storage/create-storage.injectable"; +import { DockStorageState, TabKind } from "../dock.store"; + +const dockStorageInjectable = getInjectable({ + instantiate: (di) => { + const createStorage = di.inject(createStorageInjectable); + + return createStorage("dock", { + height: 300, + tabs: [ + { + id: "terminal", + kind: TabKind.TERMINAL, + title: "Terminal", + pinned: false, + }, + ], + }); + }, + + lifecycle: lifecycleEnum.singleton, +}); + +export default dockStorageInjectable; diff --git a/src/renderer/components/dock/dock-store/dock-store.injectable.ts b/src/renderer/components/dock/dock-store/dock-store.injectable.ts new file mode 100644 index 000000000000..d9468083b29f --- /dev/null +++ b/src/renderer/components/dock/dock-store/dock-store.injectable.ts @@ -0,0 +1,34 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { DockStore } from "./dock.store"; +import dockStorageInjectable from "./dock-storage/dock-storage.injectable"; + +const dockStoreInjectable = getInjectable({ + instantiate: (di) => + new DockStore({ + storage: di.inject(dockStorageInjectable), + }), + + lifecycle: lifecycleEnum.singleton, +}); + +export default dockStoreInjectable; diff --git a/src/renderer/components/dock/dock.store.ts b/src/renderer/components/dock/dock-store/dock.store.ts similarity index 89% rename from src/renderer/components/dock/dock.store.ts rename to src/renderer/components/dock/dock-store/dock.store.ts index 372ecbf8710b..025ece749912 100644 --- a/src/renderer/components/dock/dock.store.ts +++ b/src/renderer/components/dock/dock-store/dock.store.ts @@ -21,7 +21,7 @@ import * as uuid from "uuid"; import { action, comparer, computed, makeObservable, observable, reaction, runInAction } from "mobx"; -import { autoBind, createStorage } from "../../utils"; +import { autoBind, StorageHelper } from "../../../utils"; import throttle from "lodash/throttle"; export type TabId = string; @@ -113,8 +113,12 @@ export interface DockTabCloseEvent { tabId: TabId; // closed tab id } +interface Dependencies { + storage: StorageHelper +} + export class DockStore implements DockStorageState { - constructor() { + constructor(private dependencies: Dependencies) { makeObservable(this); autoBind(this); this.init(); @@ -123,56 +127,53 @@ export class DockStore implements DockStorageState { readonly minHeight = 100; @observable fullSize = false; - private storage = createStorage("dock", { - height: 300, - tabs: [ - { id: "terminal", kind: TabKind.TERMINAL, title: "Terminal", pinned: false }, - ], - }); - get whenReady() { - return this.storage.whenReady; + return this.dependencies.storage.whenReady; } + @computed get isOpen(): boolean { - return this.storage.get().isOpen; + return this.dependencies.storage.value.isOpen; } set isOpen(isOpen: boolean) { - this.storage.merge({ isOpen }); + this.dependencies.storage.merge({ isOpen }); } + @computed get height(): number { - return this.storage.get().height; + return this.dependencies.storage.value.height; } set height(height: number) { - this.storage.merge({ + this.dependencies.storage.merge({ height: Math.max(this.minHeight, Math.min(height || this.minHeight, this.maxHeight)), }); } + @computed get tabs(): DockTab[] { - return this.storage.get().tabs; + return this.dependencies.storage.value.tabs; } set tabs(tabs: DockTab[]) { - this.storage.merge({ tabs }); + this.dependencies.storage.merge({ tabs }); } + @computed get selectedTabId(): TabId | undefined { - return this.storage.get().selectedTabId - || ( - this.tabs.length > 0 - ? this.tabs[0]?.id - : undefined - ); + const storageData = this.dependencies.storage.value; + + return ( + storageData.selectedTabId || + (this.tabs.length > 0 ? this.tabs[0]?.id : undefined) + ); } set selectedTabId(tabId: TabId) { if (tabId && !this.getTabById(tabId)) return; // skip invalid ids - this.storage.merge({ selectedTabId: tabId }); + this.dependencies.storage.merge({ selectedTabId: tabId }); } @computed get selectedTab() { @@ -206,7 +207,7 @@ export class DockStore implements DockStorageState { } onTabClose(callback: (evt: DockTabCloseEvent) => void, opts: { fireImmediately?: boolean } = {}) { - return reaction(() => dockStore.tabs.map(tab => tab.id), (tabs: TabId[], prevTabs?: TabId[]) => { + return reaction(() => this.tabs.map(tab => tab.id), (tabs: TabId[], prevTabs?: TabId[]) => { if (!Array.isArray(prevTabs)) { return; // tabs not yet modified } @@ -292,7 +293,7 @@ export class DockStore implements DockStorageState { } @action - createTab(rawTabDesc: DockTabCreate, addNumber = true): DockTab { + createTab = (rawTabDesc: DockTabCreate, addNumber = true): DockTab => { const { id = uuid.v4(), kind, @@ -322,7 +323,7 @@ export class DockStore implements DockStorageState { this.open(); return tab; - } + }; @action closeTab(tabId: TabId) { @@ -381,8 +382,6 @@ export class DockStore implements DockStorageState { @action reset() { - this.storage?.reset(); + this.dependencies.storage?.reset(); } } - -export const dockStore = new DockStore(); diff --git a/src/renderer/components/dock/dock-tab-store/create-dock-tab-store.injectable.ts b/src/renderer/components/dock/dock-tab-store/create-dock-tab-store.injectable.ts new file mode 100644 index 000000000000..a3a99876bb3e --- /dev/null +++ b/src/renderer/components/dock/dock-tab-store/create-dock-tab-store.injectable.ts @@ -0,0 +1,39 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { DockTabStore, DockTabStoreOptions } from "./dock-tab.store"; +import dockStoreInjectable from "../dock-store/dock-store.injectable"; +import createStorageInjectable from "../../../utils/create-storage/create-storage.injectable"; + +const createDockTabStoreInjectable = getInjectable({ + instantiate: (di) => { + const dependencies = { + dockStore: di.inject(dockStoreInjectable), + createStorage: di.inject(createStorageInjectable), + }; + + return (options: DockTabStoreOptions = {}) => new DockTabStore(dependencies, options); + }, + + lifecycle: lifecycleEnum.singleton, +}); + +export default createDockTabStoreInjectable; diff --git a/src/renderer/components/dock/dock-tab.store.ts b/src/renderer/components/dock/dock-tab-store/dock-tab.store.ts similarity index 83% rename from src/renderer/components/dock/dock-tab.store.ts rename to src/renderer/components/dock/dock-tab-store/dock-tab.store.ts index a554e3fbd2f3..38f1897f0cbf 100644 --- a/src/renderer/components/dock/dock-tab.store.ts +++ b/src/renderer/components/dock/dock-tab-store/dock-tab.store.ts @@ -20,8 +20,8 @@ */ import { autorun, observable, reaction } from "mobx"; -import { autoBind, createStorage, StorageHelper, toJS } from "../../utils"; -import { dockStore, TabId } from "./dock.store"; +import { autoBind, StorageHelper, toJS } from "../../../utils"; +import type { DockStore, TabId } from "../dock-store/dock.store"; export interface DockTabStoreOptions { autoInit?: boolean; // load data from storage when `storageKey` is provided and bind events, default: true @@ -30,11 +30,16 @@ export interface DockTabStoreOptions { export type DockTabStorageState = Record; +interface Dependencies { + dockStore: DockStore + createStorage: (storageKey: string, options: DockTabStorageState) => StorageHelper> +} + export class DockTabStore { protected storage?: StorageHelper>; protected data = observable.map(); - constructor(protected options: DockTabStoreOptions = {}) { + constructor(protected dependencies: Dependencies, protected options: DockTabStoreOptions) { autoBind(this); this.options = { @@ -52,16 +57,17 @@ export class DockTabStore { // auto-save to local-storage if (storageKey) { - this.storage = createStorage(storageKey, {}); + this.storage = this.dependencies.createStorage(storageKey, {}); + this.storage.whenReady.then(() => { - this.data.replace(this.storage.get()); + this.data.replace(this.storage.value); reaction(() => this.toJSON(), data => this.storage.set(data)); }); } // clear data for closed tabs autorun(() => { - const currentTabs = dockStore.tabs.map(tab => tab.id); + const currentTabs = this.dependencies.dockStore.tabs.map(tab => tab.id); Array.from(this.data.keys()).forEach(tabId => { if (!currentTabs.includes(tabId)) { diff --git a/src/renderer/components/dock/dock-tab.tsx b/src/renderer/components/dock/dock-tab.tsx index 6bd186579a22..90208b0e5368 100644 --- a/src/renderer/components/dock/dock-tab.tsx +++ b/src/renderer/components/dock/dock-tab.tsx @@ -24,22 +24,28 @@ import "./dock-tab.scss"; import React from "react"; import { observer } from "mobx-react"; import { boundMethod, cssNames, prevDefault, isMiddleClick } from "../../utils"; -import { dockStore, DockTab as DockTabModel } from "./dock.store"; +import type { DockStore, DockTab as DockTabModel } from "./dock-store/dock.store"; import { Tab, TabProps } from "../tabs"; import { Icon } from "../icon"; import { Menu, MenuItem } from "../menu"; import { observable, makeObservable } from "mobx"; import { isMac } from "../../../common/vars"; +import { withInjectables } from "@ogre-tools/injectable-react"; +import dockStoreInjectable from "./dock-store/dock-store.injectable"; export interface DockTabProps extends TabProps { moreActions?: React.ReactNode; } +interface Dependencies { + dockStore: DockStore +} + @observer -export class DockTab extends React.Component { +class NonInjectedDockTab extends React.Component { @observable menuVisible = false; - constructor(props: DockTabProps) { + constructor(props: DockTabProps & Dependencies) { super(props); makeObservable(this); } @@ -50,11 +56,11 @@ export class DockTab extends React.Component { @boundMethod close() { - dockStore.closeTab(this.tabId); + this.props.dockStore.closeTab(this.tabId); } renderMenu() { - const { closeTab, closeAllTabs, closeOtherTabs, closeTabsToTheRight, tabs, getTabIndex } = dockStore; + const { closeTab, closeAllTabs, closeOtherTabs, closeTabsToTheRight, tabs, getTabIndex } = this.props.dockStore; const closeAllDisabled = tabs.length === 1; const closeOtherDisabled = tabs.length === 1; const closeRightDisabled = getTabIndex(this.tabId) === tabs.length - 1; @@ -86,7 +92,7 @@ export class DockTab extends React.Component { } render() { - const { className, moreActions, ...tabProps } = this.props; + const { className, moreActions, dockStore, ...tabProps } = this.props; const { title, pinned } = tabProps.value; const label = (
@@ -116,3 +122,14 @@ export class DockTab extends React.Component { ); } } + +export const DockTab = withInjectables( + NonInjectedDockTab, + + { + getProps: (di, props) => ({ + dockStore: di.inject(dockStoreInjectable), + ...props, + }), + }, +); diff --git a/src/renderer/components/dock/dock-tabs.tsx b/src/renderer/components/dock/dock-tabs.tsx index 372ef100d92b..7a218320e602 100644 --- a/src/renderer/components/dock/dock-tabs.tsx +++ b/src/renderer/components/dock/dock-tabs.tsx @@ -24,8 +24,8 @@ import React, { Fragment } from "react"; import { Icon } from "../icon"; import { Tabs } from "../tabs/tabs"; import { DockTab } from "./dock-tab"; -import type { DockTab as DockTabModel } from "./dock.store"; -import { TabKind } from "./dock.store"; +import type { DockTab as DockTabModel } from "./dock-store/dock.store"; +import { TabKind } from "./dock-store/dock.store"; import { TerminalTab } from "./terminal-tab"; interface Props { diff --git a/src/renderer/components/dock/dock.tsx b/src/renderer/components/dock/dock.tsx index 747fd37a5ce9..29dfc130a2cb 100644 --- a/src/renderer/components/dock/dock.tsx +++ b/src/renderer/components/dock/dock.tsx @@ -30,22 +30,30 @@ import { MenuItem } from "../menu"; import { MenuActions } from "../menu/menu-actions"; import { ResizeDirection, ResizingAnchor } from "../resizing-anchor"; import { CreateResource } from "./create-resource"; -import { createResourceTab } from "./create-resource.store"; import { DockTabs } from "./dock-tabs"; -import { dockStore, DockTab, TabKind } from "./dock.store"; +import { DockStore, DockTab, TabKind } from "./dock-store/dock.store"; import { EditResource } from "./edit-resource"; import { InstallChart } from "./install-chart"; import { Logs } from "./logs"; import { TerminalWindow } from "./terminal-window"; -import { createTerminalTab } from "./terminal.store"; import { UpgradeChart } from "./upgrade-chart"; +import { withInjectables } from "@ogre-tools/injectable-react"; +import createResourceTabInjectable from "./create-resource-tab/create-resource-tab.injectable"; +import dockStoreInjectable from "./dock-store/dock-store.injectable"; +import createTerminalTabInjectable from "./create-terminal-tab/create-terminal-tab.injectable"; interface Props { className?: string; } +interface Dependencies { + createResourceTab: () => void + createTerminalTab: () => void + dockStore: DockStore +} + @observer -export class Dock extends React.Component { +class NonInjectedDock extends React.Component { private element = React.createRef(); componentDidMount() { @@ -57,7 +65,7 @@ export class Dock extends React.Component { } onKeyDown = (evt: KeyboardEvent) => { - const { close, selectedTab, closeTab } = dockStore; + const { close, selectedTab, closeTab } = this.props.dockStore; const { code, ctrlKey, metaKey, shiftKey } = evt; // Determine if user working inside or using any other areas in app const dockIsFocused = this.element?.current.contains(document.activeElement); @@ -75,7 +83,7 @@ export class Dock extends React.Component { }; onChangeTab = (tab: DockTab) => { - const { open, selectTab } = dockStore; + const { open, selectTab } = this.props.dockStore; open(); selectTab(tab.id); @@ -93,14 +101,14 @@ export class Dock extends React.Component { case TabKind.UPGRADE_CHART: return ; case TabKind.POD_LOGS: - return ; + return ; case TabKind.TERMINAL: return ; } } renderTabContent() { - const { isOpen, height, selectedTab } = dockStore; + const { isOpen, height, selectedTab } = this.props.dockStore; if (!isOpen || !selectedTab) return null; @@ -112,8 +120,8 @@ export class Dock extends React.Component { } render() { - const { className } = this.props; - const { isOpen, toggle, tabs, toggleFillSize, selectedTab, hasTabs, fullSize } = dockStore; + const { className, dockStore } = this.props; + const { isOpen, toggle, tabs, toggleFillSize, selectedTab, hasTabs, fullSize } = this.props.dockStore; return (
{
- createTerminalTab()}> + this.props.createTerminalTab()}> Terminal session - createResourceTab()}> + this.props.createResourceTab()}> Create resource @@ -173,3 +181,17 @@ export class Dock extends React.Component { ); } } + +export const Dock = withInjectables( + NonInjectedDock, + + { + getProps: (di, props) => ({ + createResourceTab: di.inject(createResourceTabInjectable), + dockStore: di.inject(dockStoreInjectable), + createTerminalTab: di.inject(createTerminalTabInjectable), + ...props, + }), + }, +); + diff --git a/src/renderer/components/dock/edit-resource-store/edit-resource-store.injectable.ts b/src/renderer/components/dock/edit-resource-store/edit-resource-store.injectable.ts new file mode 100644 index 000000000000..dc1c33090d52 --- /dev/null +++ b/src/renderer/components/dock/edit-resource-store/edit-resource-store.injectable.ts @@ -0,0 +1,36 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import dockStoreInjectable from "../dock-store/dock-store.injectable"; +import { EditResourceStore } from "./edit-resource.store"; +import createStorageInjectable from "../../../utils/create-storage/create-storage.injectable"; + +const editResourceStoreInjectable = getInjectable({ + instantiate: (di) => + new EditResourceStore({ + dockStore: di.inject(dockStoreInjectable), + createStorage: di.inject(createStorageInjectable), + }), + + lifecycle: lifecycleEnum.singleton, +}); + +export default editResourceStoreInjectable; diff --git a/src/renderer/components/dock/edit-resource.store.ts b/src/renderer/components/dock/edit-resource-store/edit-resource.store.ts similarity index 74% rename from src/renderer/components/dock/edit-resource.store.ts rename to src/renderer/components/dock/edit-resource-store/edit-resource.store.ts index 1b9c5fc1f80b..e793537efe6c 100644 --- a/src/renderer/components/dock/edit-resource.store.ts +++ b/src/renderer/components/dock/edit-resource-store/edit-resource.store.ts @@ -19,13 +19,13 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import { autoBind, noop } from "../../utils"; -import { DockTabStore } from "./dock-tab.store"; +import { autoBind, noop, StorageHelper } from "../../../utils"; +import { DockTabStorageState, DockTabStore } from "../dock-tab-store/dock-tab.store"; import { autorun, IReactionDisposer } from "mobx"; -import { dockStore, DockTab, DockTabCreateSpecific, TabId, TabKind } from "./dock.store"; -import type { KubeObject } from "../../../common/k8s-api/kube-object"; -import { apiManager } from "../../../common/k8s-api/api-manager"; -import type { KubeObjectStore } from "../../../common/k8s-api/kube-object.store"; +import type { DockStore, DockTab, TabId } from "../dock-store/dock.store"; +import type { KubeObject } from "../../../../common/k8s-api/kube-object"; +import { apiManager } from "../../../../common/k8s-api/api-manager"; +import type { KubeObjectStore } from "../../../../common/k8s-api/kube-object.store"; export interface EditingResource { resource: string; // resource path, e.g. /api/v1/namespaces/default @@ -33,13 +33,19 @@ export interface EditingResource { firstDraft?: string; } +interface Dependencies { + dockStore: DockStore + createStorage: (storageKey: string, options: DockTabStorageState) => StorageHelper> +} + export class EditResourceStore extends DockTabStore { private watchers = new Map(); - constructor() { - super({ + constructor(protected dependencies: Dependencies) { + super(dependencies, { storageKey: "edit_resource_store", }); + autoBind(this); } @@ -56,7 +62,7 @@ export class EditResourceStore extends DockTabStore { const store = apiManager.getStore(resource); if (store) { - const isActiveTab = dockStore.isOpen && dockStore.selectedTabId === tabId; + const isActiveTab = this.dependencies.dockStore.isOpen && this.dependencies.dockStore.selectedTabId === tabId; const obj = store.getByPath(resource); // preload resource for editing @@ -65,7 +71,7 @@ export class EditResourceStore extends DockTabStore { } // auto-close tab when resource removed from store else if (!obj && store.isLoaded) { - dockStore.closeTab(tabId); + this.dependencies.dockStore.closeTab(tabId); } } }, { @@ -102,7 +108,7 @@ export class EditResourceStore extends DockTabStore { return object.selfLink === resource; }) || []; - return dockStore.getTabById(tabId); + return this.dependencies.dockStore.getTabById(tabId); } clearInitialDraft(tabId: TabId): void { @@ -117,29 +123,3 @@ export class EditResourceStore extends DockTabStore { }); } } - -export const editResourceStore = new EditResourceStore(); - -export function editResourceTab(object: KubeObject, tabParams: DockTabCreateSpecific = {}) { - // use existing tab if already opened - let tab = editResourceStore.getTabByResource(object); - - if (tab) { - dockStore.open(); - dockStore.selectTab(tab.id); - } - - // or create new tab - if (!tab) { - tab = dockStore.createTab({ - title: `${object.kind}: ${object.getName()}`, - ...tabParams, - kind: TabKind.EDIT_RESOURCE, - }, false); - editResourceStore.setData(tab.id, { - resource: object.selfLink, - }); - } - - return tab; -} diff --git a/src/renderer/components/kube-object-menu/dependencies/edit-resource-tab.injectable.ts b/src/renderer/components/dock/edit-resource-tab/edit-resource-tab.injectable.ts similarity index 77% rename from src/renderer/components/kube-object-menu/dependencies/edit-resource-tab.injectable.ts rename to src/renderer/components/dock/edit-resource-tab/edit-resource-tab.injectable.ts index 69b2c0a8e1bf..087ecc461c05 100644 --- a/src/renderer/components/kube-object-menu/dependencies/edit-resource-tab.injectable.ts +++ b/src/renderer/components/dock/edit-resource-tab/edit-resource-tab.injectable.ts @@ -18,11 +18,17 @@ * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import { editResourceTab } from "../../dock/edit-resource.store"; import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { editResourceTab } from "./edit-resource-tab"; +import editResourceStoreInjectable from "../edit-resource-store/edit-resource-store.injectable"; +import dockStoreInjectable from "../dock-store/dock-store.injectable"; const editResourceTabInjectable = getInjectable({ - instantiate: () => editResourceTab, + instantiate: (di) => editResourceTab({ + dockStore: di.inject(dockStoreInjectable), + editResourceStore: di.inject(editResourceStoreInjectable), + }), + lifecycle: lifecycleEnum.singleton, }); diff --git a/src/renderer/components/dock/edit-resource-tab/edit-resource-tab.ts b/src/renderer/components/dock/edit-resource-tab/edit-resource-tab.ts new file mode 100644 index 000000000000..30878fe2c780 --- /dev/null +++ b/src/renderer/components/dock/edit-resource-tab/edit-resource-tab.ts @@ -0,0 +1,57 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +import type { KubeObject } from "../../../../common/k8s-api/kube-object"; +import { DockStore, DockTabCreateSpecific, TabKind } from "../dock-store/dock.store"; +import type { EditResourceStore } from "../edit-resource-store/edit-resource.store"; + +interface Dependencies { + dockStore: DockStore; + editResourceStore: EditResourceStore; +} + +export const editResourceTab = + ({ dockStore, editResourceStore }: Dependencies) => + (object: KubeObject, tabParams: DockTabCreateSpecific = {}) => { + // use existing tab if already opened + let tab = editResourceStore.getTabByResource(object); + + if (tab) { + dockStore.open(); + dockStore.selectTab(tab.id); + } + + // or create new tab + if (!tab) { + tab = dockStore.createTab( + { + title: `${object.kind}: ${object.getName()}`, + ...tabParams, + kind: TabKind.EDIT_RESOURCE, + }, + false, + ); + editResourceStore.setData(tab.id, { + resource: object.selfLink, + }); + } + + return tab; + }; diff --git a/src/renderer/components/dock/edit-resource.tsx b/src/renderer/components/dock/edit-resource.tsx index 78171db96557..506364b61f11 100644 --- a/src/renderer/components/dock/edit-resource.tsx +++ b/src/renderer/components/dock/edit-resource.tsx @@ -25,24 +25,30 @@ import React from "react"; import { computed, makeObservable, observable } from "mobx"; import { observer } from "mobx-react"; import yaml from "js-yaml"; -import type { DockTab } from "./dock.store"; -import { editResourceStore } from "./edit-resource.store"; +import type { DockTab } from "./dock-store/dock.store"; +import type { EditResourceStore } from "./edit-resource-store/edit-resource.store"; import { InfoPanel } from "./info-panel"; import { Badge } from "../badge"; import { EditorPanel } from "./editor-panel"; import { Spinner } from "../spinner"; import type { KubeObject } from "../../../common/k8s-api/kube-object"; import { createPatch } from "rfc6902"; +import { withInjectables } from "@ogre-tools/injectable-react"; +import editResourceStoreInjectable from "./edit-resource-store/edit-resource-store.injectable"; interface Props { tab: DockTab; } +interface Dependencies { + editResourceStore: EditResourceStore +} + @observer -export class EditResource extends React.Component { +class NonInjectedEditResource extends React.Component { @observable error = ""; - constructor(props: Props) { + constructor(props: Props & Dependencies) { super(props); makeObservable(this); } @@ -52,11 +58,11 @@ export class EditResource extends React.Component { } get isReadyForEditing() { - return editResourceStore.isReady(this.tabId); + return this.props.editResourceStore.isReady(this.tabId); } get resource(): KubeObject | undefined { - return editResourceStore.getResource(this.tabId); + return this.props.editResourceStore.getResource(this.tabId); } @computed get draft(): string { @@ -64,7 +70,7 @@ export class EditResource extends React.Component { return ""; // wait until tab's data and kube-object resource are loaded } - const editData = editResourceStore.getData(this.tabId); + const editData = this.props.editResourceStore.getData(this.tabId); if (typeof editData.draft === "string") { return editData.draft; @@ -76,7 +82,7 @@ export class EditResource extends React.Component { } saveDraft(draft: string) { - editResourceStore.getData(this.tabId).draft = draft; + this.props.editResourceStore.getData(this.tabId).draft = draft; } onChange = (draft: string) => { @@ -93,13 +99,13 @@ export class EditResource extends React.Component { return null; } - const store = editResourceStore.getStore(this.tabId); + const store = this.props.editResourceStore.getStore(this.tabId); const currentVersion = yaml.load(this.draft); - const firstVersion = yaml.load(editResourceStore.getData(this.tabId).firstDraft ?? this.draft); + const firstVersion = yaml.load(this.props.editResourceStore.getData(this.tabId).firstDraft ?? this.draft); const patches = createPatch(firstVersion, currentVersion); const updatedResource = await store.patch(this.resource, patches); - editResourceStore.clearInitialDraft(this.tabId); + this.props.editResourceStore.clearInitialDraft(this.tabId); return (

@@ -141,3 +147,14 @@ export class EditResource extends React.Component { ); } } + +export const EditResource = withInjectables( + NonInjectedEditResource, + + { + getProps: (di, props) => ({ + editResourceStore: di.inject(editResourceStoreInjectable), + ...props, + }), + }, +); diff --git a/src/renderer/components/dock/editor-panel.tsx b/src/renderer/components/dock/editor-panel.tsx index df9cc15e105c..80547bf3a676 100644 --- a/src/renderer/components/dock/editor-panel.tsx +++ b/src/renderer/components/dock/editor-panel.tsx @@ -24,9 +24,11 @@ import throttle from "lodash/throttle"; import React from "react"; import { makeObservable, observable, reaction } from "mobx"; import { disposeOnUnmount, observer } from "mobx-react"; -import { dockStore, TabId } from "./dock.store"; +import type { DockStore, TabId } from "./dock-store/dock.store"; import { cssNames } from "../../utils"; import { MonacoEditor, MonacoEditorProps } from "../monaco-editor"; +import { withInjectables } from "@ogre-tools/injectable-react"; +import dockStoreInjectable from "./dock-store/dock-store.injectable"; export interface EditorPanelProps { tabId: TabId; @@ -37,17 +39,21 @@ export interface EditorPanelProps { onError?: MonacoEditorProps["onError"]; } +interface Dependencies { + dockStore: DockStore +} + const defaultProps: Partial = { autoFocus: true, }; @observer -export class EditorPanel extends React.Component { +class NonInjectedEditorPanel extends React.Component { static defaultProps = defaultProps as object; @observable.ref editor?: MonacoEditor; - constructor(props: EditorPanelProps) { + constructor(props: EditorPanelProps & Dependencies) { super(props); makeObservable(this); } @@ -55,12 +61,12 @@ export class EditorPanel extends React.Component { componentDidMount() { disposeOnUnmount(this, [ // keep focus on editor's area when just opened - reaction(() => dockStore.isOpen, isOpen => isOpen && this.editor?.focus(), { + reaction(() => this.props.dockStore.isOpen, isOpen => isOpen && this.editor?.focus(), { fireImmediately: true, }), // focus to editor on dock's resize or turning into fullscreen mode - dockStore.onResize(throttle(() => this.editor?.focus(), 250)), + this.props.dockStore.onResize(throttle(() => this.editor?.focus(), 250)), ]); } @@ -82,3 +88,14 @@ export class EditorPanel extends React.Component { ); } } + +export const EditorPanel = withInjectables( + NonInjectedEditorPanel, + + { + getProps: (di, props) => ({ + dockStore: di.inject(dockStoreInjectable), + ...props, + }), + }, +); diff --git a/src/renderer/components/dock/info-panel.tsx b/src/renderer/components/dock/info-panel.tsx index f14e7b86f1a5..18ba23e5461a 100644 --- a/src/renderer/components/dock/info-panel.tsx +++ b/src/renderer/components/dock/info-panel.tsx @@ -28,8 +28,10 @@ import { cssNames } from "../../utils"; import { Button } from "../button"; import { Icon } from "../icon"; import { Spinner } from "../spinner"; -import { dockStore, TabId } from "./dock.store"; +import type { DockStore, TabId } from "./dock-store/dock.store"; import { Notifications } from "../notifications"; +import { withInjectables } from "@ogre-tools/injectable-react"; +import dockStoreInjectable from "./dock-store/dock-store.injectable"; interface Props extends OptionalProps { tabId: TabId; @@ -50,8 +52,12 @@ interface OptionalProps { showStatusPanel?: boolean; } +interface Dependencies { + dockStore: DockStore +} + @observer -export class InfoPanel extends Component { +class NonInjectedInfoPanel extends Component { static defaultProps: OptionalProps = { submitLabel: "Submit", submittingMessage: "Submitting..", @@ -65,7 +71,7 @@ export class InfoPanel extends Component { @observable error = ""; @observable waiting = false; - constructor(props: Props) { + constructor(props: Props & Dependencies) { super(props); makeObservable(this); } @@ -104,7 +110,7 @@ export class InfoPanel extends Component { }; close = () => { - dockStore.closeTab(this.props.tabId); + this.props.dockStore.closeTab(this.props.tabId); }; renderErrorIcon() { @@ -159,3 +165,14 @@ export class InfoPanel extends Component { ); } } + +export const InfoPanel = withInjectables( + NonInjectedInfoPanel, + + { + getProps: (di, props) => ({ + dockStore: di.inject(dockStoreInjectable), + ...props, + }), + }, +); diff --git a/src/renderer/components/dock/install-chart-store/install-chart-store.injectable.ts b/src/renderer/components/dock/install-chart-store/install-chart-store.injectable.ts new file mode 100644 index 000000000000..7dafc1f35777 --- /dev/null +++ b/src/renderer/components/dock/install-chart-store/install-chart-store.injectable.ts @@ -0,0 +1,42 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { InstallChartStore } from "./install-chart.store"; +import dockStoreInjectable from "../dock-store/dock-store.injectable"; +import createDockTabStoreInjectable from "../dock-tab-store/create-dock-tab-store.injectable"; +import type { IReleaseUpdateDetails } from "../../../../common/k8s-api/endpoints/helm-releases.api"; +import createStorageInjectable from "../../../utils/create-storage/create-storage.injectable"; + +const installChartStoreInjectable = getInjectable({ + instantiate: (di) => { + const createDockTabStore = di.inject(createDockTabStoreInjectable); + + return new InstallChartStore({ + dockStore: di.inject(dockStoreInjectable), + createStorage: di.inject(createStorageInjectable), + versionsStore: createDockTabStore(), + detailsStore: createDockTabStore(), + }); + }, + lifecycle: lifecycleEnum.singleton, +}); + +export default installChartStoreInjectable; diff --git a/src/renderer/components/dock/install-chart.store.ts b/src/renderer/components/dock/install-chart-store/install-chart.store.ts similarity index 71% rename from src/renderer/components/dock/install-chart.store.ts rename to src/renderer/components/dock/install-chart-store/install-chart.store.ts index 5f624bc0c1cd..b9aa2f694fb9 100644 --- a/src/renderer/components/dock/install-chart.store.ts +++ b/src/renderer/components/dock/install-chart-store/install-chart.store.ts @@ -20,11 +20,12 @@ */ import { action, autorun, makeObservable } from "mobx"; -import { dockStore, DockTabCreateSpecific, TabId, TabKind } from "./dock.store"; -import { DockTabStore } from "./dock-tab.store"; -import { getChartDetails, getChartValues, HelmChart } from "../../../common/k8s-api/endpoints/helm-charts.api"; -import type { IReleaseUpdateDetails } from "../../../common/k8s-api/endpoints/helm-releases.api"; -import { Notifications } from "../notifications"; +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"; export interface IChartInstallData { name: string; @@ -37,17 +38,24 @@ export interface IChartInstallData { lastVersion?: boolean; } -export class InstallChartStore extends DockTabStore { - public versions = new DockTabStore(); - public details = new DockTabStore(); +interface Dependencies { + dockStore: DockStore, + createStorage: (storageKey: string, options: DockTabStorageState) => StorageHelper> + + versionsStore: DockTabStore, + detailsStore: DockTabStore +} - constructor() { - super({ - storageKey: "install_charts", - }); +export class InstallChartStore extends DockTabStore { + constructor(protected dependencies: Dependencies) { + super( + dependencies, + { storageKey: "install_charts" }, + ); + makeObservable(this); autorun(() => { - const { selectedTab, isOpen } = dockStore; + const { selectedTab, isOpen } = dependencies.dockStore; if (selectedTab?.kind === TabKind.INSTALL_CHART && isOpen) { this.loadData(selectedTab.id) @@ -56,6 +64,14 @@ export class InstallChartStore extends DockTabStore { }, { delay: 250 }); } + get versions() { + return this.dependencies.versionsStore; + } + + get details() { + return this.dependencies.detailsStore; + } + @action async loadData(tabId: string) { const promises = []; @@ -99,25 +115,3 @@ export class InstallChartStore extends DockTabStore { super.setData(tabId, data); } } - -export const installChartStore = new InstallChartStore(); - -export function createInstallChartTab(chart: HelmChart, tabParams: DockTabCreateSpecific = {}) { - const { name, repo, version } = chart; - const tab = dockStore.createTab({ - 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/install-chart.tsx b/src/renderer/components/dock/install-chart.tsx index aa6fceff5df7..f8b1bad581f7 100644 --- a/src/renderer/components/dock/install-chart.tsx +++ b/src/renderer/components/dock/install-chart.tsx @@ -24,39 +24,52 @@ import "./install-chart.scss"; import React, { Component } from "react"; import { action, makeObservable, observable } from "mobx"; import { observer } from "mobx-react"; -import { dockStore, DockTab } from "./dock.store"; +import type { DockStore, DockTab } from "./dock-store/dock.store"; import { InfoPanel } from "./info-panel"; import { Badge } from "../badge"; import { NamespaceSelect } from "../+namespaces/namespace-select"; import { prevDefault } from "../../utils"; -import { IChartInstallData, installChartStore } from "./install-chart.store"; +import type { IChartInstallData, InstallChartStore } from "./install-chart-store/install-chart.store"; import { Spinner } from "../spinner"; import { Icon } from "../icon"; import { Button } from "../button"; -import { releaseStore } from "../+apps-releases/release.store"; import { LogsDialog } from "../dialog/logs-dialog"; import { Select, SelectOption } from "../select"; import { Input } from "../input"; import { EditorPanel } from "./editor-panel"; import { navigate } from "../../navigation"; import { releaseURL } from "../../../common/routes"; +import type { + IReleaseCreatePayload, + IReleaseUpdateDetails, +} from "../../../common/k8s-api/endpoints/helm-releases.api"; +import releaseStoreInjectable from "../+apps-releases/release-store.injectable"; +import { withInjectables } from "@ogre-tools/injectable-react"; +import installChartStoreInjectable from "./install-chart-store/install-chart-store.injectable"; +import dockStoreInjectable from "./dock-store/dock-store.injectable"; interface Props { tab: DockTab; } +interface Dependencies { + createRelease: (payload: IReleaseCreatePayload) => Promise + installChartStore: InstallChartStore + dockStore: DockStore +} + @observer -export class InstallChart extends Component { +class NonInjectedInstallChart extends Component { @observable error = ""; @observable showNotes = false; - constructor(props: Props) { + constructor(props: Props & Dependencies) { super(props); makeObservable(this); } get chartData() { - return installChartStore.getData(this.tabId); + return this.props.installChartStore.getData(this.tabId); } get tabId() { @@ -64,11 +77,11 @@ export class InstallChart extends Component { } get versions() { - return installChartStore.versions.getData(this.tabId); + return this.props.installChartStore.versions.getData(this.tabId); } get releaseDetails() { - return installChartStore.details.getData(this.tabId); + return this.props.installChartStore.details.getData(this.tabId); } viewRelease = () => { @@ -80,20 +93,20 @@ export class InstallChart extends Component { namespace: release.namespace, }, })); - dockStore.closeTab(this.tabId); + this.props.dockStore.closeTab(this.tabId); }; save(data: Partial) { const chart = { ...this.chartData, ...data }; - installChartStore.setData(this.tabId, chart); + this.props.installChartStore.setData(this.tabId, chart); } onVersionChange = (option: SelectOption) => { const version = option.value; this.save({ version, values: "" }); - installChartStore.loadValues(this.tabId); + this.props.installChartStore.loadValues(this.tabId); }; @action @@ -117,13 +130,13 @@ export class InstallChart extends Component { install = async () => { const { repo, name, version, namespace, values, releaseName } = this.chartData; - const details = await releaseStore.create({ + const details = await this.props.createRelease({ name: releaseName || undefined, chart: name, repo, namespace, version, values, }); - installChartStore.details.setData(this.tabId, details); + this.props.installChartStore.details.setData(this.tabId, details); return (

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

@@ -219,3 +232,16 @@ export class InstallChart extends Component { ); } } + +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/log-controls.tsx b/src/renderer/components/dock/log-controls.tsx index b7ac1c5fb419..27f360bd6669 100644 --- a/src/renderer/components/dock/log-controls.tsx +++ b/src/renderer/components/dock/log-controls.tsx @@ -26,20 +26,25 @@ import { observer } from "mobx-react"; import { Pod } from "../../../common/k8s-api/endpoints"; import { cssNames, saveFileDialog } from "../../utils"; -import { logStore } from "./log.store"; import { Checkbox } from "../checkbox"; import { Icon } from "../icon"; -import type { LogTabData } from "./log-tab.store"; +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 - reload: () => void } -export const LogControls = observer((props: Props) => { - const { tabData, save, reload, logs } = props; +interface Dependencies { + logStore: LogStore +} + +const NonInjectedLogControls = observer((props: Props & Dependencies) => { + const { tabData, save, logs, logStore } = props; if (!tabData) { return null; @@ -55,7 +60,7 @@ export const LogControls = observer((props: Props) => { const togglePrevious = () => { save({ previous: !previous }); - reload(); + logStore.reload(); }; const downloadLogs = () => { @@ -98,3 +103,15 @@ export const LogControls = observer((props: Props) => {
); }); + +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 index 1da9fc0dfed0..485c996cf740 100644 --- a/src/renderer/components/dock/log-list.tsx +++ b/src/renderer/components/dock/log-list.tsx @@ -29,27 +29,33 @@ 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, searchStore } from "../../../common/search-store"; +import { SearchStore } from "../../search-store/search-store"; import { UserStore } from "../../../common/user-store"; import { array, boundMethod, cssNames } from "../../utils"; -import { Spinner } from "../spinner"; import { VirtualList } from "../virtual-list"; -import { logStore } from "./log.store"; -import { logTabStore } from "./log-tab.store"; +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[] - isLoading: boolean - load: () => void id: string } const colorConverter = new AnsiUp(); +interface Dependencies { + logTabStore: LogTabStore + logStore: LogStore + searchStore: SearchStore +} + @observer -export class LogList extends React.Component { +export class NonInjectedLogList extends React.Component { @observable isJumpButtonVisible = false; @observable isLastLineVisible = true; @@ -57,7 +63,7 @@ export class LogList extends React.Component { 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) { + constructor(props: Props & Dependencies) { super(props); makeObservable(this); } @@ -108,14 +114,14 @@ export class LogList extends React.Component { */ @computed get logs() { - const showTimestamps = logTabStore.getData(this.props.id)?.showTimestamps; + const showTimestamps = this.props.logTabStore.getData(this.props.id)?.showTimestamps; if (!showTimestamps) { - return logStore.logsWithoutTimestamps; + return this.props.logStore.logsWithoutTimestamps; } return this.props.logs - .map(log => logStore.splitOutTimestamp(log)) + .map(log => this.props.logStore.splitOutTimestamp(log)) .map(([logTimestamp, log]) => (`${logTimestamp && moment.tz(logTimestamp, UserStore.getInstance().localeTimezone).format()}${log}`)); } @@ -156,7 +162,7 @@ export class LogList extends React.Component { const { scrollOffset } = props; if (scrollOffset === 0) { - this.props.load(); + this.props.logStore.load(); } }; @@ -187,7 +193,7 @@ export class LogList extends React.Component { * @returns A react element with a row itself */ getLogRow = (rowIndex: number) => { - const { searchQuery, isActiveOverlay } = searchStore; + 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)); @@ -232,18 +238,8 @@ export class LogList extends React.Component { }; render() { - const { isLoading } = this.props; - const isInitLoading = isLoading && !this.logs.length; const rowHeights = array.filled(this.logs.length, this.lineHeight); - if (isInitLoading) { - return ( -
- -
- ); - } - if (!this.logs.length) { return (
@@ -253,7 +249,7 @@ export class LogList extends React.Component { } return ( -
+
{ ); } } + +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 index 8bdfa475c41c..9a95f0d9aa41 100644 --- a/src/renderer/components/dock/log-resource-selector.tsx +++ b/src/renderer/components/dock/log-resource-selector.tsx @@ -27,19 +27,26 @@ import { observer } from "mobx-react"; import { Pod } from "../../../common/k8s-api/endpoints"; import { Badge } from "../badge"; import { Select, SelectOption } from "../select"; -import { LogTabData, logTabStore } from "./log-tab.store"; +import type { LogTabData, LogTabStore } from "./log-tab-store/log-tab.store"; import { podsStore } from "../+workloads-pods/pods.store"; -import type { TabId } from "./dock.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 - reload: () => void } -export const LogResourceSelector = observer((props: Props) => { - const { tabData, save, reload, tabId } = props; +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(); @@ -51,13 +58,15 @@ export const LogResourceSelector = observer((props: Props) => { .concat(initContainers) .find(container => container.name === option.value), }); - reload(); + + reloadLogs(); }; const onPodChange = (option: SelectOption) => { const selectedPod = podsStore.getByName(option.value, pod.getNs()); save({ selectedPod }); + logTabStore.renameTab(tabId); }; @@ -89,7 +98,7 @@ export const LogResourceSelector = observer((props: Props) => { ]; useEffect(() => { - reload(); + reloadLogs(); }, [selectedPod]); return ( @@ -114,3 +123,16 @@ export const LogResourceSelector = observer((props: Props) => {
); }); + +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-search.tsx b/src/renderer/components/dock/log-search.tsx index 14b4b3d30c01..d0b26b3a15ec 100644 --- a/src/renderer/components/dock/log-search.tsx +++ b/src/renderer/components/dock/log-search.tsx @@ -24,8 +24,10 @@ import "./log-search.scss"; import React, { useEffect } from "react"; import { observer } from "mobx-react"; import { SearchInput } from "../input"; -import { searchStore } from "../../../common/search-store"; +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"; export interface PodLogSearchProps { onSearch: (query: string) => void @@ -37,8 +39,12 @@ interface Props extends PodLogSearchProps { logs: string[] } -export const LogSearch = observer((props: Props) => { - const { logs, onSearch, toPrevOverlay, toNextOverlay } = props; +interface Dependencies { + searchStore: SearchStore +} + +const NonInjectedLogSearch = observer((props: Props & Dependencies) => { + const { logs, onSearch, toPrevOverlay, toNextOverlay, searchStore } = props; const { setNextOverlayActive, setPrevOverlayActive, searchQuery, occurrences, activeFind, totalFinds } = searchStore; const jumpDisabled = !searchQuery || !occurrences.length; const findCounts = ( @@ -102,3 +108,14 @@ export const LogSearch = observer((props: Props) => {
); }); + +export const LogSearch = withInjectables( + NonInjectedLogSearch, + + { + getProps: (di, props) => ({ + searchStore: di.inject(searchStoreInjectable), + ...props, + }), + }, +); diff --git a/src/renderer/components/dock/log-store/call-for-logs/call-for-logs.injectable.ts b/src/renderer/components/dock/log-store/call-for-logs/call-for-logs.injectable.ts new file mode 100644 index 000000000000..d26263dd744c --- /dev/null +++ b/src/renderer/components/dock/log-store/call-for-logs/call-for-logs.injectable.ts @@ -0,0 +1,29 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { podsApi } from "../../../../../common/k8s-api/endpoints"; + +const callForLogsInjectable = getInjectable({ + instantiate: () => podsApi.getLogs, + lifecycle: lifecycleEnum.singleton, +}); + +export default callForLogsInjectable; diff --git a/src/renderer/components/dock/log-store/log-store.injectable.ts b/src/renderer/components/dock/log-store/log-store.injectable.ts new file mode 100644 index 000000000000..a493c8e5e77f --- /dev/null +++ b/src/renderer/components/dock/log-store/log-store.injectable.ts @@ -0,0 +1,37 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +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"; + +const logStoreInjectable = getInjectable({ + instantiate: (di) => new LogStore({ + logTabStore: di.inject(logTabStoreInjectable), + dockStore: di.inject(dockStoreInjectable), + callForLogs: di.inject(callForLogsInjectable), + }), + + lifecycle: lifecycleEnum.singleton, +}); + +export default logStoreInjectable; diff --git a/src/renderer/components/dock/log.store.ts b/src/renderer/components/dock/log-store/log.store.ts similarity index 83% rename from src/renderer/components/dock/log.store.ts rename to src/renderer/components/dock/log-store/log.store.ts index 3517db01ae77..9c1d46bb573d 100644 --- a/src/renderer/components/dock/log.store.ts +++ b/src/renderer/components/dock/log-store/log.store.ts @@ -21,18 +21,24 @@ import { autorun, computed, observable, makeObservable } from "mobx"; -import { IPodLogsQuery, Pod, podsApi } from "../../../common/k8s-api/endpoints"; -import { autoBind, interval } from "../../utils"; -import { dockStore, TabId, TabKind } from "./dock.store"; -import { logTabStore } from "./log-tab.store"; +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"; 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 = dockStore.selectedTabId; + const id = this.dependencies.dockStore.selectedTabId; if (!this.podLogs.get(id)) return; this.loadMore(id); @@ -40,12 +46,12 @@ export class LogStore { @observable podLogs = observable.map(); - constructor() { + constructor(private dependencies: Dependencies) { makeObservable(this); autoBind(this); autorun(() => { - const { selectedTab, isOpen } = dockStore; + const { selectedTab, isOpen } = this.dependencies.dockStore; if (selectedTab?.kind === TabKind.POD_LOGS && isOpen) { this.refresher.start(); @@ -74,9 +80,10 @@ export class LogStore { * Each time it increasing it's number, caused to fetch more logs. * Also, it handles loading errors, rewriting whole logs with error * messages - * @param tabId */ - load = async (tabId: TabId) => { + load = async () => { + const tabId = this.dependencies.dockStore.selectedTabId; + try { const logs = await this.loadLogs(tabId, { tailLines: this.lines + logLinesToLoad, @@ -121,13 +128,14 @@ export class LogStore { * @returns A fetch request promise */ async loadLogs(tabId: TabId, params: Partial): Promise { - const data = logTabStore.getData(tabId); + const data = this.dependencies.logTabStore.getData(tabId); + const { selectedContainer, previous } = data; const pod = new Pod(data.selectedPod); const namespace = pod.getNs(); const name = pod.getName(); - const result = await podsApi.getLogs({ namespace, name }, { + const result = await this.dependencies.callForLogs({ namespace, name }, { ...params, timestamps: true, // Always setting timestamp to separate old logs from new ones container: selectedContainer.name, @@ -152,7 +160,7 @@ export class LogStore { */ @computed get logs() { - return this.podLogs.get(dockStore.selectedTabId) ?? []; + return this.podLogs.get(this.dependencies.dockStore.selectedTabId) ?? []; } /** @@ -200,6 +208,10 @@ export class LogStore { clearLogs(tabId: TabId) { this.podLogs.delete(tabId); } -} -export const logStore = new LogStore(); + reload = async () => { + this.clearLogs(this.dependencies.dockStore.selectedTabId); + + await this.load(); + }; +} 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 new file mode 100644 index 000000000000..159c4a415cd4 --- /dev/null +++ b/src/renderer/components/dock/log-store/reloaded-log-store.injectable.ts @@ -0,0 +1,36 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +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.injectable.ts b/src/renderer/components/dock/log-tab-store/log-tab-store.injectable.ts new file mode 100644 index 000000000000..1b4fba2d3244 --- /dev/null +++ b/src/renderer/components/dock/log-tab-store/log-tab-store.injectable.ts @@ -0,0 +1,35 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +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"; + +const logTabStoreInjectable = getInjectable({ + instantiate: (di) => new LogTabStore({ + dockStore: di.inject(dockStoreInjectable), + createStorage: di.inject(createStorageInjectable), + }), + + lifecycle: lifecycleEnum.singleton, +}); + +export default logTabStoreInjectable; diff --git a/src/renderer/components/dock/log-tab.store.ts b/src/renderer/components/dock/log-tab-store/log-tab.store.ts similarity index 77% rename from src/renderer/components/dock/log-tab.store.ts rename to src/renderer/components/dock/log-tab-store/log-tab.store.ts index 8b767ba91f8c..6f3f55426a44 100644 --- a/src/renderer/components/dock/log-tab.store.ts +++ b/src/renderer/components/dock/log-tab-store/log-tab.store.ts @@ -20,14 +20,15 @@ */ import uniqueId from "lodash/uniqueId"; -import { reaction } from "mobx"; -import { podsStore } from "../+workloads-pods/pods.store"; +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 { DockTabStore } from "./dock-tab.store"; -import { dockStore, DockTabCreateSpecific, TabKind } from "./dock.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[]; @@ -46,13 +47,26 @@ interface WorkloadLogsTabData { workload: WorkloadKubeObject } +interface Dependencies { + dockStore: DockStore + createStorage: (storageKey: string, options: DockTabStorageState) => StorageHelper> +} + export class LogTabStore extends DockTabStore { - constructor() { - super({ + 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 { @@ -86,11 +100,11 @@ export class LogTabStore extends DockTabStore { renameTab(tabId: string) { const { selectedPod } = this.getData(tabId); - dockStore.renameTab(tabId, `Pod ${selectedPod.metadata.name}`); + this.dependencies.dockStore.renameTab(tabId, `Pod ${selectedPod.metadata.name}`); } private createDockTab(tabParams: DockTabCreateSpecific) { - dockStore.createTab({ + this.dependencies.dockStore.createTab({ ...tabParams, kind: TabKind.POD_LOGS, }, false); @@ -143,8 +157,7 @@ export class LogTabStore extends DockTabStore { private closeTab(tabId: string) { this.clearData(tabId); - dockStore.closeTab(tabId); + this.dependencies.dockStore.closeTab(tabId); } } -export const logTabStore = new LogTabStore(); diff --git a/src/renderer/components/dock/logs.tsx b/src/renderer/components/dock/logs.tsx index 6c5c8d115f75..4991826867c1 100644 --- a/src/renderer/components/dock/logs.tsx +++ b/src/renderer/components/dock/logs.tsx @@ -20,72 +20,43 @@ */ import React from "react"; -import { observable, reaction, makeObservable } from "mobx"; -import { disposeOnUnmount, observer } from "mobx-react"; - -import { searchStore } from "../../../common/search-store"; +import { observer } from "mobx-react"; import { boundMethod } from "../../utils"; -import type { DockTab } from "./dock.store"; import { InfoPanel } from "./info-panel"; import { LogResourceSelector } from "./log-resource-selector"; -import { LogList } from "./log-list"; -import { logStore } from "./log.store"; +import { LogList, NonInjectedLogList } from "./log-list"; import { LogSearch } from "./log-search"; import { LogControls } from "./log-controls"; -import { LogTabData, logTabStore } from "./log-tab.store"; +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 - tab: DockTab + className?: string; } -@observer -export class Logs extends React.Component { - @observable isLoading = true; - - private logListElement = React.createRef(); // A reference for VirtualList component - - constructor(props: Props) { - super(props); - makeObservable(this); - } +interface Dependencies { + searchStore: SearchStore + model: LogsViewModel +} - componentDidMount() { - disposeOnUnmount(this, - reaction(() => this.props.tab.id, this.reload, { fireImmediately: true }), - ); - } +@observer +class NonInjectedLogs extends React.Component { + private logListElement = React.createRef(); // A reference for VirtualList component - get tabId() { - return this.props.tab.id; - } - - load = async () => { - this.isLoading = true; - await logStore.load(this.tabId); - this.isLoading = false; - }; - - reload = async () => { - logStore.clearLogs(this.tabId); - await this.load(); - }; - - /** - * A function for various actions after search is happened - * @param query {string} A text from search field - */ - @boundMethod - onSearch() { - this.toOverlay(); + get model() { + return this.props.model; } /** * Scrolling to active overlay (search word highlight) */ @boundMethod - toOverlay() { - const { activeOverlayLine } = searchStore; + scrollToOverlay() { + const { activeOverlayLine } = this.props.searchStore; if (!this.logListElement.current || activeOverlayLine === undefined) return; // Scroll vertically @@ -99,33 +70,35 @@ export class Logs extends React.Component { }, 100); } - renderResourceSelector(data?: LogTabData) { - if (!data) { + renderResourceSelector() { + const { tabs, logs, logsWithoutTimestamps, saveTab, tabId } = this.model; + + if (!tabs) { return null; } - const logs = logStore.logs; - const searchLogs = data.showTimestamps ? logs : logStore.logsWithoutTimestamps; + const searchLogs = tabs.showTimestamps ? logs : logsWithoutTimestamps; + const controls = (
logTabStore.setData(this.tabId, { ...data, ...newData })} - reload={this.reload} + tabId={tabId} + tabData={tabs} + save={saveTab} /> +
); return ( { } render() { - const logs = logStore.logs; - const data = logTabStore.getData(this.tabId); - - if (!data) { - this.reload(); - } + const { logs, tabs, tabId, saveTab } = this.model; return (
- {this.renderResourceSelector(data)} + {this.renderResourceSelector()} + + logTabStore.setData(this.tabId, { ...data, ...newData })} - reload={this.reload} + tabData={tabs} + save={saveTab} />
); } } + + + +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/logs-view-model/logs-view-model.injectable.ts b/src/renderer/components/dock/logs/logs-view-model/logs-view-model.injectable.ts new file mode 100644 index 000000000000..30c4265fc0a6 --- /dev/null +++ b/src/renderer/components/dock/logs/logs-view-model/logs-view-model.injectable.ts @@ -0,0 +1,37 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +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 new file mode 100644 index 000000000000..127942f8e9f9 --- /dev/null +++ b/src/renderer/components/dock/logs/logs-view-model/logs-view-model.ts @@ -0,0 +1,60 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +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/terminal-store/terminal-store.injectable.ts b/src/renderer/components/dock/terminal-store/terminal-store.injectable.ts new file mode 100644 index 000000000000..67d9a1a36fdb --- /dev/null +++ b/src/renderer/components/dock/terminal-store/terminal-store.injectable.ts @@ -0,0 +1,37 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +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"; + +const terminalStoreInjectable = getInjectable({ + instantiate: (di) => new TerminalStore({ + createTerminalTab: di.inject(createTerminalTabInjectable), + dockStore: di.inject(dockStoreInjectable), + createTerminal: di.inject(createTerminalInjectable), + }), + + lifecycle: lifecycleEnum.singleton, +}); + +export default terminalStoreInjectable; diff --git a/src/renderer/components/dock/terminal.store.ts b/src/renderer/components/dock/terminal-store/terminal.store.ts similarity index 70% rename from src/renderer/components/dock/terminal.store.ts rename to src/renderer/components/dock/terminal-store/terminal.store.ts index bc6bda61e8b5..f3b33f1b38e1 100644 --- a/src/renderer/components/dock/terminal.store.ts +++ b/src/renderer/components/dock/terminal-store/terminal.store.ts @@ -20,36 +20,39 @@ */ import { autorun, observable, when } from "mobx"; -import { autoBind, noop, Singleton } from "../../utils"; -import { Terminal } from "./terminal"; -import { TerminalApi, TerminalChannels } from "../../api/terminal-api"; -import { dockStore, DockTab, DockTabCreateSpecific, TabId, TabKind } from "./dock.store"; -import { WebSocketApiState } from "../../api/websocket-api"; -import { Notifications } from "../notifications"; +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 } -export function createTerminalTab(tabParams: DockTabCreateSpecific = {}) { - return dockStore.createTab({ - title: `Terminal`, - ...tabParams, - kind: TabKind.TERMINAL, - }); +interface Dependencies { + createTerminalTab: () => DockTabCreate + dockStore: DockStore + createTerminal: (tabId: TabId, api: TerminalApi) => Terminal } -export class TerminalStore extends Singleton { +export class TerminalStore { protected terminals = new Map(); protected connections = observable.map(); - constructor() { - super(); + constructor(private dependencies: Dependencies) { autoBind(this); // connect active tab autorun(() => { - const { selectedTab, isOpen } = dockStore; + const { selectedTab, isOpen } = dependencies.dockStore; if (selectedTab?.kind === TabKind.TERMINAL && isOpen) { this.connect(selectedTab.id); @@ -57,7 +60,7 @@ export class TerminalStore extends Singleton { }); // disconnect closed tabs autorun(() => { - const currentTabs = dockStore.tabs.map(tab => tab.id); + const currentTabs = dependencies.dockStore.tabs.map(tab => tab.id); for (const [tabId] of this.connections) { if (!currentTabs.includes(tabId)) this.disconnect(tabId); @@ -69,12 +72,12 @@ export class TerminalStore extends Singleton { if (this.isConnected(tabId)) { return; } - const tab: ITerminalTab = dockStore.getTabById(tabId); + const tab: ITerminalTab = this.dependencies.dockStore.getTabById(tabId); const api = new TerminalApi({ id: tabId, node: tab.node, }); - const terminal = new Terminal(tabId, api); + const terminal = this.dependencies.createTerminal(tabId, api); this.connections.set(tabId, api); this.terminals.set(tabId, terminal); @@ -111,11 +114,11 @@ export class TerminalStore extends Singleton { const { enter, newTab, tabId } = options; if (tabId) { - dockStore.selectTab(tabId); + this.dependencies.dockStore.selectTab(tabId); } if (newTab) { - const tab = createTerminalTab(); + const tab = this.dependencies.createTerminalTab(); await when(() => this.connections.has(tab.id)); @@ -134,7 +137,7 @@ export class TerminalStore extends Singleton { clearTimeout(notifyVeryLong); } - const terminalApi = this.connections.get(dockStore.selectedTabId); + const terminalApi = this.connections.get(this.dependencies.dockStore.selectedTabId); if (terminalApi) { if (enter) { @@ -146,7 +149,10 @@ export class TerminalStore extends Singleton { data: command, }); } else { - console.warn("The selected tab is does not have a connection. Cannot send command.", { tabId: dockStore.selectedTabId, command }); + console.warn( + "The selected tab is does not have a connection. Cannot send command.", + { tabId: this.dependencies.dockStore.selectedTabId, command }, + ); } } @@ -160,25 +166,3 @@ export class TerminalStore extends Singleton { }); } } - -/** - * @deprecated use `TerminalStore.getInstance()` instead - */ -export const terminalStore = new Proxy({}, { - get(target, p) { - if (p === "$$typeof") { - return "TerminalStore"; - } - - const ts = TerminalStore.getInstance(); - const res = (ts as any)?.[p]; - - if (typeof res === "function") { - return function (...args: any[]) { - return res.apply(ts, args); - }; - } - - return res; - }, -}) as TerminalStore; diff --git a/src/renderer/components/dock/terminal-tab.tsx b/src/renderer/components/dock/terminal-tab.tsx index 64832a5c3ee9..0c327669b7c8 100644 --- a/src/renderer/components/dock/terminal-tab.tsx +++ b/src/renderer/components/dock/terminal-tab.tsx @@ -26,18 +26,26 @@ import { observer } from "mobx-react"; import { boundMethod, cssNames } from "../../utils"; import { DockTab, DockTabProps } from "./dock-tab"; import { Icon } from "../icon"; -import { terminalStore } from "./terminal.store"; -import { dockStore } from "./dock.store"; +import type { TerminalStore } from "./terminal-store/terminal.store"; +import type { DockStore } from "./dock-store/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"; interface Props extends DockTabProps { } +interface Dependencies { + dockStore: DockStore + terminalStore: TerminalStore +} + @observer -export class TerminalTab extends React.Component { +class NonInjectedTerminalTab extends React.Component { componentDidMount() { reaction(() => this.isDisconnected === true, () => { - dockStore.closeTab(this.tabId); + this.props.dockStore.closeTab(this.tabId); }); } @@ -46,12 +54,12 @@ export class TerminalTab extends React.Component { } get isDisconnected() { - return terminalStore.isDisconnected(this.tabId); + return this.props.terminalStore.isDisconnected(this.tabId); } @boundMethod reconnect() { - terminalStore.reconnect(this.tabId); + this.props.terminalStore.reconnect(this.tabId); } render() { @@ -60,9 +68,11 @@ export class TerminalTab extends React.Component { disconnected: this.isDisconnected, }); + const { dockStore, terminalStore, ...tabProps } = this.props; + return ( { ); } } + +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-window.tsx b/src/renderer/components/dock/terminal-window.tsx index 58508a11a5c4..1e03cb12566e 100644 --- a/src/renderer/components/dock/terminal-window.tsx +++ b/src/renderer/components/dock/terminal-window.tsx @@ -24,29 +24,37 @@ import "./terminal-window.scss"; import React from "react"; import { disposeOnUnmount, observer } from "mobx-react"; import { cssNames } from "../../utils"; -import type { Terminal } from "./terminal"; -import { TerminalStore } from "./terminal.store"; +import type { Terminal } from "./terminal/terminal"; +import type { TerminalStore } from "./terminal-store/terminal.store"; import { ThemeStore } from "../../theme.store"; -import { dockStore, DockTab, TabKind, TabId } from "./dock.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 -export class TerminalWindow extends React.Component { +class NonInjectedTerminalWindow extends React.Component { public elem: HTMLElement; public terminal: Terminal; componentDidMount() { disposeOnUnmount(this, [ - dockStore.onTabChange(({ tabId }) => this.activate(tabId), { + this.props.dockStore.onTabChange(({ tabId }) => this.activate(tabId), { tabKind: TabKind.TERMINAL, fireImmediately: true, }), // refresh terminal available space (cols/rows) when resized - dockStore.onResize(() => this.terminal?.fitLazy(), { + this.props.dockStore.onResize(() => this.terminal?.fitLazy(), { fireImmediately: true, }), ]); @@ -54,7 +62,7 @@ export class TerminalWindow extends React.Component { activate(tabId: TabId) { this.terminal?.detach(); // detach previous - this.terminal = TerminalStore.getInstance().getTerminal(tabId); + this.terminal = this.props.terminalStore.getTerminal(tabId); this.terminal.attachTo(this.elem); } @@ -67,3 +75,16 @@ export class TerminalWindow extends React.Component { ); } } + +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/create-terminal.injectable.ts b/src/renderer/components/dock/terminal/create-terminal.injectable.ts new file mode 100644 index 000000000000..23f308455efd --- /dev/null +++ b/src/renderer/components/dock/terminal/create-terminal.injectable.ts @@ -0,0 +1,40 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +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"; + +const createTerminalInjectable = getInjectable({ + instantiate: (di) => { + const dependencies = { + dockStore: di.inject(dockStoreInjectable), + }; + + return (tabId: TabId, api: TerminalApi) => + new Terminal(dependencies, tabId, api); + }, + + lifecycle: lifecycleEnum.singleton, +}); + +export default createTerminalInjectable; diff --git a/src/renderer/components/dock/terminal.ts b/src/renderer/components/dock/terminal/terminal.ts similarity index 89% rename from src/renderer/components/dock/terminal.ts rename to src/renderer/components/dock/terminal/terminal.ts index 383accab208d..06d96d07000f 100644 --- a/src/renderer/components/dock/terminal.ts +++ b/src/renderer/components/dock/terminal/terminal.ts @@ -23,15 +23,19 @@ import debounce from "lodash/debounce"; import { reaction } from "mobx"; import { Terminal as XTerm } from "xterm"; import { FitAddon } from "xterm-addon-fit"; -import { dockStore, TabId } from "./dock.store"; -import { TerminalApi, TerminalChannels } from "../../api/terminal-api"; -import { ThemeStore } from "../../theme.store"; -import { boundMethod, disposer } from "../../utils"; -import { isMac } from "../../../common/vars"; +import type { DockStore, TabId } from "../dock-store/dock.store"; +import { TerminalApi, TerminalChannels } from "../../../api/terminal-api"; +import { ThemeStore } from "../../../theme.store"; +import { boundMethod, disposer } from "../../../utils"; +import { isMac } from "../../../../common/vars"; import { camelCase, once } from "lodash"; -import { UserStore } from "../../../common/user-store"; +import { UserStore } from "../../../../common/user-store"; import { clipboard } from "electron"; -import logger from "../../../common/logger"; +import logger from "../../../../common/logger"; + +interface Dependencies { + dockStore: DockStore +} export class Terminal { public static get spawningPool() { @@ -39,7 +43,7 @@ export class Terminal { } static async preloadFonts() { - const fontPath = require("../fonts/roboto-mono-nerd.ttf").default; // eslint-disable-line @typescript-eslint/no-var-requires + const fontPath = require("../../fonts/roboto-mono-nerd.ttf").default; // eslint-disable-line @typescript-eslint/no-var-requires const fontFace = new FontFace("RobotoMono", `url(${fontPath})`); await fontFace.load(); @@ -82,7 +86,7 @@ export class Terminal { } get isActive() { - const { isOpen, selectedTabId } = dockStore; + const { isOpen, selectedTabId } = this.dependencies.dockStore; return isOpen && selectedTabId === this.tabId; } @@ -100,7 +104,7 @@ export class Terminal { } } - constructor(public tabId: TabId, protected api: TerminalApi) { + constructor(private dependencies: Dependencies, public tabId: TabId, protected api: TerminalApi) { // enable terminal addons this.xterm.loadAddon(this.fitAddon); @@ -124,7 +128,7 @@ export class Terminal { reaction(() => ThemeStore.getInstance().activeTheme.colors, this.setTheme, { fireImmediately: true, }), - dockStore.onResize(this.onResize), + dependencies.dockStore.onResize(this.onResize), () => onDataHandler.dispose(), () => this.fitAddon.dispose(), () => this.api.removeAllListeners(), 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 new file mode 100644 index 000000000000..8dde8e365530 --- /dev/null +++ b/src/renderer/components/dock/upgrade-chart-store/upgrade-chart-store.injectable.ts @@ -0,0 +1,45 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +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.ts b/src/renderer/components/dock/upgrade-chart-store/upgrade-chart.store.ts similarity index 67% rename from src/renderer/components/dock/upgrade-chart.store.ts rename to src/renderer/components/dock/upgrade-chart-store/upgrade-chart.store.ts index 81230466b91c..d94f67ffca8e 100644 --- a/src/renderer/components/dock/upgrade-chart.store.ts +++ b/src/renderer/components/dock/upgrade-chart-store/upgrade-chart.store.ts @@ -20,35 +20,44 @@ */ import { action, autorun, computed, IReactionDisposer, reaction, makeObservable } from "mobx"; -import { dockStore, DockTab, DockTabCreateSpecific, TabId, TabKind } from "./dock.store"; -import { DockTabStore } from "./dock-tab.store"; -import { getReleaseValues, HelmRelease } from "../../../common/k8s-api/endpoints/helm-releases.api"; -import { releaseStore } from "../+apps-releases/release.store"; -import { iter } from "../../utils"; +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(); - values = new DockTabStore(); - @computed private get releaseNameReverseLookup(): Map { return new Map(iter.map(this.data, ([id, { releaseName }]) => [releaseName, id])); } - constructor() { - super({ + get values() { + return this.dependencies.valuesStore; + } + + constructor(protected dependencies : Dependencies) { + super(dependencies, { storageKey: "chart_releases", }); makeObservable(this); autorun(() => { - const { selectedTab, isOpen } = dockStore; + const { selectedTab, isOpen } = dependencies.dockStore; if (selectedTab?.kind === TabKind.UPGRADE_CHART && isOpen) { this.loadData(selectedTab.id); @@ -67,20 +76,20 @@ export class UpgradeChartStore extends DockTabStore { return; } const dispose = reaction(() => { - const release = releaseStore.getByName(releaseName); + const release = this.dependencies.releaseStore.getByName(releaseName); return release?.getRevision(); // watch changes only by revision }, release => { const releaseTab = this.getTabByRelease(releaseName); - if (!releaseStore.isLoaded || !releaseTab) { + if (!this.dependencies.releaseStore.isLoaded || !releaseTab) { return; } // auto-reload values if was loaded before if (release) { - if (dockStore.selectedTab === releaseTab && this.values.getData(releaseTab.id)) { + if (this.dependencies.dockStore.selectedTab === releaseTab && this.values.getData(releaseTab.id)) { this.loadValues(releaseTab.id); } } @@ -88,17 +97,17 @@ export class UpgradeChartStore extends DockTabStore { else { dispose(); this.watchers.delete(releaseName); - dockStore.closeTab(releaseTab.id); + this.dependencies.dockStore.closeTab(releaseTab.id); } }); this.watchers.set(releaseName, dispose); } - isLoading(tabId = dockStore.selectedTabId) { + isLoading(tabId = this.dependencies.dockStore.selectedTabId) { const values = this.values.getData(tabId); - return !releaseStore.isLoaded || values === undefined; + return !this.dependencies.releaseStore.isLoaded || values === undefined; } @action @@ -106,7 +115,7 @@ export class UpgradeChartStore extends DockTabStore { const values = this.values.getData(tabId); await Promise.all([ - !releaseStore.isLoaded && releaseStore.loadFromContextNamespaces(), + !this.dependencies.releaseStore.isLoaded && this.dependencies.releaseStore.loadFromContextNamespaces(), !values && this.loadValues(tabId), ]); } @@ -121,32 +130,6 @@ export class UpgradeChartStore extends DockTabStore { } getTabByRelease(releaseName: string): DockTab { - return dockStore.getTabById(this.releaseNameReverseLookup.get(releaseName)); + return this.dependencies.dockStore.getTabById(this.releaseNameReverseLookup.get(releaseName)); } } - -export const upgradeChartStore = new UpgradeChartStore(); - -export function createUpgradeChartTab(release: HelmRelease, tabParams: DockTabCreateSpecific = {}) { - let tab = upgradeChartStore.getTabByRelease(release.getName()); - - if (tab) { - dockStore.open(); - dockStore.selectTab(tab.id); - } - - if (!tab) { - tab = dockStore.createTab({ - title: `Helm Upgrade: ${release.getName()}`, - ...tabParams, - kind: TabKind.UPGRADE_CHART, - }, false); - - upgradeChartStore.setData(tab.id, { - releaseName: release.getName(), - releaseNamespace: release.getNs(), - }); - } - - return tab; -} diff --git a/src/renderer/components/dock/upgrade-chart.tsx b/src/renderer/components/dock/upgrade-chart.tsx index 4eaaaa4d2195..27acbf295ed8 100644 --- a/src/renderer/components/dock/upgrade-chart.tsx +++ b/src/renderer/components/dock/upgrade-chart.tsx @@ -25,29 +25,37 @@ 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"; +import type { DockTab } from "./dock-store/dock.store"; import { InfoPanel } from "./info-panel"; -import { upgradeChartStore } from "./upgrade-chart.store"; +import type { UpgradeChartStore } from "./upgrade-chart-store/upgrade-chart.store"; import { Spinner } from "../spinner"; -import { releaseStore } from "../+apps-releases/release.store"; +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 UpgradeChart extends React.Component { +export class NonInjectedUpgradeChart extends React.Component { @observable error: string; @observable versions = observable.array(); @observable version: IChartVersion; - constructor(props: Props) { + constructor(props: Props & Dependencies) { super(props); makeObservable(this); } @@ -65,15 +73,15 @@ export class UpgradeChart extends React.Component { } get release(): HelmRelease { - const tabData = upgradeChartStore.getData(this.tabId); + const tabData = this.props.upgradeChartStore.getData(this.tabId); if (!tabData) return null; - return releaseStore.getByName(tabData.releaseName); + return this.props.releaseStore.getByName(tabData.releaseName); } get value() { - return upgradeChartStore.values.getData(this.tabId); + return this.props.upgradeChartStore.values.getData(this.tabId); } async loadVersions() { @@ -89,7 +97,7 @@ export class UpgradeChart extends React.Component { @action onChange = (value: string) => { this.error = ""; - upgradeChartStore.values.setData(this.tabId, value); + this.props.upgradeChartStore.values.setData(this.tabId, value); }; @action @@ -103,7 +111,7 @@ export class UpgradeChart extends React.Component { const releaseName = this.release.getName(); const releaseNs = this.release.getNs(); - await releaseStore.update(releaseName, releaseNs, { + await this.props.releaseStore.update(releaseName, releaseNs, { chart: this.release.getChart(), values: this.value, repo, version, @@ -127,7 +135,7 @@ export class UpgradeChart extends React.Component { const { tabId, release, value, error, onChange, onError, upgrade, versions, version } = this; const { className } = this.props; - if (!release || upgradeChartStore.isLoading() || !version) { + if (!release || this.props.upgradeChartStore.isLoading() || !version) { return ; } const currentVersion = release.getVersion(); @@ -169,3 +177,15 @@ export class UpgradeChart extends React.Component { ); } } + +export const UpgradeChart = withInjectables( + NonInjectedUpgradeChart, + + { + getProps: (di, props) => ({ + releaseStore: di.inject(releaseStoreInjectable), + upgradeChartStore: di.inject(upgradeChartStoreInjectable), + ...props, + }), + }, +); diff --git a/src/renderer/components/drawer/drawer-storage/drawer-storage.injectable.ts b/src/renderer/components/drawer/drawer-storage/drawer-storage.injectable.ts new file mode 100644 index 000000000000..e230c265e940 --- /dev/null +++ b/src/renderer/components/drawer/drawer-storage/drawer-storage.injectable.ts @@ -0,0 +1,38 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import createStorageInjectable from "../../../utils/create-storage/create-storage.injectable"; + +export const defaultDrawerWidth = 725; + +const drawerStorageInjectable = getInjectable({ + instantiate: (di) => { + const createStorage = di.inject(createStorageInjectable); + + return createStorage("drawer", { + width: defaultDrawerWidth, + }); + }, + + lifecycle: lifecycleEnum.singleton, +}); + +export default drawerStorageInjectable; diff --git a/src/renderer/components/drawer/drawer.tsx b/src/renderer/components/drawer/drawer.tsx index 4c93b8cada50..a534786021ec 100644 --- a/src/renderer/components/drawer/drawer.tsx +++ b/src/renderer/components/drawer/drawer.tsx @@ -24,11 +24,15 @@ import "./drawer.scss"; import React from "react"; import { clipboard } from "electron"; import { createPortal } from "react-dom"; -import { createStorage, cssNames, noop } from "../../utils"; +import { cssNames, noop, StorageHelper } 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 { withInjectables } from "@ogre-tools/injectable-react"; export type DrawerPosition = "top" | "left" | "right" | "bottom"; @@ -70,12 +74,11 @@ resizingAnchorProps.set("left", [ResizeDirection.HORIZONTAL, ResizeSide.TRAILING resizingAnchorProps.set("top", [ResizeDirection.VERTICAL, ResizeSide.TRAILING, ResizeGrowthDirection.TOP_TO_BOTTOM]); resizingAnchorProps.set("bottom", [ResizeDirection.VERTICAL, ResizeSide.LEADING, ResizeGrowthDirection.BOTTOM_TO_TOP]); -const defaultDrawerWidth = 725; -const drawerStorage = createStorage("drawer", { - width: defaultDrawerWidth, -}); +interface Dependencies { + drawerStorage: StorageHelper<{ width: number }>; +} -export class Drawer extends React.Component { +class NonInjectedDrawer extends React.Component { static defaultProps = defaultProps as object; private mouseDownTarget: HTMLElement; @@ -89,7 +92,7 @@ export class Drawer extends React.Component { public state = { isCopied: false, - width: drawerStorage.get().width, + width: this.props.drawerStorage.get().width, }; componentDidMount() { @@ -110,7 +113,7 @@ export class Drawer extends React.Component { resizeWidth = (width: number) => { this.setState({ width }); - drawerStorage.merge({ width }); + this.props.drawerStorage.merge({ width }); }; fixUpTripleClick = (ev: MouseEvent) => { @@ -239,3 +242,15 @@ export class Drawer extends React.Component { return usePortal ? createPortal(drawer, document.body) : drawer; } } + +export const Drawer = withInjectables( + NonInjectedDrawer, + + { + getProps: (di, props) => ({ + drawerStorage: di.inject(drawerStorageInjectable), + ...props, + }), + }, +); + 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 334d9205ad2a..09b482a32993 100644 --- a/src/renderer/components/hotbar/__tests__/hotbar-remove-command.test.tsx +++ b/src/renderer/components/hotbar/__tests__/hotbar-remove-command.test.tsx @@ -23,33 +23,16 @@ import "@testing-library/jest-dom/extend-expect"; import { HotbarRemoveCommand } from "../hotbar-remove-command"; import { fireEvent } from "@testing-library/react"; import React from "react"; -import { AppPaths } from "../../../../common/app-paths"; -import type { ConfigurableDependencyInjectionContainer } from "@ogre-tools/injectable"; +import type { DependencyInjectionContainer } from "@ogre-tools/injectable"; import { getDiForUnitTesting } from "../../../getDiForUnitTesting"; -import { type DiRender, renderFor } from "../../test-utils/renderFor"; +import { DiRender, renderFor } from "../../test-utils/renderFor"; import hotbarManagerInjectable from "../../../../common/hotbar-store.injectable"; -import { UserStore } from "../../../../common/user-store"; import { ThemeStore } from "../../../theme.store"; import { ConfirmDialog } from "../../confirm-dialog"; import type { HotbarStore } from "../../../../common/hotbar-store"; - -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(), - }, -})); - -AppPaths.init(); +import { UserStore } from "../../../../common/user-store"; +import mockFs from "mock-fs"; +import directoryForUserDataInjectable from "../../../../common/app-paths/directory-for-user-data/directory-for-user-data.injectable"; const mockHotbars: { [id: string]: any } = { "1": { @@ -60,11 +43,16 @@ const mockHotbars: { [id: string]: any } = { }; describe("", () => { - let di: ConfigurableDependencyInjectionContainer; + let di: DependencyInjectionContainer; let render: DiRender; beforeEach(() => { - di = getDiForUnitTesting(); + di = getDiForUnitTesting({ doGeneralOverrides: true }); + + mockFs(); + + di.override(directoryForUserDataInjectable, () => "some-directory-for-user-data"); + render = renderFor(di); UserStore.createInstance(); @@ -72,25 +60,29 @@ describe("", () => { }); afterEach(() => { + mockFs.restore(); ThemeStore.resetInstance(); UserStore.resetInstance(); }); - it("renders w/o errors", () => { + it("renders w/o errors", async () => { di.override(hotbarManagerInjectable, () => ({ hotbars: [mockHotbars["1"]], getById: (id: string) => mockHotbars[id], - remove: () => { }, + remove: () => { + }, hotbarIndex: () => 0, getDisplayLabel: () => "1: Default", }) as any as HotbarStore); - const { container } = render(); + await di.runSetups(); + + const { container } = render(); expect(container).toBeInstanceOf(HTMLElement); }); - it("calls remove if you click on the entry", () => { + it("calls remove if you click on the entry", async () => { const removeMock = jest.fn(); di.override(hotbarManagerInjectable, () => ({ @@ -101,6 +93,8 @@ describe("", () => { getDisplayLabel: () => "1: Default", }) as any as HotbarStore); + await di.runSetups(); + const { getByText } = render( <> diff --git a/src/renderer/components/item-object-list/item-list-layout-storage/item-list-layout-storage.injectable.ts b/src/renderer/components/item-object-list/item-list-layout-storage/item-list-layout-storage.injectable.ts new file mode 100644 index 000000000000..674a33a7959e --- /dev/null +++ b/src/renderer/components/item-object-list/item-list-layout-storage/item-list-layout-storage.injectable.ts @@ -0,0 +1,36 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import createStorageInjectable from "../../../utils/create-storage/create-storage.injectable"; + +const itemListLayoutStorageInjectable = getInjectable({ + instantiate: (di) => { + const createStorage = di.inject(createStorageInjectable); + + return createStorage("item_list_layout", { + showFilters: false, // setup defaults + }); + }, + + lifecycle: lifecycleEnum.singleton, +}); + +export default itemListLayoutStorageInjectable; 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 7e5123c48f74..c3aacfa17e06 100644 --- a/src/renderer/components/item-object-list/item-list-layout.tsx +++ b/src/renderer/components/item-object-list/item-list-layout.tsx @@ -27,7 +27,17 @@ import { computed, makeObservable } from "mobx"; import { observer } from "mobx-react"; import { ConfirmDialog, ConfirmDialogParams } from "../confirm-dialog"; import { Table, TableCell, TableCellProps, TableHead, TableProps, TableRow, TableRowProps, TableSortCallbacks } from "../table"; -import { boundMethod, createStorage, cssNames, IClassName, isReactNode, noop, ObservableToggleSet, prevDefault, stopPropagation } from "../../utils"; +import { + boundMethod, + cssNames, + IClassName, + isReactNode, + noop, + ObservableToggleSet, + prevDefault, + stopPropagation, + StorageHelper, +} from "../../utils"; import { AddRemoveButtons, AddRemoveButtonsProps } from "../add-remove-buttons"; import { NoItems } from "../no-items"; import { Spinner } from "../spinner"; @@ -40,8 +50,11 @@ import { MenuActions } from "../menu/menu-actions"; import { MenuItem } from "../menu"; import { Checkbox } from "../checkbox"; import { UserStore } from "../../../common/user-store"; -import { namespaceStore } from "../+namespaces/namespace.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"; export type SearchFilter = (item: I) => string | number | (string | number)[]; @@ -72,7 +85,7 @@ export interface ItemListLayoutProps { // header (title, filtering, searching, etc.) showHeader?: boolean; headerClassName?: IClassName; - renderHeaderTitle?: ReactNode | ((parent: ItemListLayout) => ReactNode); + renderHeaderTitle?: ReactNode | ((parent: NonInjectedItemListLayout) => ReactNode); customizeHeader?: HeaderCustomizer | HeaderCustomizer[]; // items list configuration @@ -96,7 +109,7 @@ export interface ItemListLayoutProps { // other customizeRemoveDialog?: (selectedItems: I[]) => Partial; - renderFooter?: (parent: ItemListLayout) => React.ReactNode; + renderFooter?: (parent: NonInjectedItemListLayout) => React.ReactNode; /** * Message to display when a store failed to load @@ -125,25 +138,26 @@ const defaultProps: Partial> = { failedToLoadMessage: "Failed to load items", }; +interface Dependencies { + namespaceStore: NamespaceStore; + itemListLayoutStorage: StorageHelper<{ showFilters: boolean }>; +} + @observer -export class ItemListLayout extends React.Component> { +class NonInjectedItemListLayout extends React.Component & Dependencies> { static defaultProps = defaultProps as object; - private storage = createStorage("item_list_layout", { - showFilters: false, // setup defaults - }); - - constructor(props: ItemListLayoutProps) { + constructor(props: ItemListLayoutProps & Dependencies) { super(props); makeObservable(this); } get showFilters(): boolean { - return this.storage.get().showFilters; + return this.props.itemListLayoutStorage.get().showFilters; } set showFilters(showFilters: boolean) { - this.storage.merge({ showFilters }); + this.props.itemListLayoutStorage.merge({ showFilters }); } async componentDidMount() { @@ -166,7 +180,7 @@ export class ItemListLayout extends React.Component store.loadAll(namespaceStore.contextNamespaces)); + stores.forEach(store => store.loadAll(this.props.namespaceStore.contextNamespaces)); } private filterCallbacks: ItemsFilters = { @@ -528,3 +542,24 @@ export class ItemListLayout extends React.Component( + props: ItemListLayoutProps, +) { + const InjectedItemListLayout = withInjectables< + Dependencies, + ItemListLayoutProps + >( + NonInjectedItemListLayout, + + { + getProps: (di, props) => ({ + namespaceStore: di.inject(namespaceStoreInjectable), + itemListLayoutStorage: di.inject(itemListLayoutStorageInjectable), + ...props, + }), + }, + ); + + return ; +} 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 f29a6da6e7b0..93c5085d958c 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 @@ -29,13 +29,17 @@ import type { KubeObject } from "../../../common/k8s-api/kube-object"; import { ItemListLayout, ItemListLayoutProps } from "../item-object-list/item-list-layout"; import type { KubeObjectStore } from "../../../common/k8s-api/kube-object.store"; import { KubeObjectMenu } from "../kube-object-menu"; -import { kubeWatchApi } from "../../../common/k8s-api/kube-watch-api"; import { NamespaceSelectFilter } from "../+namespaces/namespace-select-filter"; import { ResourceKindMap, ResourceNames } from "../../utils/rbac"; import { kubeSelectedUrlParam, toggleDetails } from "../kube-detail-params"; import { Icon } from "../icon"; import { TooltipPosition } from "../tooltip"; -import type { ClusterContext } from "../../../common/k8s-api/cluster-context"; +import { withInjectables } from "@ogre-tools/injectable-react"; +import type { ClusterFrameContext } 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 type { KubeWatchSubscribeStoreOptions } from "../../kube-watch-api/kube-watch-api"; export interface KubeObjectListLayoutProps extends ItemListLayoutProps { store: KubeObjectStore; @@ -48,12 +52,16 @@ const defaultProps: Partial> = { subscribeStores: true, }; +interface Dependencies { + clusterFrameContext: ClusterFrameContext + subscribeToStores: (stores: KubeObjectStore[], options: KubeWatchSubscribeStoreOptions) => Disposer +} + @observer -export class KubeObjectListLayout extends React.Component> { +class NonInjectedKubeObjectListLayout extends React.Component & Dependencies> { static defaultProps = defaultProps as object; - static clusterContext: ClusterContext; - constructor(props: KubeObjectListLayoutProps) { + constructor(props: KubeObjectListLayoutProps & Dependencies) { super(props); makeObservable(this); } @@ -68,7 +76,7 @@ export class KubeObjectListLayout extends React.Component< const { store, dependentStores = [], subscribeStores } = this.props; const stores = Array.from(new Set([store, ...dependentStores])); const reactions: Disposer[] = [ - reaction(() => KubeObjectListLayout.clusterContext.contextNamespaces.slice(), () => { + reaction(() => this.props.clusterFrameContext.contextNamespaces.slice(), () => { // clear load errors this.loadErrors.length = 0; }), @@ -76,7 +84,7 @@ export class KubeObjectListLayout extends React.Component< if (subscribeStores) { reactions.push( - kubeWatchApi.subscribeStores(stores, { + this.props.subscribeToStores(stores, { onLoadFailure: error => this.loadErrors.push(String(error)), }), ); @@ -145,3 +153,24 @@ export class KubeObjectListLayout extends React.Component< ); } } + +export function KubeObjectListLayout( + props: KubeObjectListLayoutProps, +) { + const InjectedKubeObjectListLayout = withInjectables< + Dependencies, + KubeObjectListLayoutProps + >( + NonInjectedKubeObjectListLayout, + + { + getProps: (di, props) => ({ + clusterFrameContext: di.inject(clusterFrameContextInjectable), + subscribeToStores: di.inject(kubeWatchApiInjectable).subscribeStores, + ...props, + }), + }, + ); + + return ; +} 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 c7b3a53142a6..0d1d59166853 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,8 +18,8 @@ exports[`kube-object-menu given kube object renders 1`] = ` > delete -
- `; @@ -68,8 +62,8 @@ exports[`kube-object-menu given kube object when removing kube object renders 1` > delete -
- `; @@ -171,8 +159,8 @@ exports[`kube-object-menu given kube object with namespace when removing kube ob > delete -
- `; @@ -274,8 +256,8 @@ exports[`kube-object-menu given kube object without namespace when removing kube > delete -
- `; 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 96901c149e5e..1f5d276954ae 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 @@ -33,21 +33,26 @@ import { getDiForUnitTesting } from "../../getDiForUnitTesting"; import clusterInjectable from "./dependencies/cluster.injectable"; import hideDetailsInjectable from "./dependencies/hide-details.injectable"; -import editResourceTabInjectable from "./dependencies/edit-resource-tab.injectable"; -import { TabKind } from "../dock/dock.store"; +import editResourceTabInjectable from "../dock/edit-resource-tab/edit-resource-tab.injectable"; +import { TabKind } from "../dock/dock-store/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 "../../../main/cluster"; +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"; +// TODO: Make tooltips free of side effects by making it deterministic +jest.mock("../tooltip"); + describe("kube-object-menu", () => { let di: ConfigurableDependencyInjectionContainer; let render: DiRender; - beforeEach(() => { - di = getDiForUnitTesting(); + beforeEach(async () => { + di = getDiForUnitTesting({ doGeneralOverrides: true }); + + await di.runSetups(); // TODO: Remove global shared state KubeObjectMenuRegistry.resetInstance(); 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 29c822be8996..eff4748a727a 100644 --- a/src/renderer/components/kube-object-menu/kube-object-menu.tsx +++ b/src/renderer/components/kube-object-menu/kube-object-menu.tsx @@ -27,7 +27,7 @@ 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 "./dependencies/edit-resource-tab.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"; @@ -127,7 +127,7 @@ class NonInjectedKubeObjectMenu extends React.Co export function KubeObjectMenu( props: KubeObjectMenuProps, ) { - return withInjectables>( + const InjectedKubeObjectMenu = withInjectables>( NonInjectedKubeObjectMenu, { getProps: (di, props) => ({ @@ -142,5 +142,7 @@ export function KubeObjectMenu( ...props, }), }, - )(props); + ); + + return ; } diff --git a/src/renderer/components/layout/main-layout.tsx b/src/renderer/components/layout/main-layout.tsx index 2c409d9ca2a6..a07ddbe29d63 100755 --- a/src/renderer/components/layout/main-layout.tsx +++ b/src/renderer/components/layout/main-layout.tsx @@ -23,10 +23,14 @@ import styles from "./main-layout.module.scss"; import React from "react"; import { observer } from "mobx-react"; -import { cssNames } from "../../utils"; +import { cssNames, StorageHelper } from "../../utils"; import { ErrorBoundary } from "../error-boundary"; import { ResizeDirection, ResizeGrowthDirection, ResizeSide, ResizingAnchor } from "../resizing-anchor"; -import { defaultSidebarWidth, sidebarStorage } from "./sidebar-storage"; +import { withInjectables } from "@ogre-tools/injectable-react"; +import sidebarStorageInjectable, { + defaultSidebarWidth, + SidebarStorageState, +} from "./sidebar-storage/sidebar-storage.injectable"; interface Props { sidebar: React.ReactNode; @@ -39,15 +43,20 @@ interface Props { * * @link https://api-docs.k8slens.dev/master/extensions/capabilities/common-capabilities/#global-pages */ + +interface Dependencies { + sidebarStorage: StorageHelper +} + @observer -export class MainLayout extends React.Component { +class NonInjectedMainLayout extends React.Component { onSidebarResize = (width: number) => { - sidebarStorage.merge({ width }); + this.props.sidebarStorage.merge({ width }); }; render() { const { className, footer, children, sidebar } = this.props; - const { width: sidebarWidth } = sidebarStorage.get(); + const { width: sidebarWidth } = this.props.sidebarStorage.get(); const style = { "--sidebar-width": `${sidebarWidth}px` } as React.CSSProperties; return ( @@ -75,3 +84,16 @@ export class MainLayout extends React.Component { ); } } + +export const MainLayout = withInjectables( + NonInjectedMainLayout, + + { + getProps: (di, props) => ({ + sidebarStorage: di.inject(sidebarStorageInjectable), + + ...props, + }), + }, +); + diff --git a/src/renderer/components/layout/sidebar-item.tsx b/src/renderer/components/layout/sidebar-item.tsx index 113cef663483..48c559160b8a 100644 --- a/src/renderer/components/layout/sidebar-item.tsx +++ b/src/renderer/components/layout/sidebar-item.tsx @@ -23,12 +23,13 @@ import "./sidebar-item.scss"; import React from "react"; import { computed, makeObservable } from "mobx"; -import { cssNames, prevDefault } from "../../utils"; +import { cssNames, prevDefault, StorageHelper } from "../../utils"; import { observer } from "mobx-react"; import { NavLink } from "react-router-dom"; import { Icon } from "../icon"; -import { sidebarStorage } from "./sidebar-storage"; import { isActiveRoute } from "../../navigation"; +import { withInjectables } from "@ogre-tools/injectable-react"; +import sidebarStorageInjectable, { SidebarStorageState } from "./sidebar-storage/sidebar-storage.injectable"; interface SidebarItemProps { /** @@ -49,11 +50,15 @@ interface SidebarItemProps { isActive?: boolean; } +interface Dependencies { + sidebarStorage: StorageHelper +} + @observer -export class SidebarItem extends React.Component { +class NonInjectedSidebarItem extends React.Component { static displayName = "SidebarItem"; - constructor(props: SidebarItemProps) { + constructor(props: SidebarItemProps & Dependencies) { super(props); makeObservable(this); } @@ -63,7 +68,7 @@ export class SidebarItem extends React.Component { } @computed get expanded(): boolean { - return Boolean(sidebarStorage.get().expanded[this.id]); + return Boolean(this.props.sidebarStorage.get().expanded[this.id]); } @computed get isActive(): boolean { @@ -78,7 +83,7 @@ export class SidebarItem extends React.Component { } toggleExpand = () => { - sidebarStorage.merge(draft => { + this.props.sidebarStorage.merge(draft => { draft.expanded[this.id] = !draft.expanded[this.id]; }); }; @@ -103,7 +108,7 @@ export class SidebarItem extends React.Component { if (isHidden) return null; const { isActive, id, expanded, isExpandable, toggleExpand } = this; - const classNames = cssNames(SidebarItem.displayName, className); + const classNames = cssNames("SidebarItem", className); return (
@@ -124,3 +129,14 @@ export class SidebarItem extends React.Component { ); } } + +export const SidebarItem = withInjectables( + NonInjectedSidebarItem, + + { + getProps: (di, props) => ({ + sidebarStorage: di.inject(sidebarStorageInjectable), + ...props, + }), + }, +); diff --git a/src/renderer/components/layout/sidebar-storage/sidebar-storage.injectable.ts b/src/renderer/components/layout/sidebar-storage/sidebar-storage.injectable.ts new file mode 100644 index 000000000000..9bad2a2b456a --- /dev/null +++ b/src/renderer/components/layout/sidebar-storage/sidebar-storage.injectable.ts @@ -0,0 +1,46 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import createStorageInjectable from "../../../utils/create-storage/create-storage.injectable"; + +export interface SidebarStorageState { + width: number; + expanded: { + [itemId: string]: boolean; + } +} + +export const defaultSidebarWidth = 200; + +const sidebarStorageInjectable = getInjectable({ + instantiate: (di) => { + const createStorage = di.inject(createStorageInjectable); + + return createStorage("sidebar", { + width: defaultSidebarWidth, + expanded: {}, + }); + }, + + lifecycle: lifecycleEnum.singleton, +}); + +export default sidebarStorageInjectable; diff --git a/src/renderer/components/layout/sidebar.tsx b/src/renderer/components/layout/sidebar.tsx index 58d388242c9f..e66c1f239df5 100644 --- a/src/renderer/components/layout/sidebar.tsx +++ b/src/renderer/components/layout/sidebar.tsx @@ -24,7 +24,7 @@ import type { TabLayoutRoute } from "./tab-layout"; import React from "react"; import { disposeOnUnmount, observer } from "mobx-react"; -import { cssNames } from "../../utils"; +import { cssNames, Disposer } from "../../utils"; import { Icon } from "../icon"; import { Workloads } from "../+workloads"; import { UserManagement } from "../+user-management"; @@ -42,19 +42,27 @@ import * as routes from "../../../common/routes"; import { Config } from "../+config"; import { catalogEntityRegistry } from "../../api/catalog-entity-registry"; import { SidebarCluster } from "./sidebar-cluster"; -import { kubeWatchApi } from "../../../common/k8s-api/kube-watch-api"; +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 { className?: string; } +interface Dependencies { + subscribeStores: (stores: KubeObjectStore[]) => Disposer +} + @observer -export class Sidebar extends React.Component { +class NonInjectedSidebar extends React.Component { static displayName = "Sidebar"; componentDidMount() { disposeOnUnmount(this, [ - kubeWatchApi.subscribeStores([ + this.props.subscribeStores([ crdStore, ]), ]); @@ -302,3 +310,14 @@ export class Sidebar extends React.Component { ); } } + +export const Sidebar = withInjectables( + NonInjectedSidebar, + + { + getProps: (di, props) => ({ + subscribeStores: di.inject(kubeWatchApiInjectable).subscribeStores, + ...props, + }), + }, +); 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 c71210ed4f9d..2fef7f7ea4a8 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 @@ -28,6 +28,9 @@ 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"; const mockConfig = vars as { isWindows: boolean; isLinux: boolean }; @@ -66,12 +69,22 @@ jest.mock("@electron/remote", () => { describe(" in Windows and Linux", () => { let render: DiRender; - beforeEach(() => { - const di = getDiForUnitTesting(); + beforeEach(async () => { + const di = getDiForUnitTesting({ doGeneralOverrides: true }); + + mockFs(); + + di.override(directoryForUserDataInjectable, () => "some-directory-for-user-data"); + + 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 2c3d5c2c9a76..9bfd0e7651d2 100644 --- a/src/renderer/components/layout/top-bar/top-bar.test.tsx +++ b/src/renderer/components/layout/top-bar/top-bar.test.tsx @@ -28,6 +28,9 @@ 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; @@ -56,9 +59,6 @@ jest.mock( }, ), }, - app: { - getPath: () => "tmp", - }, }), ); @@ -88,12 +88,22 @@ describe("", () => { let di: ConfigurableDependencyInjectionContainer; let render: DiRender; - beforeEach(() => { - di = getDiForUnitTesting(); + 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(); diff --git a/src/renderer/components/table/table-model/table-model.injectable.ts b/src/renderer/components/table/table-model/table-model.injectable.ts new file mode 100644 index 000000000000..e2abdee7831e --- /dev/null +++ b/src/renderer/components/table/table-model/table-model.injectable.ts @@ -0,0 +1,41 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +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.storage.ts b/src/renderer/components/table/table-model/table-model.ts similarity index 67% rename from src/renderer/components/table/table.storage.ts rename to src/renderer/components/table/table-model/table-model.ts index 0eeb31bd41b9..e3a0acea3859 100644 --- a/src/renderer/components/table/table.storage.ts +++ b/src/renderer/components/table/table-model/table-model.ts @@ -18,26 +18,31 @@ * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ - -import { createStorage } from "../../utils"; -import type { TableSortParams } from "./table"; +import type { StorageHelper } from "../../../utils"; +import type { TableSortParams } from "../table"; export interface TableStorageModel { sortParams: { [tableId: string]: Partial; - } + }; } -export const tableStorage = createStorage("table_settings", { - sortParams: {}, -}); - -export function getSortParams(tableId: string): Partial { - return tableStorage.get().sortParams[tableId]; +interface Dependencies { + storage: StorageHelper; } -export function setSortParams(tableId: string, sortParams: Partial) { - tableStorage.merge(draft => { - draft.sortParams[tableId] = sortParams; - }); +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.tsx b/src/renderer/components/table/table.tsx index 6ad688a51679..d068ac31c7d7 100644 --- a/src/renderer/components/table/table.tsx +++ b/src/renderer/components/table/table.tsx @@ -29,9 +29,11 @@ import { TableHead, TableHeadElem, TableHeadProps } from "./table-head"; import type { TableCellElem } from "./table-cell"; import { VirtualList } from "../virtual-list"; import { createPageParam } from "../../navigation"; -import { getSortParams, setSortParams } from "./table.storage"; 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"; export type TableSortBy = string; export type TableOrderBy = "asc" | "desc" | string; @@ -90,8 +92,12 @@ export const orderByUrlParam = createPageParam({ name: "order", }); +interface Dependencies { + model: TableModel +} + @observer -export class Table extends React.Component> { +class NonInjectedTable extends React.Component & Dependencies> { static defaultProps: TableProps = { scrollable: true, autoSize: true, @@ -101,7 +107,7 @@ export class Table extends React.Component> { customRowHeights: (item, lineHeight, paddings) => lineHeight + paddings, }; - constructor(props: TableProps) { + constructor(props: TableProps & Dependencies) { super(props); makeObservable(this); } @@ -121,7 +127,7 @@ export class Table extends React.Component> { } @computed get sortParams() { - return Object.assign({}, this.props.sortByDefault, getSortParams(this.props.tableId)); + return Object.assign({}, this.props.sortByDefault, this.props.model.getSortParams(this.props.tableId)); } renderHead() { @@ -167,7 +173,7 @@ export class Table extends React.Component> { } protected onSort({ sortBy, orderBy }: TableSortParams) { - setSortParams(this.props.tableId, { sortBy, orderBy }); + this.props.model.setSortParams(this.props.tableId, { sortBy, orderBy }); const { sortSyncWithUrl, onSort } = this.props; if (sortSyncWithUrl) { @@ -255,3 +261,19 @@ export class Table extends React.Component> { ); } } + +export function Table(props: TableProps) { + const InjectedTable = withInjectables>( + NonInjectedTable, + + { + getProps: (di, props) => ({ + model: di.inject(tableModelInjectable), + ...props, + }), + }, + ); + + return ; +} + diff --git a/src/renderer/components/test-utils/renderFor.tsx b/src/renderer/components/test-utils/renderFor.tsx index d60032384da1..91a8e87e3f88 100644 --- a/src/renderer/components/test-utils/renderFor.tsx +++ b/src/renderer/components/test-utils/renderFor.tsx @@ -25,18 +25,24 @@ import { RenderResult, } from "@testing-library/react"; -import type { ConfigurableDependencyInjectionContainer } from "@ogre-tools/injectable"; +import type { DependencyInjectionContainer } from "@ogre-tools/injectable"; import { DiContextProvider } from "@ogre-tools/injectable-react"; export type DiRender = (ui: React.ReactElement) => RenderResult; -type DiRenderFor = ( - di: ConfigurableDependencyInjectionContainer, -) => DiRender; +type DiRenderFor = (di: DependencyInjectionContainer) => DiRender; -export const renderFor: DiRenderFor = di => ui => - testingLibraryRender( +export const renderFor: DiRenderFor = (di) => (ui) => { + const result = testingLibraryRender( {ui}, ); + return { + ...result, + + rerender: (ui: React.ReactElement) => result.rerender( + {ui}, + ), + }; +}; diff --git a/src/renderer/create-cluster/create-cluster.injectable.ts b/src/renderer/create-cluster/create-cluster.injectable.ts new file mode 100644 index 000000000000..99cfe092503c --- /dev/null +++ b/src/renderer/create-cluster/create-cluster.injectable.ts @@ -0,0 +1,44 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +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 { createClusterInjectionToken } from "../../common/cluster/create-cluster-injection-token"; + +const createClusterInjectable = getInjectable({ + instantiate: (di) => { + const dependencies = { + 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."); }, + }; + + return (model: ClusterModel) => new Cluster(dependencies, model); + }, + + injectionToken: createClusterInjectionToken, + + lifecycle: lifecycleEnum.singleton, +}); + +export default createClusterInjectable; diff --git a/src/renderer/frames/cluster-frame/cluster-frame.tsx b/src/renderer/frames/cluster-frame/cluster-frame.tsx new file mode 100755 index 000000000000..06057b98415e --- /dev/null +++ b/src/renderer/frames/cluster-frame/cluster-frame.tsx @@ -0,0 +1,204 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +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 { 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"; +import { ErrorBoundary } from "../../components/error-boundary"; +import { MainLayout } from "../../components/layout/main-layout"; +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"; + +interface Dependencies { + namespaceStore: NamespaceStore + hostedClusterId: ClusterId + subscribeStores: (stores: KubeObjectStore[]) => Disposer +} + +@observer +class NonInjectedClusterFrame extends React.Component { + static displayName = "ClusterFrame"; + + constructor(props: Dependencies) { + super(props); + makeObservable(this); + } + + componentDidMount() { + disposeOnUnmount(this, [ + this.props.subscribeStores([ + this.props.namespaceStore, + ]), + + watchHistoryState(), + ]); + } + + @observable startUrl = isAllowedResource(["events", "nodes", "pods"]) ? routes.clusterURL() : routes.workloadsURL(); + + getTabLayoutRoutes(menuItem: ClusterPageMenuRegistration) { + const routes: TabLayoutRoute[] = []; + + if (!menuItem.id) { + return routes; + } + + ClusterPageMenuRegistry.getInstance().getSubItems(menuItem).forEach((subMenu) => { + const page = ClusterPageRegistry.getInstance().getByPageTarget(subMenu.target); + + if (page) { + routes.push({ + routePath: page.url, + url: getExtensionPageUrl(subMenu.target), + title: subMenu.title, + component: page.components.Page, + }); + } + }); + + return routes; + } + + renderExtensionTabLayoutRoutes() { + return ClusterPageMenuRegistry.getInstance().getRootItems().map((menu, index) => { + const tabRoutes = this.getTabLayoutRoutes(menu); + + if (tabRoutes.length > 0) { + const pageComponent = () => ; + + return tab.routePath)}/>; + } else { + const page = ClusterPageRegistry.getInstance().getByPageTarget(menu.target); + + if (page) { + return ; + } + } + + return null; + }); + } + + renderExtensionRoutes() { + return ClusterPageRegistry.getInstance().getItems().map((page, index) => { + const menu = ClusterPageMenuRegistry.getInstance().getByPage(page); + + if (!menu) { + return ; + } + + return null; + }); + } + + render() { + return ( + + + } footer={}> + + + + + + + + + + + + + {this.renderExtensionTabLayoutRoutes()} + {this.renderExtensionRoutes()} + + + { + Notifications.error(`Unknown location ${location.pathname}, redirecting to main page.`); + + return ; + }} /> + + + + + + + + + + + + + + + + + ); + } +} + +export const ClusterFrame = withInjectables(NonInjectedClusterFrame, { + getProps: di => ({ + namespaceStore: di.inject(namespaceStoreInjectable), + hostedClusterId: di.inject(hostedClusterInjectable).id, + subscribeStores: di.inject(kubeWatchApiInjectable).subscribeStores, + }), +}); diff --git a/src/renderer/frames/cluster-frame/init-cluster-frame/frame-routing-id/frame-routing-id.injectable.ts b/src/renderer/frames/cluster-frame/init-cluster-frame/frame-routing-id/frame-routing-id.injectable.ts new file mode 100644 index 000000000000..15ee058ae6d7 --- /dev/null +++ b/src/renderer/frames/cluster-frame/init-cluster-frame/frame-routing-id/frame-routing-id.injectable.ts @@ -0,0 +1,30 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import webFrameInjectable from "./web-frame/web-frame.injectable"; + +const frameRoutingIdInjectable = getInjectable({ + instantiate: (di) => di.inject(webFrameInjectable).routingId, + + lifecycle: lifecycleEnum.singleton, +}); + +export default frameRoutingIdInjectable; diff --git a/src/renderer/frames/cluster-frame/init-cluster-frame/frame-routing-id/web-frame/web-frame.injectable.ts b/src/renderer/frames/cluster-frame/init-cluster-frame/frame-routing-id/web-frame/web-frame.injectable.ts new file mode 100644 index 000000000000..b647f0a32277 --- /dev/null +++ b/src/renderer/frames/cluster-frame/init-cluster-frame/frame-routing-id/web-frame/web-frame.injectable.ts @@ -0,0 +1,30 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { webFrame } from "electron"; + +const webFrameInjectable = getInjectable({ + instantiate: () => webFrame, + lifecycle: lifecycleEnum.singleton, + causesSideEffects: true, +}); + +export default webFrameInjectable; 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 new file mode 100644 index 000000000000..a49151cf07d7 --- /dev/null +++ b/src/renderer/frames/cluster-frame/init-cluster-frame/init-cluster-frame.injectable.ts @@ -0,0 +1,45 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +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 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"; +import clusterFrameContextInjectable from "../../../cluster-frame-context/cluster-frame-context.injectable"; + +const initClusterFrameInjectable = getInjectable({ + instantiate: (di) => + initClusterFrame({ + hostedCluster: di.inject(hostedClusterInjectable), + loadExtensions: di.inject(extensionLoaderInjectable).loadOnClusterRenderer, + catalogEntityRegistry: di.inject(catalogEntityRegistryInjectable), + frameRoutingId: di.inject(frameRoutingIdInjectable), + emitEvent: di.inject(appEventBusInjectable).emit, + + clusterFrameContext: di.inject(clusterFrameContextInjectable), + }), + + lifecycle: lifecycleEnum.singleton, +}); + +export default initClusterFrameInjectable; 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 new file mode 100644 index 000000000000..6439723e6e6d --- /dev/null +++ b/src/renderer/frames/cluster-frame/init-cluster-frame/init-cluster-frame.ts @@ -0,0 +1,110 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +import type { Cluster } from "../../../../common/cluster/cluster"; +import type { CatalogEntityRegistry } from "../../../api/catalog-entity-registry"; +import logger from "../../../../main/logger"; +import { Terminal } from "../../../components/dock/terminal/terminal"; +import { requestMain } from "../../../../common/ipc"; +import { clusterSetFrameIdHandler } from "../../../../common/cluster-ipc"; +import type { KubernetesCluster } from "../../../../common/catalog-entities"; +import { Notifications } from "../../../components/notifications"; +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 { KubeObjectStore } from "../../../../common/k8s-api/kube-object.store"; + +interface Dependencies { + hostedCluster: Cluster; + loadExtensions: (entity: CatalogEntity) => void; + catalogEntityRegistry: CatalogEntityRegistry; + frameRoutingId: number; + emitEvent: (event: AppEvent) => void; + + // TODO: This dependency belongs to KubeObjectStore + clusterFrameContext: ClusterFrameContext +} + +const logPrefix = "[CLUSTER-FRAME]:"; + +export const initClusterFrame = + ({ hostedCluster, loadExtensions, catalogEntityRegistry, frameRoutingId, emitEvent, clusterFrameContext }: Dependencies) => + async (rootElem: HTMLElement) => { + + // TODO: Make catalogEntityRegistry already initialized when passed as dependency + catalogEntityRegistry.init(); + + logger.info( + `${logPrefix} Init dashboard, clusterId=${hostedCluster.id}, frameId=${frameRoutingId}`, + ); + + await Terminal.preloadFonts(); + await requestMain(clusterSetFrameIdHandler, hostedCluster.id); + await hostedCluster.whenReady; // cluster.activate() is done at this point + + catalogEntityRegistry.activeEntity = hostedCluster.id; + + // Only load the extensions once the catalog has been populated + when( + () => Boolean(catalogEntityRegistry.activeEntity), + () => + loadExtensions(catalogEntityRegistry.activeEntity as KubernetesCluster), + { + timeout: 15_000, + onError: (error) => { + console.warn( + "[CLUSTER-FRAME]: error from activeEntity when()", + error, + ); + + Notifications.error( + "Failed to get KubernetesCluster for this view. Extensions will not be loaded.", + ); + }, + }, + ); + + setTimeout(() => { + emitEvent({ + name: "cluster", + action: "open", + params: { + clusterId: hostedCluster.id, + }, + }); + }); + + window.addEventListener("online", () => { + window.location.reload(); + }); + + window.onbeforeunload = () => { + logger.info( + `${logPrefix} Unload dashboard, clusterId=${(hostedCluster.id)}, frameId=${frameRoutingId}`, + ); + + unmountComponentAtNode(rootElem); + }; + + // TODO: Make context dependency of KubeObjectStore + KubeObjectStore.defaultContext.set(clusterFrameContext); + }; 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 new file mode 100644 index 000000000000..f474f4f03f15 --- /dev/null +++ b/src/renderer/frames/root-frame/init-root-frame/init-root-frame.injectable.ts @@ -0,0 +1,53 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +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 lensProtocolRouterRendererInjectable from "../../../protocol-handler/lens-protocol-router-renderer/lens-protocol-router-renderer.injectable"; +import catalogEntityRegistryInjectable from "../../../api/catalog-entity-registry/catalog-entity-registry.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), + }); + }, + + lifecycle: lifecycleEnum.singleton, +}); + +export default initRootFrameInjectable; 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 new file mode 100644 index 000000000000..06269c8af82f --- /dev/null +++ b/src/renderer/frames/root-frame/init-root-frame/init-root-frame.ts @@ -0,0 +1,89 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +import { delay } from "../../../../common/utils"; +import { broadcastMessage, BundledExtensionsLoaded } from "../../../../common/ipc"; +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"; + +interface Dependencies { + loadExtensions: () => ExtensionLoading[] + + // TODO: Move usages of third party library behind abstraction + ipcRenderer: { send: (name: string) => void } + + // TODO: Remove dependencies being here only for correct timing of initialization + bindProtocolAddRouteHandlers: () => void; + lensProtocolRouterRenderer: { init: () => void }; + catalogEntityRegistry: CatalogEntityRegistry +} + +const logPrefix = "[ROOT-FRAME]:"; + +export const initRootFrame = + ({ + loadExtensions, + bindProtocolAddRouteHandlers, + lensProtocolRouterRenderer, + ipcRenderer, + + catalogEntityRegistry, + }: Dependencies) => + async (rootElem: HTMLElement) => { + catalogEntityRegistry.init(); + + try { + // maximum time to let bundled extensions finish loading + const timeout = delay(10000); + + const loadingExtensions = loadExtensions(); + + const loadingBundledExtensions = loadingExtensions + .filter((e) => e.isBundled) + .map((e) => e.loaded); + + const bundledExtensionsFinished = Promise.all(loadingBundledExtensions); + + await Promise.race([bundledExtensionsFinished, timeout]); + } finally { + ipcRenderer.send(BundledExtensionsLoaded); + } + + lensProtocolRouterRenderer.init(); + + bindProtocolAddRouteHandlers(); + + window.addEventListener("offline", () => + broadcastMessage("network:offline"), + ); + + window.addEventListener("online", () => broadcastMessage("network:online")); + + registerIpcListeners(); + + window.addEventListener("beforeunload", () => { + logger.info(`${logPrefix} Unload app`); + + unmountComponentAtNode(rootElem); + }); + }; diff --git a/src/renderer/frames/root-frame/root-frame.tsx b/src/renderer/frames/root-frame/root-frame.tsx new file mode 100644 index 000000000000..6d43536a4e5c --- /dev/null +++ b/src/renderer/frames/root-frame/root-frame.tsx @@ -0,0 +1,66 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import { injectSystemCAs } from "../../../common/system-ca"; +import React 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"; +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"; + +injectSystemCAs(); + +@observer +export class RootFrame extends React.Component { + static displayName = "RootFrame"; + + constructor(props: {}) { + super(props); + + ClusterFrameHandler.createInstance(); + } + + componentDidMount() { + ipcRenderer.send(IpcRendererNavigationEvents.LOADED); + } + + render() { + return ( + + + + + + + + + + + ); + } +} diff --git a/src/renderer/getDi.tsx b/src/renderer/getDi.tsx index e9126d62a9ae..ea2d9044a22f 100644 --- a/src/renderer/getDi.tsx +++ b/src/renderer/getDi.tsx @@ -22,7 +22,7 @@ import { createContainer } from "@ogre-tools/injectable"; import { setLegacyGlobalDiForExtensionApi } from "../extensions/as-legacy-globals-for-extension-api/legacy-global-di-for-extension-api"; -export function getDi() { +export const getDi = () => { const di = createContainer( getRequireContextForRendererCode, getRequireContextForCommonExtensionCode, @@ -32,7 +32,7 @@ export function getDi() { setLegacyGlobalDiForExtensionApi(di); return di; -} +}; const getRequireContextForRendererCode = () => require.context("./", true, /\.injectable\.(ts|tsx)$/); diff --git a/src/renderer/getDiForUnitTesting.tsx b/src/renderer/getDiForUnitTesting.tsx index a5f03165b27c..5bbcbe0da1f0 100644 --- a/src/renderer/getDiForUnitTesting.tsx +++ b/src/renderer/getDiForUnitTesting.tsx @@ -22,10 +22,16 @@ import glob from "glob"; import { memoize } from "lodash/fp"; import { createContainer } from "@ogre-tools/injectable"; +import { setLegacyGlobalDiForExtensionApi } from "../extensions/as-legacy-globals-for-extension-api/legacy-global-di-for-extension-api"; +import getValueFromRegisteredChannelInjectable from "./app-paths/get-value-from-registered-channel/get-value-from-registered-channel.injectable"; +import writeJsonFileInjectable from "../common/fs/write-json-file/write-json-file.injectable"; +import readJsonFileInjectable from "../common/fs/read-json-file/read-json-file.injectable"; -export const getDiForUnitTesting = () => { +export const getDiForUnitTesting = ({ doGeneralOverrides } = { doGeneralOverrides: false }) => { const di = createContainer(); + setLegacyGlobalDiForExtensionApi(di); + for (const filePath of getInjectableFilePaths()) { const injectableInstance = require(filePath).default; @@ -38,6 +44,18 @@ export const getDiForUnitTesting = () => { di.preventSideEffects(); + if (doGeneralOverrides) { + di.override(getValueFromRegisteredChannelInjectable, () => () => undefined); + + di.override(writeJsonFileInjectable, () => () => { + throw new Error("Tried to write JSON file to file system without specifying explicit override."); + }); + + di.override(readJsonFileInjectable, () => () => { + throw new Error("Tried to read JSON file from file system without specifying explicit override."); + }); + } + return di; }; diff --git a/src/renderer/hooks/index.ts b/src/renderer/hooks/index.ts index 277cfefbde46..f2a7b6bd0e88 100644 --- a/src/renderer/hooks/index.ts +++ b/src/renderer/hooks/index.ts @@ -21,7 +21,6 @@ // Custom react hooks for common usage -export * from "./useStorage"; export * from "./useOnUnmount"; export * from "./useInterval"; export * from "./useMutationObserver"; diff --git a/src/renderer/initializers/catalog.tsx b/src/renderer/initializers/catalog.tsx index 546c717aa5cf..4eb08daf572b 100644 --- a/src/renderer/initializers/catalog.tsx +++ b/src/renderer/initializers/catalog.tsx @@ -22,7 +22,7 @@ import React from "react"; import fs from "fs"; import "../../common/catalog-entities/kubernetes-cluster"; -import { ClusterStore } from "../../common/cluster-store"; +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"; @@ -58,6 +58,7 @@ export function initCatalog({ openCommandDialog }: Dependencies) { onClick: () => openCommandDialog(), }); }); + catalogCategoryRegistry .getForGroupKind("entity.k8slens.dev", "KubernetesCluster") .on("contextMenuOpen", (entity, context) => { diff --git a/src/renderer/ipc/index.tsx b/src/renderer/ipc/index.tsx index 211dfbc8f22e..10d2c8ed469e 100644 --- a/src/renderer/ipc/index.tsx +++ b/src/renderer/ipc/index.tsx @@ -25,7 +25,7 @@ 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"; +import { ClusterStore } from "../../common/cluster-store/cluster-store"; import { navigate } from "../navigation"; import { entitySettingsURL } from "../../common/routes"; import { defaultHotbarCells } from "../../common/hotbar-types"; diff --git a/src/renderer/kube-watch-api/kube-watch-api.injectable.ts b/src/renderer/kube-watch-api/kube-watch-api.injectable.ts new file mode 100644 index 000000000000..e9f57c24d5f5 --- /dev/null +++ b/src/renderer/kube-watch-api/kube-watch-api.injectable.ts @@ -0,0 +1,33 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import clusterFrameContextInjectable from "../cluster-frame-context/cluster-frame-context.injectable"; +import { KubeWatchApi } from "./kube-watch-api"; + +const kubeWatchApiInjectable = getInjectable({ + instantiate: (di) => new KubeWatchApi({ + clusterFrameContext: di.inject(clusterFrameContextInjectable), + }), + + lifecycle: lifecycleEnum.singleton, +}); + +export default kubeWatchApiInjectable; diff --git a/src/common/k8s-api/kube-watch-api.ts b/src/renderer/kube-watch-api/kube-watch-api.ts similarity index 86% rename from src/common/k8s-api/kube-watch-api.ts rename to src/renderer/kube-watch-api/kube-watch-api.ts index 63636eddc3e6..d4e78d4c896e 100644 --- a/src/common/k8s-api/kube-watch-api.ts +++ b/src/renderer/kube-watch-api/kube-watch-api.ts @@ -18,20 +18,17 @@ * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ - -// Kubernetes watch-api client -// API: https://developer.mozilla.org/en-US/docs/Web/API/Streams_API/Using_readable_streams - -import type { KubeObjectStore } from "./kube-object.store"; -import type { ClusterContext } from "./cluster-context"; - import { comparer, reaction } from "mobx"; -import { disposer, Disposer, noop } from "../utils"; -import type { KubeJsonApiData } from "./kube-json-api"; -import type { KubeObject } from "./kube-object"; +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 logger from "../logger"; +import type { ClusterFrameContext } from "../cluster-frame-context/cluster-frame-context"; +import type { KubeObjectStore } from "../../common/k8s-api/kube-object.store"; +import logger from "../../common/logger"; + +// Kubernetes watch-api client +// API: https://developer.mozilla.org/en-US/docs/Web/API/Streams_API/Using_readable_streams class WrappedAbortController extends AbortController { constructor(protected parent: AbortController) { @@ -43,31 +40,6 @@ class WrappedAbortController extends AbortController { } } -export interface IKubeWatchEvent { - type: "ADDED" | "MODIFIED" | "DELETED" | "ERROR"; - object?: T; -} - -export interface KubeWatchSubscribeStoreOptions { - /** - * The namespaces to watch - * @default all selected namespaces - */ - namespaces?: string[]; - - /** - * A function that is called when listing fails. If set then blocks errors - * being rejected with - */ - onLoadFailure?: (err: any) => void; -} - -export interface IKubeWatchLog { - message: string | string[] | Error; - meta?: object; - cssStyle?: string; -} - interface SubscribeStoreParams { store: KubeObjectStore; parent: AbortController; @@ -110,11 +82,29 @@ class WatchCount { } } -export class KubeWatchApi { - static context: ClusterContext = null; +export interface KubeWatchSubscribeStoreOptions { + /** + * The namespaces to watch + * @default all selected namespaces + */ + namespaces?: string[]; + /** + * A function that is called when listing fails. If set then blocks errors + * being rejected with + */ + onLoadFailure?: (err: any) => void; +} + +interface Dependencies { + clusterFrameContext: ClusterFrameContext +} + +export class KubeWatchApi { #watch = new WatchCount(); + constructor(private dependencies: Dependencies) {} + private subscribeStore({ store, parent, watchChanges, namespaces, onLoadFailure }: SubscribeStoreParams): Disposer { if (this.#watch.inc(store) > 1) { // don't load or subscribe to a store more than once @@ -146,7 +136,7 @@ export class KubeWatchApi { const cancelReloading = watchChanges ? reaction( // Note: must slice because reaction won't fire if it isn't there - () => [KubeWatchApi.context.contextNamespaces.slice(), KubeWatchApi.context.hasSelectedAll] as const, + () => [this.dependencies.clusterFrameContext.contextNamespaces.slice(), this.dependencies.clusterFrameContext.hasSelectedAll] as const, ([namespaces, curSelectedAll], [prevNamespaces, prevSelectedAll]) => { if (curSelectedAll && prevSelectedAll) { const action = namespaces.length > prevNamespaces.length ? "created" : "deleted"; @@ -176,14 +166,14 @@ export class KubeWatchApi { }; } - subscribeStores(stores: KubeObjectStore[], { namespaces, onLoadFailure }: KubeWatchSubscribeStoreOptions = {}): Disposer { + subscribeStores = (stores: KubeObjectStore[], { namespaces, onLoadFailure }: KubeWatchSubscribeStoreOptions = {}): Disposer => { const parent = new AbortController(); const unsubscribe = disposer( ...stores.map(store => this.subscribeStore({ store, parent, watchChanges: !namespaces && store.api.isNamespaced, - namespaces: namespaces ?? KubeWatchApi.context?.contextNamespaces ?? [], + namespaces: namespaces ?? this.dependencies.clusterFrameContext?.contextNamespaces ?? [], onLoadFailure, })), ); @@ -193,7 +183,7 @@ export class KubeWatchApi { parent.abort(); unsubscribe(); }); - } + }; protected log(message: any, meta: any) { const log = message instanceof Error @@ -206,5 +196,3 @@ export class KubeWatchApi { }); } } - -export const kubeWatchApi = new KubeWatchApi(); diff --git a/src/renderer/port-forward/index.ts b/src/renderer/port-forward/index.ts index 34b12b2408ee..9a7a681fc22a 100644 --- a/src/renderer/port-forward/index.ts +++ b/src/renderer/port-forward/index.ts @@ -19,7 +19,7 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -export * from "./port-forward.store"; +export * from "./port-forward-store/port-forward-store"; export * from "./port-forward-item"; export * from "./port-forward-dialog"; export * from "./port-forward-notify"; 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 new file mode 100644 index 000000000000..1ca73d86a3b7 --- /dev/null +++ b/src/renderer/port-forward/port-forward-dialog-model/port-forward-dialog-model.injectable.ts @@ -0,0 +1,29 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +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 new file mode 100644 index 000000000000..8dc78dbafbaa --- /dev/null +++ b/src/renderer/port-forward/port-forward-dialog-model/port-forward-dialog-model.ts @@ -0,0 +1,64 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +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 index 9db91f899378..b5a1a2029798 100644 --- a/src/renderer/port-forward/port-forward-dialog.tsx +++ b/src/renderer/port-forward/port-forward-dialog.tsx @@ -27,64 +27,40 @@ import { observer } from "mobx-react"; import { Dialog, DialogProps } from "../components/dialog"; import { Wizard, WizardStep } from "../components/wizard"; import { Input } from "../components/input"; -import { cssNames, noop } from "../utils"; -import { addPortForward, getPortForwards, modifyPortForward } from "./port-forward.store"; -import type { ForwardedPort } from "./port-forward-item"; +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 Props extends Partial {} -interface PortForwardDialogOpenOptions { - openInBrowser: boolean; - onClose: () => void; +interface Dependencies { + portForwardStore: PortForwardStore, + model: PortForwardDialogModel } -const dialogState = observable.object({ - isOpen: false, - data: null as ForwardedPort, - useHttps: false, - openInBrowser: false, - onClose: noop, -}); - @observer -export class PortForwardDialog extends Component { +class NonInjectedPortForwardDialog extends Component { @observable currentPort = 0; @observable desiredPort = 0; - constructor(props: Props) { + constructor(props: Props & Dependencies) { super(props); makeObservable(this); } - static open(portForward: ForwardedPort, options: PortForwardDialogOpenOptions = { openInBrowser: false, onClose: noop }) { - dialogState.isOpen = true; - dialogState.data = portForward; - dialogState.useHttps = portForward.protocol === "https"; - dialogState.openInBrowser = options.openInBrowser; - dialogState.onClose = options.onClose; - } - - static close() { - dialogState.isOpen = false; - } - - get portForward() { - return dialogState.data; + get portForwardStore() { + return this.props.portForwardStore; } - close = () => { - PortForwardDialog.close(); - }; - onOpen = async () => { - const { portForward } = this; - - this.currentPort = +portForward.forwardPort; + this.currentPort = +this.props.model.portForward.forwardPort; this.desiredPort = this.currentPort; }; @@ -93,26 +69,26 @@ export class PortForwardDialog extends Component { }; startPortForward = async () => { - let { portForward } = this; - const { currentPort, desiredPort, close } = this; + let { portForward } = this.props.model; + const { currentPort, desiredPort } = this; try { // determine how many port-forwards already exist - const { length } = getPortForwards(); + const { length } = this.portForwardStore.getPortForwards(); - portForward.protocol = dialogState.useHttps ? "https" : "http"; + portForward.protocol = this.props.model.useHttps ? "https" : "http"; if (currentPort) { const wasRunning = portForward.status === "Active"; - portForward = await modifyPortForward(portForward, desiredPort); - + 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 addPortForward(portForward); + 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`); @@ -124,13 +100,13 @@ export class PortForwardDialog extends Component { } } - if (portForward.status === "Active" && dialogState.openInBrowser) { + if (portForward.status === "Active" && this.props.model.openInBrowser) { openPortForward(portForward); } } catch (error) { logger.error(`[PORT-FORWARD-DIALOG]: ${error}`, portForward); } finally { - close(); + this.props.model.close(); } }; @@ -154,14 +130,14 @@ export class PortForwardDialog extends Component { dialogState.useHttps = value} + value={this.props.model.useHttps} + onChange={value => this.props.model.useHttps = value} /> dialogState.openInBrowser = value} + value={this.props.model.openInBrowser} + onChange={value => this.props.model.openInBrowser = value} />
@@ -169,8 +145,8 @@ export class PortForwardDialog extends Component { } render() { - const { className, ...dialogProps } = this.props; - const resourceName = this.portForward?.name ?? ""; + const { className, portForwardStore, model, ...dialogProps } = this.props; + const resourceName = this.props.model.portForward?.name ?? ""; const header = (
Port Forwarding for {resourceName} @@ -180,13 +156,13 @@ export class PortForwardDialog extends Component { return ( - + { ); } } + +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 new file mode 100644 index 000000000000..a2341ba818d1 --- /dev/null +++ b/src/renderer/port-forward/port-forward-store/port-forward-store.injectable.ts @@ -0,0 +1,41 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +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-store/port-forward-store.ts b/src/renderer/port-forward/port-forward-store/port-forward-store.ts new file mode 100644 index 000000000000..d39c65d01ffd --- /dev/null +++ b/src/renderer/port-forward/port-forward-store/port-forward-store.ts @@ -0,0 +1,387 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +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 { waitUntilFree } from "tcp-port-used"; +import logger from "../../../common/logger"; + +interface Dependencies { + storage: StorageHelper +} + +export class PortForwardStore extends ItemStore { + @observable portForwards: PortForwardItem[] = []; + + constructor(private dependencies: Dependencies) { + super(); + makeObservable(this); + autoBind(this); + + this.init(); + } + + 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)); + + // 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)); + + for (const result of results) { + if (result.status === "rejected" || result.value.status === "Disabled") { + notifyErrorPortForwarding("One or more port-forwards could not be started"); + + return; + } + } + } + } + + watch() { + return disposer( + reaction( + () => this.portForwards.slice(), + () => this.loadAll(), + ), + ); + } + + loadAll() { + return this.loadItems(() => { + const portForwards = this.getPortForwards(); + + this.dependencies.storage.set(portForwards); + + this.portForwards = []; + portForwards.map((pf) => this.portForwards.push(new PortForwardItem(pf))); + + return this.portForwards; + }); + } + + async removeSelectedItems() { + return Promise.all(this.selectedItems.map(this.remove)); + } + + getById(id: string) { + const index = this.getIndexById(id); + + if (index === -1) { + return null; + } + + 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)); + }; + + private setPortForward = action((portForward: ForwardedPort) => { + const index = this.portForwards.findIndex(portForwardsEqual(portForward)); + + if (index < 0) { + return; + } + + this.portForwards[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 + * used + * + * @returns the port-forward with updated status ("Active" if successfully started, "Disabled" otherwise) and + * forwardPort + * + * @throws if the port-forward does not already exist in the store + */ + start = action(async (portForward: ForwardedPort): Promise => { + const pf = this.findPortForward(portForward); + + if (!pf) { + throw new Error("cannot start non-existent port-forward"); + } + + const { port, forwardPort } = pf; + let response: PortForwardResult; + + try { + const protocol = pf.protocol ?? "http"; + + response = await apiBase.post( + `/pods/port-forward/${pf.namespace}/${pf.kind}/${pf.name}`, + { query: { port, forwardPort, protocol }}, + ); + + // 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}`, + ); + } + + pf.forwardPort = response.port; + pf.status = "Active"; + } catch (error) { + logger.warn( + `[PORT-FORWARD-STORE] Error starting port-forward: ${error}`, + pf, + ); + pf.status = "Disabled"; + } + + this.setPortForward(pf); + + return pf as ForwardedPort; + }); + + /** + * get a port-forward from the store, with up-to-date status + * @param portForward the port-forward to get. + * + * @returns the port-forward with updated status ("Active" if running, "Disabled" if not) and + * forwardPort used. + * + * @throws if the port-forward does not exist in the store + */ + getPortForward = async ( + portForward: ForwardedPort, + ): Promise => { + if (!this.findPortForward(portForward)) { + throw new Error("port-forward not found"); + } + + let pf: ForwardedPort; + + try { + // check if the port-forward is active, and if so check if it has the same local port + pf = await getActivePortForward(portForward); + + if (pf.forwardPort && pf.forwardPort !== portForward.forwardPort) { + logger.warn( + `[PORT-FORWARD-STORE] local port, expected ${pf.forwardPort}, got ${portForward.forwardPort}`, + ); + } + } catch (error) { + // port is not active + } + + 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, protocol } = portForward; + let response: PortForwardResult; + + try { + response = await apiBase.get(`/pods/port-forward/${portForward.namespace}/${portForward.kind}/${portForward.name}`, { query: { port, forwardPort, protocol }}); + } 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; +} diff --git a/src/renderer/port-forward/port-forward.store.ts b/src/renderer/port-forward/port-forward.store.ts deleted file mode 100644 index e914fff7d5c5..000000000000 --- a/src/renderer/port-forward/port-forward.store.ts +++ /dev/null @@ -1,351 +0,0 @@ -/** - * Copyright (c) 2021 OpenLens Authors - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to - * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of - * the Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER - * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN - * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ - - -import { action, makeObservable, observable, reaction } from "mobx"; -import { ItemStore } from "../../common/item.store"; -import { autoBind, createStorage, disposer } from "../utils"; -import { ForwardedPort, PortForwardItem } from "./port-forward-item"; -import { notifyErrorPortForwarding } from "./port-forward-notify"; -import { apiBase } from "../api"; -import { waitUntilFree } from "tcp-port-used"; -import logger from "../../common/logger"; - -export class PortForwardStore extends ItemStore { - private storage = createStorage("port_forwards", undefined); - - @observable portForwards: PortForwardItem[] = []; - - constructor() { - super(); - makeObservable(this); - autoBind(this); - - this.init(); - } - - private async init() { - await this.storage.whenReady; - - const savedPortForwards = this.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(addPortForward)); - - // 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(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; - } - } - } - } - - watch() { - return disposer( - reaction(() => portForwardStore.portForwards.slice(), () => portForwardStore.loadAll()), - ); - } - - loadAll() { - return this.loadItems(() => { - const portForwards = getPortForwards(); - - this.storage.set(portForwards); - - this.portForwards = []; - portForwards.map(pf => this.portForwards.push(new PortForwardItem(pf))); - - return this.portForwards; - }); - } - - async removeSelectedItems() { - return Promise.all(this.selectedItems.map(removePortForward)); - } - - getById(id: string) { - const index = this.getIndexById(id); - - if (index === -1) { - return null; - } - - return this.getItems()[index]; - } -} - -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 - ); -} - -function findPortForward(portForward: ForwardedPort) { - return portForwardStore.portForwards.find(portForwardsEqual(portForward)); - -} - -const setPortForward = action((portForward: ForwardedPort) => { - const index = portForwardStore.portForwards.findIndex(portForwardsEqual(portForward)); - - if (index < 0 ) { - return; - } - - portForwardStore.portForwards[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 - * used - * - * @returns the port-forward with updated status ("Active" if successfully started, "Disabled" otherwise) and - * forwardPort - * - * @throws if the port-forward does not already exist in the store - */ -export const startPortForward = action( async (portForward: ForwardedPort): Promise => { - const pf = findPortForward(portForward); - - if (!pf) { - throw new Error("cannot start non-existent port-forward"); - } - - const { port, forwardPort } = pf; - let response: PortForwardResult; - - try { - const protocol = pf.protocol ?? "http"; - - response = await apiBase.post(`/pods/port-forward/${pf.namespace}/${pf.kind}/${pf.name}`, { query: { port, forwardPort, protocol }}); - - // 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}`); - } - - pf.forwardPort = response.port; - pf.status = "Active"; - - } catch (error) { - logger.warn(`[PORT-FORWARD-STORE] Error starting port-forward: ${error}`, pf); - pf.status = "Disabled"; - } - - setPortForward(pf); - - return pf as ForwardedPort; -}); - -/** - * 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 - */ -export const addPortForward = action(async (portForward: ForwardedPort): Promise => { - const pf = findPortForward(portForward); - - if (pf) { - return pf; - } - - portForwardStore.portForwards.push(new PortForwardItem(portForward)); - - if (!portForward.status) { - portForward.status = "Active"; - } - - if (portForward.status === "Active") { - portForward = await startPortForward(portForward); - } - - return portForward; -}); - -async function getActivePortForward(portForward: ForwardedPort): Promise { - const { port, forwardPort, protocol } = portForward; - let response: PortForwardResult; - - try { - response = await apiBase.get(`/pods/port-forward/${portForward.namespace}/${portForward.kind}/${portForward.name}`, { query: { port, forwardPort, protocol }}); - } 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; -} - -/** - * get a port-forward from the store, with up-to-date status - * @param portForward the port-forward to get. - * - * @returns the port-forward with updated status ("Active" if running, "Disabled" if not) and - * forwardPort used. - * - * @throws if the port-forward does not exist in the store - */ -export async function getPortForward(portForward: ForwardedPort): Promise { - if (!findPortForward(portForward)) { - throw new Error("port-forward not found"); - } - - let pf: ForwardedPort; - - try { - // check if the port-forward is active, and if so check if it has the same local port - pf = await getActivePortForward(portForward); - - if (pf.forwardPort && pf.forwardPort !== portForward.forwardPort) { - logger.warn(`[PORT-FORWARD-STORE] local port, expected ${pf.forwardPort}, got ${portForward.forwardPort}`); - } - } catch (error) { - // port is not active - } - - return pf; -} - -/** - * 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. - */ -export const modifyPortForward = action(async (portForward: ForwardedPort, desiredPort: number): Promise => { - const pf = findPortForward(portForward); - - if (!pf) { - throw new Error("port-forward not found"); - } - - if (pf.status === "Active") { - try { - await stopPortForward(pf); - } catch { - // ignore, assume it is stopped and proceed to restart it - } - - pf.forwardPort = desiredPort; - pf.protocol = portForward.protocol ?? "http"; - setPortForward(pf); - - return await startPortForward(pf); - } - - pf.forwardPort = desiredPort; - setPortForward(pf); - - return pf as ForwardedPort; -}); - - -/** - * 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 - */ -export const stopPortForward = action(async (portForward: ForwardedPort) => { - const pf = 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"; - setPortForward(pf); -}); - -/** - * remove and stop an existing port-forward. - * @param portForward the port-forward to remove. - */ -export const removePortForward = action(async (portForward: ForwardedPort) => { - const pf = 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 stopPortForward(portForward); - } catch (error) { - if (pf.status === "Active") { - logger.warn(`[PORT-FORWARD-STORE] Error removing port-forward: ${error}`, portForward); - } - } - - const index = portForwardStore.portForwards.findIndex(portForwardsEqual(portForward)); - - if (index >= 0 ) { - portForwardStore.portForwards.splice(index, 1); - } -}); - -/** - * gets the list of port-forwards in the store - * - * @returns the port-forwards - */ -export function getPortForwards(): ForwardedPort[] { - return portForwardStore.portForwards; -} - -export const portForwardStore = new PortForwardStore(); 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 e247b3dde90a..4635733374b4 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 @@ -23,7 +23,7 @@ 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"; +import { ClusterStore } from "../../../common/cluster-store/cluster-store"; import { EXTENSION_NAME_MATCH, EXTENSION_PUBLISHER_MATCH, diff --git a/src/renderer/protocol-handler/lens-protocol-router-renderer/lens-protocol-router-renderer.injectable.ts b/src/renderer/protocol-handler/lens-protocol-router-renderer/lens-protocol-router-renderer.injectable.ts index f96f33fcb6ca..aef39908c297 100644 --- a/src/renderer/protocol-handler/lens-protocol-router-renderer/lens-protocol-router-renderer.injectable.ts +++ b/src/renderer/protocol-handler/lens-protocol-router-renderer/lens-protocol-router-renderer.injectable.ts @@ -21,11 +21,14 @@ import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; import extensionLoaderInjectable from "../../../extensions/extension-loader/extension-loader.injectable"; import { LensProtocolRouterRenderer } from "./lens-protocol-router-renderer"; +import extensionsStoreInjectable + from "../../../extensions/extensions-store/extensions-store.injectable"; const lensProtocolRouterRendererInjectable = getInjectable({ instantiate: (di) => new LensProtocolRouterRenderer({ extensionLoader: di.inject(extensionLoaderInjectable), + extensionsStore: di.inject(extensionsStoreInjectable), }), lifecycle: lifecycleEnum.singleton, diff --git a/src/renderer/protocol-handler/lens-protocol-router-renderer/lens-protocol-router-renderer.tsx b/src/renderer/protocol-handler/lens-protocol-router-renderer/lens-protocol-router-renderer.tsx index bd3edc1bd580..43407a793ff4 100644 --- a/src/renderer/protocol-handler/lens-protocol-router-renderer/lens-protocol-router-renderer.tsx +++ b/src/renderer/protocol-handler/lens-protocol-router-renderer/lens-protocol-router-renderer.tsx @@ -27,6 +27,7 @@ import { onCorrect } from "../../../common/ipc"; import { foldAttemptResults, ProtocolHandlerInvalid, RouteAttempt } from "../../../common/protocol-handler"; import { Notifications } from "../../components/notifications"; import type { ExtensionLoader } from "../../../extensions/extension-loader"; +import type { ExtensionsStore } from "../../../extensions/extensions-store/extensions-store"; function verifyIpcArgs(args: unknown[]): args is [string, RouteAttempt] { if (args.length !== 2) { @@ -49,6 +50,7 @@ function verifyIpcArgs(args: unknown[]): args is [string, RouteAttempt] { interface Dependencies { extensionLoader: ExtensionLoader + extensionsStore: ExtensionsStore } diff --git a/src/renderer/root-frame.tsx b/src/renderer/root-frame.tsx deleted file mode 100644 index 33132570c029..000000000000 --- a/src/renderer/root-frame.tsx +++ /dev/null @@ -1,110 +0,0 @@ -/** - * Copyright (c) 2021 OpenLens Authors - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to - * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of - * the Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER - * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN - * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ - -import { injectSystemCAs } from "../common/system-ca"; -import React 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"; -import { ConfirmDialog } from "./components/confirm-dialog"; -import type { ExtensionLoader } from "../extensions/extension-loader"; -import { broadcastMessage, BundledExtensionsLoaded } from "../common/ipc"; -import { CommandContainer } from "./components/command-palette/command-container"; -import { registerIpcListeners } from "./ipc"; -import { ipcRenderer } from "electron"; -import { IpcRendererNavigationEvents } from "./navigation/events"; -import { catalogEntityRegistry } from "./api/catalog-entity-registry"; -import logger from "../common/logger"; -import { unmountComponentAtNode } from "react-dom"; -import { ClusterFrameHandler } from "./components/cluster-manager/lens-views"; -import type { LensProtocolRouterRenderer } from "./protocol-handler"; -import { delay } from "./utils"; - -injectSystemCAs(); - -@observer -export class RootFrame extends React.Component { - static readonly logPrefix = "[ROOT-FRAME]:"; - static displayName = "RootFrame"; - - static async init( - rootElem: HTMLElement, - extensionLoader: ExtensionLoader, - bindProtocolAddRouteHandlers: () => void, - lensProtocolRouterRendererInjectable: LensProtocolRouterRenderer, - ) { - catalogEntityRegistry.init(); - - try { - // maximum time to let bundled extensions finish loading - const timeout = delay(10000); - - const loadingExtensions = extensionLoader.loadOnClusterManagerRenderer(); - const loadingBundledExtensions = loadingExtensions.filter(e => e.isBundled).map(e => e.loaded); - const bundledExtensionsFinished = Promise.all(loadingBundledExtensions); - - await Promise.race([bundledExtensionsFinished, timeout]); - } finally { - ipcRenderer.send(BundledExtensionsLoaded); - } - lensProtocolRouterRendererInjectable.init(); - - bindProtocolAddRouteHandlers(); - - window.addEventListener("offline", () => broadcastMessage("network:offline")); - window.addEventListener("online", () => broadcastMessage("network:online")); - - registerIpcListeners(); - - window.addEventListener("beforeunload", () => { - logger.info(`${RootFrame.logPrefix} Unload app`); - unmountComponentAtNode(rootElem); - }); - } - - constructor(props: {}) { - super(props); - - ClusterFrameHandler.createInstance(); - } - - componentDidMount() { - ipcRenderer.send(IpcRendererNavigationEvents.LOADED); - } - - render() { - return ( - - - - - - - - - - - ); - } -} diff --git a/src/renderer/search-store/search-store.injectable.ts b/src/renderer/search-store/search-store.injectable.ts new file mode 100644 index 000000000000..43f79310e16b --- /dev/null +++ b/src/renderer/search-store/search-store.injectable.ts @@ -0,0 +1,33 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +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/common/__tests__/search-store.test.ts b/src/renderer/search-store/search-store.test.ts similarity index 86% rename from src/common/__tests__/search-store.test.ts rename to src/renderer/search-store/search-store.test.ts index bfb183ccdc1d..93b3d8624435 100644 --- a/src/common/__tests__/search-store.test.ts +++ b/src/renderer/search-store/search-store.test.ts @@ -19,9 +19,13 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import { SearchStore } from "../search-store"; +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: { @@ -31,7 +35,6 @@ jest.mock("electron", () => ({ console = new Console(stdout, stderr); -let searchStore: SearchStore = null; 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", @@ -39,8 +42,16 @@ const logs = [ ]; describe("search store tests", () => { + let searchStore: SearchStore; + beforeEach(async () => { - searchStore = new SearchStore(); + 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", () => { diff --git a/src/common/search-store.ts b/src/renderer/search-store/search-store.ts similarity index 94% rename from src/common/search-store.ts rename to src/renderer/search-store/search-store.ts index 6827548bda79..3c2df6897ce4 100644 --- a/src/common/search-store.ts +++ b/src/renderer/search-store/search-store.ts @@ -20,8 +20,12 @@ */ import { action, computed, observable, reaction, makeObservable } from "mobx"; -import { dockStore } from "../renderer/components/dock/dock.store"; -import { boundMethod } from "../renderer/utils"; +import type { DockStore } from "../components/dock/dock-store/dock.store"; +import { boundMethod } from "../utils"; + +interface Dependencies { + dockStore: DockStore +} export class SearchStore { /** @@ -53,10 +57,10 @@ export class SearchStore { */ @observable activeOverlayIndex = -1; - constructor() { + constructor(dependencies: Dependencies) { makeObservable(this); - reaction(() => dockStore.selectedTabId, () => { - searchStore.reset(); + reaction(() => dependencies.dockStore.selectedTabId, () => { + this.reset(); }); } @@ -174,5 +178,3 @@ export class SearchStore { this.occurrences = []; } } - -export const searchStore = new SearchStore; diff --git a/src/renderer/utils/create-storage/create-storage.injectable.ts b/src/renderer/utils/create-storage/create-storage.injectable.ts new file mode 100644 index 000000000000..ea5a57be120e --- /dev/null +++ b/src/renderer/utils/create-storage/create-storage.injectable.ts @@ -0,0 +1,41 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import directoryForLensLocalStorageInjectable from "../../../common/directory-for-lens-local-storage/directory-for-lens-local-storage.injectable"; +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"; + +const createStorageInjectable = getInjectable({ + instantiate: (di) => + createStorage({ + readJsonFile: di.inject(readJsonFileInjectable), + writeJsonFile: di.inject(writeJsonFileInjectable), + + directoryForLensLocalStorage: di.inject( + directoryForLensLocalStorageInjectable, + ), + }), + + lifecycle: lifecycleEnum.singleton, +}); + +export default createStorageInjectable; diff --git a/src/renderer/utils/createStorage.ts b/src/renderer/utils/create-storage/create-storage.ts similarity index 76% rename from src/renderer/utils/createStorage.ts rename to src/renderer/utils/create-storage/create-storage.ts index c282994e618d..6a6389831b71 100755 --- a/src/renderer/utils/createStorage.ts +++ b/src/renderer/utils/create-storage/create-storage.ts @@ -23,10 +23,12 @@ // 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 fse from "fs-extra"; -import { StorageHelper } from "./storageHelper"; -import logger from "../../main/logger"; -import { isTestEnv } from "../../common/vars"; +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"; const storage = observable({ initialized: false, @@ -34,25 +36,32 @@ const storage = observable({ data: {} as Record, // json-serializable }); +interface Dependencies { + directoryForLensLocalStorage: string; + readJsonFile: (filePath: string) => Promise; + writeJsonFile: (filePath: string, contentObject: JsonObject) => Promise; +} + /** * Creates a helper for saving data under the "key" intended for window.localStorage - * @param key The descriptor of the data - * @param defaultValue The default value of the data, must be JSON serializable */ -export function createStorage(key: string, defaultValue: T) { +export const createStorage = ({ directoryForLensLocalStorage, readJsonFile, writeJsonFile }: Dependencies) => (key: string, defaultValue: T) => { const { logPrefix } = StorageHelper; if (!storage.initialized) { storage.initialized = true; (async () => { - const filePath = await StorageHelper.getLocalStoragePath(); + const filePath = path.resolve(directoryForLensLocalStorage, `${getHostedClusterId() || "app"}.json`); try { - storage.data = await fse.readJson(filePath); - } catch { - // ignore error - } finally { + storage.data = await readJsonFile(filePath); + } + + // eslint-disable-next-line no-empty + catch {} + + finally { if (!isTestEnv) { logger.info(`${logPrefix} loading finished for ${filePath}`); } @@ -70,8 +79,7 @@ export function createStorage(key: string, defaultValue: T) { logger.info(`${logPrefix} saving ${filePath}`); try { - await fse.ensureDir(path.dirname(filePath), { mode: 0o755 }); - await fse.writeJson(filePath, state, { spaces: 2 }); + await writeJsonFile(filePath, state); } catch (error) { logger.error(`${logPrefix} saving failed: ${error}`, { json: state, jsonFilePath: filePath, @@ -99,4 +107,4 @@ export function createStorage(key: string, defaultValue: T) { }, }, }); -} +}; diff --git a/src/renderer/utils/index.ts b/src/renderer/utils/index.ts index 54a99fa34520..8a9c431ee10c 100755 --- a/src/renderer/utils/index.ts +++ b/src/renderer/utils/index.ts @@ -20,11 +20,9 @@ */ // Common usage utils & helpers - export * from "../../common/utils"; export * from "../../common/event-emitter"; -export * from "./createStorage"; export * from "./cssNames"; export * from "./cssVar"; export * from "./display-booleans"; diff --git a/src/renderer/utils/storageHelper.ts b/src/renderer/utils/storageHelper.ts index b5d5e6abde49..457cdecf72ef 100755 --- a/src/renderer/utils/storageHelper.ts +++ b/src/renderer/utils/storageHelper.ts @@ -20,13 +20,10 @@ */ // Helper for working with storages (e.g. window.localStorage, NodeJS/file-system, etc.) -import { action, comparer, makeObservable, observable, toJS, when } from "mobx"; +import { action, comparer, computed, makeObservable, observable, toJS, when } from "mobx"; import { produce, Draft, isDraft } from "immer"; import { isEqual, isPlainObject } from "lodash"; import logger from "../../main/logger"; -import { getHostedClusterId } from "../../common/utils"; -import path from "path"; -import { AppPaths } from "../../common/app-paths"; export interface StorageAdapter { [metadata: string]: any; @@ -43,10 +40,6 @@ export interface StorageHelperOptions { } export class StorageHelper { - static async getLocalStoragePath() { - return path.resolve(await AppPaths.getAsync("userData"), "lens-local-storage", `${getHostedClusterId() || "app"}.json`); - } - static logPrefix = "[StorageHelper]:"; readonly storage: StorageAdapter; @@ -73,6 +66,7 @@ export class StorageHelper { this.storage = storage; + // TODO: This code uses undocumented MobX internal to criminally permit exotic mutations without encapsulation. this.data.observe_(({ newValue, oldValue }) => { this.onChange(newValue as T, oldValue as T); }); @@ -136,7 +130,15 @@ export class StorageHelper { } } + /** + * @deprecated Switch to using value for being reactive + */ get(): T { + return this.value; + } + + @computed + get value(): T { return this.data.get() ?? this.defaultValue; } diff --git a/src/test-utils/get-dis-for-unit-testing.ts b/src/test-utils/get-dis-for-unit-testing.ts new file mode 100644 index 000000000000..dc871b75bcf7 --- /dev/null +++ b/src/test-utils/get-dis-for-unit-testing.ts @@ -0,0 +1,36 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +import { getDiForUnitTesting as getRendererDi } from "../renderer/getDiForUnitTesting"; +import { getDiForUnitTesting as getMainDi } from "../main/getDiForUnitTesting"; +import { overrideIpcBridge } from "./override-ipc-bridge"; + +export const getDisForUnitTesting = ({ doGeneralOverrides } = { doGeneralOverrides: false }) => { + const rendererDi = getRendererDi({ doGeneralOverrides }); + const mainDi = getMainDi({ doGeneralOverrides }); + + overrideIpcBridge({ rendererDi, mainDi }); + + return { + rendererDi, + mainDi, + runSetups: () => Promise.all([rendererDi.runSetups(), mainDi.runSetups()]), + }; +}; diff --git a/src/test-utils/override-ipc-bridge.ts b/src/test-utils/override-ipc-bridge.ts new file mode 100644 index 000000000000..b76e346ed224 --- /dev/null +++ b/src/test-utils/override-ipc-bridge.ts @@ -0,0 +1,82 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +import type { DependencyInjectionContainer } from "@ogre-tools/injectable"; +import type { Channel } from "../common/ipc-channel/channel"; +import getValueFromRegisteredChannelInjectable from "../renderer/app-paths/get-value-from-registered-channel/get-value-from-registered-channel.injectable"; +import registerChannelInjectable from "../main/app-paths/register-channel/register-channel.injectable"; +import asyncFn from "@async-fn/jest"; + +export const overrideIpcBridge = ({ + rendererDi, + mainDi, +}: { + rendererDi: DependencyInjectionContainer; + mainDi: DependencyInjectionContainer; +}) => { + const fakeChannelMap = new Map< + Channel, + { promise: Promise; resolve: (arg0: any) => Promise } + >(); + + const mainIpcRegistrations = { + set: , TInstance>( + key: TChannel, + callback: () => TChannel["_template"], + ) => { + if (!fakeChannelMap.has(key)) { + const mockInstance = asyncFn(); + + fakeChannelMap.set(key, { + promise: mockInstance(), + resolve: mockInstance.resolve, + }); + } + + return fakeChannelMap.get(key).resolve(callback); + }, + + get: , TInstance>(key: TChannel) => { + if (!fakeChannelMap.has(key)) { + const mockInstance = asyncFn(); + + fakeChannelMap.set(key, { + promise: mockInstance(), + resolve: mockInstance.resolve, + }); + } + + return fakeChannelMap.get(key).promise; + }, + }; + + rendererDi.override( + getValueFromRegisteredChannelInjectable, + () => async (channel) => { + const callback = await mainIpcRegistrations.get(channel); + + return callback(); + }, + ); + + mainDi.override(registerChannelInjectable, () => (channel, callback) => { + mainIpcRegistrations.set(channel, callback); + }); +}; diff --git a/yarn.lock b/yarn.lock index 70fb22d9fbdd..9437aff4b73d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -972,28 +972,28 @@ "@nodelib/fs.scandir" "2.1.3" fastq "^1.6.0" -"@ogre-tools/fp@^2.0.0": - version "2.0.0" - resolved "https://registry.yarnpkg.com/@ogre-tools/fp/-/fp-2.0.0.tgz#751b280959e7b7132e85ae031a96ea59c951178f" - integrity sha512-L3UWyIHuA1z746jSsHV4iwy5Yc/qz8g/0YktiMgYjU6aU5jFtX0vaVVS7MRJWMnOAFg5JcJyg3KqgyYXSe5GKA== +"@ogre-tools/fp@^3.0.0": + version "3.0.0" + resolved "https://registry.yarnpkg.com/@ogre-tools/fp/-/fp-3.0.0.tgz#808292075644590a0f875211361f8650dee0587b" + integrity sha512-fZdeMY7Jj690V/RnJ2Lc3hnEGATJ1rp1R4pb1k0tiQgDC4Q9Neekv76z4HereOTG1SjpdF6tJTIaff/vJ/vYqA== dependencies: lodash "^4.17.21" -"@ogre-tools/injectable-react@2.0.0": - version "2.0.0" - resolved "https://registry.yarnpkg.com/@ogre-tools/injectable-react/-/injectable-react-2.0.0.tgz#4a0990426dba0485d6c71e9693ba8882aa42a5d0" - integrity sha512-yhYi7jhhi2CitO07xVPHOWBCOESmTgmGcSILWoY6IPjoKdzl88PqMkTem8kO+k6YO3sTQQexEQcCi9NEmR9hOg== +"@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== dependencies: - "@ogre-tools/fp" "^2.0.0" - "@ogre-tools/injectable" "^2.0.0" + "@ogre-tools/fp" "^3.0.0" + "@ogre-tools/injectable" "^3.1.1" lodash "^4.17.21" -"@ogre-tools/injectable@2.0.0", "@ogre-tools/injectable@^2.0.0": - version "2.0.0" - resolved "https://registry.yarnpkg.com/@ogre-tools/injectable/-/injectable-2.0.0.tgz#16e6255062443ae8aca9a2d149c5b591533b63a8" - integrity sha512-9R8J9vJp/obaKKTGSDiJ+PTodJtZ2JCUdzGexL2lQK4s/xvVTjpBhCgQZVCG87uA/6d0oB6rxov/Dkv001rwCw== +"@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== dependencies: - "@ogre-tools/fp" "^2.0.0" + "@ogre-tools/fp" "^3.0.0" lodash "^4.17.21" "@panva/asn1.js@^1.0.0":