diff --git a/integration/__tests__/cluster-pages.tests.ts b/integration/__tests__/cluster-pages.tests.ts index 6dc89020e3..375321aaaf 100644 --- a/integration/__tests__/cluster-pages.tests.ts +++ b/integration/__tests__/cluster-pages.tests.ts @@ -354,8 +354,7 @@ utils.describeIf(minikubeReady(TEST_NAMESPACE))("Minikube based tests", () => { } }, 10*60*1000); - // TODO: Make re-rendering of KubeObjectListLayout not cause namespaceSelector to be closed - xit("show logs and highlight the log search entries", async () => { + it("show logs and highlight the log search entries", async () => { await frame.click(`a[href="/workloads"]`); await frame.click(`a[href="/pods"]`); @@ -400,8 +399,7 @@ utils.describeIf(minikubeReady(TEST_NAMESPACE))("Minikube based tests", () => { await frame.waitForSelector("div.TableCell >> text='kube-system'"); }, 10*60*1000); - // 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 () => { + it(`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 829b7d7df0..ebb8d290e6 100644 --- a/package.json +++ b/package.json @@ -349,6 +349,7 @@ "fork-ts-checker-webpack-plugin": "^5.2.1", "hoist-non-react-statics": "^3.3.2", "html-webpack-plugin": "^4.5.2", + "ignore-loader": "^0.1.2", "include-media": "^1.4.9", "jest": "26.6.3", "jest-canvas-mock": "^2.3.1", diff --git a/src/renderer/components/dock/logs/update-tab-name.injectable.ts b/src/common/fs/read-dir.injectable.ts similarity index 54% rename from src/renderer/components/dock/logs/update-tab-name.injectable.ts rename to src/common/fs/read-dir.injectable.ts index 24074e4cb6..501ecc1cdd 100644 --- a/src/renderer/components/dock/logs/update-tab-name.injectable.ts +++ b/src/common/fs/read-dir.injectable.ts @@ -3,11 +3,11 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; -import logTabStoreInjectable from "./tab-store.injectable"; +import fsInjectable from "./fs.injectable"; -const updateTabNameInjectable = getInjectable({ - instantiate: (di) => di.inject(logTabStoreInjectable).updateTabName, +const readDirInjectable = getInjectable({ + instantiate: (di) => di.inject(fsInjectable).readdir, lifecycle: lifecycleEnum.singleton, }); -export default updateTabNameInjectable; +export default readDirInjectable; diff --git a/src/common/fs/write-json-file/write-json-file.injectable.ts b/src/common/fs/read-file.injectable.ts similarity index 50% rename from src/common/fs/write-json-file/write-json-file.injectable.ts rename to src/common/fs/read-file.injectable.ts index 5ff6cbaec3..5e2871a03d 100644 --- a/src/common/fs/write-json-file/write-json-file.injectable.ts +++ b/src/common/fs/read-file.injectable.ts @@ -3,12 +3,11 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; -import { writeJsonFile } from "./write-json-file"; -import fsInjectable from "../fs.injectable"; +import fsInjectable from "./fs.injectable"; -const writeJsonFileInjectable = getInjectable({ - instantiate: (di) => writeJsonFile({ fs: di.inject(fsInjectable) }), +const readFileInjectable = getInjectable({ + instantiate: (di) => di.inject(fsInjectable).readFile, lifecycle: lifecycleEnum.singleton, }); -export default writeJsonFileInjectable; +export default readFileInjectable; diff --git a/src/common/fs/read-json-file/read-json-file.injectable.ts b/src/common/fs/read-json-file.injectable.ts similarity index 66% rename from src/common/fs/read-json-file/read-json-file.injectable.ts rename to src/common/fs/read-json-file.injectable.ts index b94c904885..b944decadc 100644 --- a/src/common/fs/read-json-file/read-json-file.injectable.ts +++ b/src/common/fs/read-json-file.injectable.ts @@ -2,15 +2,11 @@ * Copyright (c) OpenLens Authors. All rights reserved. * Licensed under MIT License. See LICENSE in root directory for more information. */ -import { readJsonFile } from "./read-json-file"; import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; -import fsInjectable from "../fs.injectable"; +import fsInjectable from "./fs.injectable"; const readJsonFileInjectable = getInjectable({ - instantiate: (di) => readJsonFile({ - fs: di.inject(fsInjectable), - }), - + instantiate: (di) => di.inject(fsInjectable).readJson, lifecycle: lifecycleEnum.singleton, }); diff --git a/src/common/fs/read-json-file/read-json-file.ts b/src/common/fs/read-json-file/read-json-file.ts deleted file mode 100644 index cb22e78ad3..0000000000 --- a/src/common/fs/read-json-file/read-json-file.ts +++ /dev/null @@ -1,16 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ -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.injectable.ts b/src/common/fs/write-json-file.injectable.ts new file mode 100644 index 0000000000..7bef449444 --- /dev/null +++ b/src/common/fs/write-json-file.injectable.ts @@ -0,0 +1,38 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import type { EnsureOptions, WriteOptions } from "fs-extra"; +import path from "path"; +import type { JsonValue } from "type-fest"; +import fsInjectable from "./fs.injectable"; + +interface Dependencies { + writeJson: (file: string, object: any, options?: WriteOptions | BufferEncoding | string) => Promise; + ensureDir: (dir: string, options?: EnsureOptions | number) => Promise; +} + +const writeJsonFile = ({ writeJson, ensureDir }: Dependencies) => async (filePath: string, content: JsonValue) => { + await ensureDir(path.dirname(filePath), { mode: 0o755 }); + + await writeJson(filePath, content, { + encoding: "utf-8", + spaces: 2, + }); +}; + +const writeJsonFileInjectable = getInjectable({ + instantiate: (di) => { + const { writeJson, ensureDir } = di.inject(fsInjectable); + + return writeJsonFile({ + writeJson, + ensureDir, + }); + }, + + 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 deleted file mode 100644 index 8db664593e..0000000000 --- a/src/common/fs/write-json-file/write-json-file.ts +++ /dev/null @@ -1,31 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ -import 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/utils/__tests__/bind.test.ts b/src/common/utils/__tests__/bind.test.ts deleted file mode 100644 index 77850a88d9..0000000000 --- a/src/common/utils/__tests__/bind.test.ts +++ /dev/null @@ -1,19 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ -import { bind } from "../index"; - -describe("bind", () => { - it("should work correctly", () => { - function foobar(bound: number, nonBound: number): number { - expect(typeof bound).toBe("number"); - expect(typeof nonBound).toBe("number"); - - return bound + nonBound; - } - const foobarBound = bind(foobar, null, 5); - - expect(foobarBound(10)).toBe(15); - }); -}); diff --git a/src/common/utils/index.ts b/src/common/utils/index.ts index 1400608aab..d349bd3b14 100644 --- a/src/common/utils/index.ts +++ b/src/common/utils/index.ts @@ -10,13 +10,6 @@ export function noop(...args: T): void { return void args; } -/** - * A typecorrect version of .bind() - */ -export function bind(fn: (...args: [...BoundArgs, ...NonBoundArgs]) => ReturnType, thisArg: any, ...boundArgs: BoundArgs): (...args: NonBoundArgs) => ReturnType { - return fn.bind(thisArg, ...boundArgs); -} - export * from "./app-version"; export * from "./autobind"; export * from "./camelCase"; @@ -49,6 +42,7 @@ export * from "./toggle-set"; export * from "./toJS"; export * from "./type-narrowing"; export * from "./types"; +export * from "./wait-for-path"; import * as iter from "./iter"; import * as array from "./array"; diff --git a/src/common/utils/wait-for-path.ts b/src/common/utils/wait-for-path.ts new file mode 100644 index 0000000000..f5a068075b --- /dev/null +++ b/src/common/utils/wait-for-path.ts @@ -0,0 +1,55 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { FSWatcher } from "chokidar"; +import path from "path"; + +/** + * Wait for `filePath` and all parent directories to exist. + * @param pathname The file path to wait until it exists + * + * NOTE: There is technically a race condition in this function of the form + * "time-of-check to time-of-use" because we have to wait for each parent + * directory to exist first. + */ +export async function waitForPath(pathname: string): Promise { + const dirOfPath = path.dirname(pathname); + + if (dirOfPath === pathname) { + // The root of this filesystem, assume it exists + return; + } else { + await waitForPath(dirOfPath); + } + + return new Promise((resolve, reject) => { + const watcher = new FSWatcher({ + depth: 0, + disableGlobbing: true, + }); + const onAddOrAddDir = (filePath: string) => { + if (filePath === pathname) { + watcher.unwatch(dirOfPath); + watcher + .close() + .then(() => resolve()) + .catch(reject); + } + }; + const onError = (error: any) => { + watcher.unwatch(dirOfPath); + watcher + .close() + .then(() => reject(error)) + .catch(() => reject(error)); + }; + + watcher + .on("add", onAddOrAddDir) + .on("addDir", onAddOrAddDir) + .on("error", onError) + .add(dirOfPath); + }); +} diff --git a/src/common/vars/is-linux.injectable.ts b/src/common/vars/is-linux.injectable.ts new file mode 100644 index 0000000000..a603f01951 --- /dev/null +++ b/src/common/vars/is-linux.injectable.ts @@ -0,0 +1,13 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { isLinux } from "../vars"; + +const isLinuxInjectable = getInjectable({ + instantiate: () => isLinux, + lifecycle: lifecycleEnum.singleton, +}); + +export default isLinuxInjectable; diff --git a/src/common/vars/is-windows.injectable.ts b/src/common/vars/is-windows.injectable.ts new file mode 100644 index 0000000000..76a08a4af2 --- /dev/null +++ b/src/common/vars/is-windows.injectable.ts @@ -0,0 +1,13 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { isWindows } from "../vars"; + +const isWindowsInjectable = getInjectable({ + instantiate: () => isWindows, + lifecycle: lifecycleEnum.singleton, +}); + +export default isWindowsInjectable; diff --git a/src/extensions/as-legacy-globals-for-extension-api/as-legacy-global-object-for-extension-api-with-modifications.ts b/src/extensions/as-legacy-globals-for-extension-api/as-legacy-global-object-for-extension-api-with-modifications.ts new file mode 100644 index 0000000000..8961ea8424 --- /dev/null +++ b/src/extensions/as-legacy-globals-for-extension-api/as-legacy-global-object-for-extension-api-with-modifications.ts @@ -0,0 +1,47 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import type { Injectable } from "@ogre-tools/injectable"; +import { getLegacyGlobalDiForExtensionApi } from "./legacy-global-di-for-extension-api"; + +type TentativeTuple = T extends object ? [T] : [undefined?]; + +type MapInjectables = { + [Key in keyof T]: T[Key] extends () => infer Res ? Res : never; +}; + +export const asLegacyGlobalObjectForExtensionApiWithModifications = < + TInjectable extends Injectable, + TInstantiationParameter, + OtherFields extends Record any>, +>( + injectableKey: TInjectable, + otherFields: OtherFields, + ...instantiationParameter: TentativeTuple + ) => + new Proxy( + {}, + { + get(target, propertyName) { + if (propertyName === "$$typeof") { + return undefined; + } + + const instance: any = getLegacyGlobalDiForExtensionApi().inject( + injectableKey, + ...instantiationParameter, + ); + + const propertyValue = instance[propertyName] ?? otherFields[propertyName as any](); + + if (typeof propertyValue === "function") { + return function (...args: any[]) { + return propertyValue.apply(instance, args); + }; + } + + return propertyValue; + }, + }, + ) as ReturnType & MapInjectables; diff --git a/src/extensions/as-legacy-globals-for-extension-api/as-legacy-global-singleton-for-extension-api.ts b/src/extensions/as-legacy-globals-for-extension-api/as-legacy-global-singleton-for-extension-api.ts deleted file mode 100644 index 4e77961ca8..0000000000 --- a/src/extensions/as-legacy-globals-for-extension-api/as-legacy-global-singleton-for-extension-api.ts +++ /dev/null @@ -1,43 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ -import type { Injectable } from "@ogre-tools/injectable"; -import { getLegacyGlobalDiForExtensionApi } from "./legacy-global-di-for-extension-api"; - -type TentativeTuple = T extends object ? [T] : [undefined?]; - -export const asLegacyGlobalSingletonForExtensionApi = < - TClass extends abstract new (...args: any[]) => any, - TInjectable extends Injectable, - TInstantiationParameter, ->( - Class: TClass, - injectableKey: TInjectable, - ...instantiationParameter: TentativeTuple - ) => - new Proxy(Class, { - construct: () => { - throw new Error("A legacy singleton class must be created by createInstance()"); - }, - - get: (target: any, propertyName) => { - if (propertyName === "getInstance" || propertyName === "createInstance") { - return () => - getLegacyGlobalDiForExtensionApi().inject( - injectableKey, - ...instantiationParameter, - ); - } - - if (propertyName === "resetInstance") { - return () => getLegacyGlobalDiForExtensionApi().purge(injectableKey); - } - - return target[propertyName]; - }, - }) as InstanceType & { - getInstance: () => InstanceType; - createInstance: () => InstanceType; - resetInstance: () => void; - }; diff --git a/src/extensions/renderer-api/components.ts b/src/extensions/renderer-api/components.ts index 54a0f2d2d1..8e6df41fa1 100644 --- a/src/extensions/renderer-api/components.ts +++ b/src/extensions/renderer-api/components.ts @@ -3,14 +3,18 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ import { asLegacyGlobalFunctionForExtensionApi } from "../as-legacy-globals-for-extension-api/as-legacy-global-function-for-extension-api"; -import createTerminalTabInjectable from "../../renderer/components/dock/create-terminal-tab/create-terminal-tab.injectable"; -import terminalStoreInjectable from "../../renderer/components/dock/terminal-store/terminal-store.injectable"; +import createTerminalTabInjectable from "../../renderer/components/dock/terminal/create-terminal-tab.injectable"; +import terminalStoreInjectable from "../../renderer/components/dock/terminal/store.injectable"; import { asLegacyGlobalObjectForExtensionApi } from "../as-legacy-globals-for-extension-api/as-legacy-global-object-for-extension-api"; import logTabStoreInjectable from "../../renderer/components/dock/logs/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 { asLegacyGlobalObjectForExtensionApiWithModifications } from "../as-legacy-globals-for-extension-api/as-legacy-global-object-for-extension-api-with-modifications"; +import createPodLogsTabInjectable from "../../renderer/components/dock/logs/create-pod-logs-tab.injectable"; +import createWorkloadLogsTabInjectable from "../../renderer/components/dock/logs/create-workload-logs-tab.injectable"; +import sendCommandInjectable from "../../renderer/components/dock/terminal/send-command.injectable"; +import { podsStore } from "../../renderer/components/+workloads-pods/pods.store"; +import renameTabInjectable from "../../renderer/components/dock/dock/rename-tab.injectable"; // layouts export * from "../../renderer/components/layout/main-layout"; @@ -71,7 +75,30 @@ export * from "../../renderer/components/+events/kube-event-details"; export * from "../../renderer/components/status-brick"; export const createTerminalTab = asLegacyGlobalFunctionForExtensionApi(createTerminalTabInjectable); -export const TerminalStore = asLegacyGlobalSingletonForExtensionApi(TerminalStoreClass, terminalStoreInjectable); -export const terminalStore = asLegacyGlobalObjectForExtensionApi(terminalStoreInjectable); -export const logTabStore = asLegacyGlobalObjectForExtensionApi(logTabStoreInjectable); +export const terminalStore = asLegacyGlobalObjectForExtensionApiWithModifications(terminalStoreInjectable, { + sendCommand: () => asLegacyGlobalFunctionForExtensionApi(sendCommandInjectable), +}); +export const logTabStore = asLegacyGlobalObjectForExtensionApiWithModifications(logTabStoreInjectable, { + createPodTab: () => asLegacyGlobalFunctionForExtensionApi(createPodLogsTabInjectable), + createWorkloadTab: () => asLegacyGlobalFunctionForExtensionApi(createWorkloadLogsTabInjectable), + renameTab: () => (tabId: string): void => { + const renameTab = asLegacyGlobalFunctionForExtensionApi(renameTabInjectable); + const tabData = logTabStore.getData(tabId); + const pod = podsStore.getById(tabData.selectedPodId); + renameTab(tabId, `Pod ${pod.getName()}`); + }, + tabs: () => undefined, +}); + +export class TerminalStore { + static getInstance() { + return terminalStore; + } + static createInstance() { + return terminalStore; + } + static resetInstance() { + console.warn("TerminalStore.resetInstance() does nothing"); + } +} diff --git a/src/main/getDiForUnitTesting.ts b/src/main/getDiForUnitTesting.ts index 54db181333..ae71fb9618 100644 --- a/src/main/getDiForUnitTesting.ts +++ b/src/main/getDiForUnitTesting.ts @@ -12,8 +12,8 @@ import getElectronAppPathInjectable from "./app-paths/get-electron-app-path/get- 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"; +import writeJsonFileInjectable from "../common/fs/write-json-file.injectable"; +import readJsonFileInjectable from "../common/fs/read-json-file.injectable"; export const getDiForUnitTesting = ( { doGeneralOverrides } = { doGeneralOverrides: false }, 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 936859dbd0..ff7d929e8f 100644 --- a/src/renderer/components/+apps-helm-charts/helm-chart-details.tsx +++ b/src/renderer/components/+apps-helm-charts/helm-chart-details.tsx @@ -18,7 +18,7 @@ import { Select, SelectOption } from "../select"; 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"; +import createInstallChartTabInjectable from "../dock/install-chart/create-install-chart-tab.injectable"; interface Props { chart: HelmChart; diff --git a/src/renderer/components/+apps-helm-charts/helm-charts.tsx b/src/renderer/components/+apps-helm-charts/helm-charts.tsx index 36b84d5267..49f885a093 100644 --- a/src/renderer/components/+apps-helm-charts/helm-charts.tsx +++ b/src/renderer/components/+apps-helm-charts/helm-charts.tsx @@ -12,7 +12,7 @@ import { helmChartStore } from "./helm-chart.store"; import type { HelmChart } from "../../../common/k8s-api/endpoints/helm-charts.api"; import { HelmChartDetails } from "./helm-chart-details"; import { navigation } from "../../navigation"; -import { ItemListLayout } from "../item-object-list/item-list-layout"; +import { ItemListLayout } from "../item-object-list/list-layout"; import { helmChartsURL } from "../../../common/routes"; import type { HelmChartsRouteParams } from "../../../common/routes"; diff --git a/src/renderer/components/+apps-releases/release-details/release-details.tsx b/src/renderer/components/+apps-releases/release-details/release-details.tsx index 77f6ab2d87..30d34e1857 100644 --- a/src/renderer/components/+apps-releases/release-details/release-details.tsx +++ b/src/renderer/components/+apps-releases/release-details/release-details.tsx @@ -27,7 +27,7 @@ import { getDetailsUrl } from "../../kube-detail-params"; import { Checkbox } from "../../checkbox"; import { MonacoEditor } from "../../monaco-editor"; import { IAsyncComputed, withInjectables } from "@ogre-tools/injectable-react"; -import createUpgradeChartTabInjectable from "../../dock/create-upgrade-chart-tab/create-upgrade-chart-tab.injectable"; +import createUpgradeChartTabInjectable from "../../dock/upgrade-chart/create-upgrade-chart-tab.injectable"; import updateReleaseInjectable from "../update-release/update-release.injectable"; import releaseInjectable from "./release.injectable"; import releaseDetailsInjectable from "./release-details.injectable"; diff --git a/src/renderer/components/+apps-releases/release-menu.tsx b/src/renderer/components/+apps-releases/release-menu.tsx index a1dc263806..30824dba15 100644 --- a/src/renderer/components/+apps-releases/release-menu.tsx +++ b/src/renderer/components/+apps-releases/release-menu.tsx @@ -10,7 +10,7 @@ import { MenuActions, MenuActionsProps } from "../menu/menu-actions"; import { MenuItem } from "../menu"; import { Icon } from "../icon"; import { withInjectables } from "@ogre-tools/injectable-react"; -import createUpgradeChartTabInjectable from "../dock/create-upgrade-chart-tab/create-upgrade-chart-tab.injectable"; +import createUpgradeChartTabInjectable from "../dock/upgrade-chart/create-upgrade-chart-tab.injectable"; import releaseRollbackDialogModelInjectable from "./release-rollback-dialog-model/release-rollback-dialog-model.injectable"; import deleteReleaseInjectable from "./delete-release/delete-release.injectable"; diff --git a/src/renderer/components/+apps-releases/releases.tsx b/src/renderer/components/+apps-releases/releases.tsx index a71cc8e7a1..34f9798783 100644 --- a/src/renderer/components/+apps-releases/releases.tsx +++ b/src/renderer/components/+apps-releases/releases.tsx @@ -23,10 +23,8 @@ import { ReleaseRollbackDialog } from "./release-rollback-dialog"; import { ReleaseDetails } from "./release-details/release-details"; import removableReleasesInjectable from "./removable-releases.injectable"; import type { RemovableHelmRelease } from "./removable-releases"; -import { observer } from "mobx-react"; import type { IComputedValue } from "mobx"; import releasesInjectable from "./releases.injectable"; -import { Spinner } from "../spinner"; enum columnId { name = "name", @@ -48,7 +46,6 @@ interface Dependencies { selectNamespace: (namespace: string) => void } -@observer class NonInjectedHelmReleases extends Component { componentDidMount() { const { match: { params: { namespace }}} = this.props; @@ -89,12 +86,8 @@ class NonInjectedHelmReleases extends Component { } render() { - if (this.props.releasesArePending.get()) { - // TODO: Make Spinner "center" work properly - return
; - } - const releases = this.props.releases; + const releasesArePending = this.props.releasesArePending; // TODO: Implement ItemListLayout without stateful stores const legacyReleaseStore = { @@ -103,7 +96,11 @@ class NonInjectedHelmReleases extends Component { }, loadAll: () => Promise.resolve(), - isLoaded: true, + + get isLoaded() { + return !releasesArePending.get(); + }, + failedLoading: false, getTotalCount: () => releases.get().length, diff --git a/src/renderer/components/+catalog/get-category-columns.injectable.ts b/src/renderer/components/+catalog/get-category-columns.injectable.ts index 66fe0cddee..080fde7e29 100644 --- a/src/renderer/components/+catalog/get-category-columns.injectable.ts +++ b/src/renderer/components/+catalog/get-category-columns.injectable.ts @@ -6,7 +6,6 @@ import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; import { orderBy } from "lodash"; import type { IComputedValue } from "mobx"; import type { CatalogCategory, CatalogEntity } from "../../../common/catalog"; -import { bind } from "../../utils"; import type { ItemListLayoutProps } from "../item-object-list"; import type { RegisteredAdditionalCategoryColumn } from "./custom-category-columns"; import categoryColumnsInjectable from "./custom-category-columns.injectable"; @@ -50,7 +49,7 @@ function getBrowseAllColumns(): RegisteredAdditionalCategoryColumn[] { ]; } -function getCategoryColumns({ extensionColumns }: Dependencies, { activeCategory }: GetCategoryColumnsParams): CategoryColumns { +const getCategoryColumns = ({ extensionColumns }: Dependencies) => ({ activeCategory }: GetCategoryColumnsParams): CategoryColumns => { const allRegistrations = orderBy( activeCategory ? getSpecificCategoryColumns(activeCategory, extensionColumns) @@ -83,12 +82,13 @@ function getCategoryColumns({ extensionColumns }: Dependencies, { activeCategory renderTableContents: entity => tableRowRenderers.map(fn => fn(entity)), searchFilters, }; -} +}; const getCategoryColumnsInjectable = getInjectable({ - instantiate: (di) => bind(getCategoryColumns, null, { + instantiate: (di) => getCategoryColumns({ extensionColumns: di.inject(categoryColumnsInjectable), }), + lifecycle: lifecycleEnum.singleton, }); diff --git a/src/renderer/components/+extensions/install-from-select-file-dialog.injectable.ts b/src/renderer/components/+extensions/install-from-select-file-dialog.injectable.ts index 5cf51733d1..eb1597f61f 100644 --- a/src/renderer/components/+extensions/install-from-select-file-dialog.injectable.ts +++ b/src/renderer/components/+extensions/install-from-select-file-dialog.injectable.ts @@ -7,14 +7,13 @@ import { requestOpenFilePickingDialog } from "../../ipc"; import { supportedExtensionFormats } from "./supported-extension-formats"; import attemptInstallsInjectable from "./attempt-installs/attempt-installs.injectable"; import directoryForDownloadsInjectable from "../../../common/app-paths/directory-for-downloads/directory-for-downloads.injectable"; -import { bind } from "../../utils"; interface Dependencies { attemptInstalls: (filePaths: string[]) => Promise directoryForDownloads: string } -async function installFromSelectFileDialog({ attemptInstalls, directoryForDownloads }: Dependencies) { +const installFromSelectFileDialog = ({ attemptInstalls, directoryForDownloads }: Dependencies) => async () => { const { canceled, filePaths } = await requestOpenFilePickingDialog({ defaultPath: directoryForDownloads, properties: ["openFile", "multiSelections"], @@ -26,13 +25,14 @@ async function installFromSelectFileDialog({ attemptInstalls, directoryForDownlo if (!canceled) { await attemptInstalls(filePaths); } -} +}; const installFromSelectFileDialogInjectable = getInjectable({ - instantiate: (di) => bind(installFromSelectFileDialog, null, { + instantiate: (di) => installFromSelectFileDialog({ attemptInstalls: di.inject(attemptInstallsInjectable), directoryForDownloads: di.inject(directoryForDownloadsInjectable), }), + lifecycle: lifecycleEnum.singleton, }); diff --git a/src/renderer/components/+network-port-forwards/port-forwards.tsx b/src/renderer/components/+network-port-forwards/port-forwards.tsx index b3e7e4c1f3..9ceeadba38 100644 --- a/src/renderer/components/+network-port-forwards/port-forwards.tsx +++ b/src/renderer/components/+network-port-forwards/port-forwards.tsx @@ -8,7 +8,7 @@ import "./port-forwards.scss"; import React from "react"; import { disposeOnUnmount, observer } from "mobx-react"; import type { RouteComponentProps } from "react-router-dom"; -import { ItemListLayout } from "../item-object-list/item-list-layout"; +import { ItemListLayout } from "../item-object-list/list-layout"; import type { PortForwardItem, PortForwardStore } from "../../port-forward"; import { PortForwardMenu } from "./port-forward-menu"; import { PortForwardsRouteParams, portForwardsURL } from "../../../common/routes"; 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 e1a3d7c46d..76e8ed7743 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 @@ -14,9 +14,8 @@ 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"; +import createTerminalTabInjectable from "../../dock/terminal/create-terminal-tab.injectable"; +import type { DockTabCreate } from "../../dock/dock/store"; export function isKubernetesClusterActive(context: CommandContext): boolean { return context.entity?.kind === "KubernetesCluster"; diff --git a/src/renderer/components/dock/__test__/dock-tabs.test.tsx b/src/renderer/components/dock/__test__/dock-tabs.test.tsx index 705680317f..4be169bf7f 100644 --- a/src/renderer/components/dock/__test__/dock-tabs.test.tsx +++ b/src/renderer/components/dock/__test__/dock-tabs.test.tsx @@ -8,12 +8,12 @@ import { fireEvent } from "@testing-library/react"; import "@testing-library/jest-dom/extend-expect"; import fse from "fs-extra"; import { DockTabs } from "../dock-tabs"; -import { DockStore, DockTab, TabKind } from "../dock-store/dock.store"; +import { DockStore, DockTab, TabKind } from "../dock/store"; import { noop } from "../../../utils"; import { ThemeStore } from "../../../theme.store"; import { UserStore } from "../../../../common/user-store"; import { getDiForUnitTesting } from "../../../getDiForUnitTesting"; -import dockStoreInjectable from "../dock-store/dock-store.injectable"; +import dockStoreInjectable from "../dock/store.injectable"; import type { DiRender } from "../../test-utils/renderFor"; import { renderFor } from "../../test-utils/renderFor"; import directoryForUserDataInjectable diff --git a/src/renderer/components/dock/create-install-chart-tab/create-install-chart-tab.injectable.ts b/src/renderer/components/dock/create-install-chart-tab/create-install-chart-tab.injectable.ts deleted file mode 100644 index 8544cff719..0000000000 --- a/src/renderer/components/dock/create-install-chart-tab/create-install-chart-tab.injectable.ts +++ /dev/null @@ -1,19 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ -import { createInstallChartTab } from "./create-install-chart-tab"; -import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; -import installChartStoreInjectable from "../install-chart-store/install-chart-store.injectable"; -import dockStoreInjectable from "../dock-store/dock-store.injectable"; - -const createInstallChartTabInjectable = getInjectable({ - instantiate: (di) => createInstallChartTab({ - installChartStore: di.inject(installChartStoreInjectable), - createDockTab: di.inject(dockStoreInjectable).createTab, - }), - - lifecycle: lifecycleEnum.singleton, -}); - -export default createInstallChartTabInjectable; diff --git a/src/renderer/components/dock/create-install-chart-tab/create-install-chart-tab.ts b/src/renderer/components/dock/create-install-chart-tab/create-install-chart-tab.ts deleted file mode 100644 index d4f6aba833..0000000000 --- a/src/renderer/components/dock/create-install-chart-tab/create-install-chart-tab.ts +++ /dev/null @@ -1,44 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ -import type { HelmChart } from "../../../../common/k8s-api/endpoints/helm-charts.api"; -import { - DockTab, - DockTabCreate, - DockTabCreateSpecific, - TabKind, -} from "../dock-store/dock.store"; - -import type { InstallChartStore } from "../install-chart-store/install-chart.store"; - -interface Dependencies { - createDockTab: (rawTab: DockTabCreate, addNumber: boolean) => DockTab; - installChartStore: InstallChartStore; -} - -export const createInstallChartTab = - ({ createDockTab, installChartStore }: Dependencies) => - (chart: HelmChart, tabParams: DockTabCreateSpecific = {}) => { - const { name, repo, version } = chart; - - const tab = createDockTab( - { - title: `Helm Install: ${repo}/${name}`, - ...tabParams, - kind: TabKind.INSTALL_CHART, - }, - false, - ); - - installChartStore.setData(tab.id, { - name, - repo, - version, - namespace: "default", - releaseName: "", - description: "", - }); - - return tab; - }; diff --git a/src/renderer/components/dock/create-resource-store/create-resource.store.ts b/src/renderer/components/dock/create-resource-store/create-resource.store.ts deleted file mode 100644 index 7c0c62f30b..0000000000 --- a/src/renderer/components/dock/create-resource-store/create-resource.store.ts +++ /dev/null @@ -1,70 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import fs from "fs-extra"; -import path from "path"; -import os from "os"; -import groupBy from "lodash/groupBy"; -import filehound from "filehound"; -import { watch } from "chokidar"; -import { autoBind, StorageHelper } from "../../../utils"; -import { DockTabStorageState, DockTabStore } from "../dock-tab-store/dock-tab.store"; -import type { DockStore } from "../dock-store/dock.store"; - -interface Dependencies { - dockStore: DockStore, - createStorage: (storageKey: string, options: DockTabStorageState) => StorageHelper> -} - -export class CreateResourceStore extends DockTabStore { - constructor(protected dependencies: Dependencies) { - super(dependencies, { - storageKey: "create_resource", - }); - - autoBind(this); - fs.ensureDirSync(this.userTemplatesFolder); - } - - get lensTemplatesFolder():string { - return path.resolve(__static, "../templates/create-resource"); - } - - get userTemplatesFolder():string { - return path.join(os.homedir(), ".k8slens", "templates"); - } - - async getTemplates(templatesPath: string, defaultGroup: string) { - const templates = await filehound.create().path(templatesPath).ext(["yaml", "json"]).depth(1).find(); - - return templates ? this.groupTemplates(templates, templatesPath, defaultGroup) : {}; - } - - groupTemplates(templates: string[], templatesPath: string, defaultGroup: string) { - return groupBy(templates, (v:string) => - path.relative(templatesPath, v).split(path.sep).length>1 - ? path.parse(path.relative(templatesPath, v)).dir - : defaultGroup); - } - - async getMergedTemplates() { - const userTemplates = await this.getTemplates(this.userTemplatesFolder, "ungrouped"); - const lensTemplates = await this.getTemplates(this.lensTemplatesFolder, "lens"); - - return { ...userTemplates, ...lensTemplates }; - } - - async watchUserTemplates(callback: ()=> void){ - watch(this.userTemplatesFolder, { - depth: 1, - ignoreInitial: true, - awaitWriteFinish: { - stabilityThreshold: 500, - }, - }).on("all", () => { - callback(); - }); - } -} diff --git a/src/renderer/components/dock/create-resource-tab/create-resource-tab.injectable.ts b/src/renderer/components/dock/create-resource-tab/create-resource-tab.injectable.ts deleted file mode 100644 index daacd34c33..0000000000 --- a/src/renderer/components/dock/create-resource-tab/create-resource-tab.injectable.ts +++ /dev/null @@ -1,17 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ -import { createResourceTab } from "./create-resource-tab"; -import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; -import dockStoreInjectable from "../dock-store/dock-store.injectable"; - -const createResourceTabInjectable = getInjectable({ - instantiate: (di) => createResourceTab({ - dockStore: di.inject(dockStoreInjectable), - }), - - lifecycle: lifecycleEnum.singleton, -}); - -export default createResourceTabInjectable; diff --git a/src/renderer/components/dock/create-resource-tab/create-resource-tab.ts b/src/renderer/components/dock/create-resource-tab/create-resource-tab.ts deleted file mode 100644 index bea9dc05aa..0000000000 --- a/src/renderer/components/dock/create-resource-tab/create-resource-tab.ts +++ /dev/null @@ -1,18 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ -import { DockStore, DockTabCreateSpecific, TabKind } from "../dock-store/dock.store"; - -interface Dependencies { - dockStore: DockStore -} - -export const createResourceTab = - ({ dockStore }: Dependencies) => - (tabParams: DockTabCreateSpecific = {}) => - dockStore.createTab({ - title: "Create resource", - ...tabParams, - kind: TabKind.CREATE_RESOURCE, - }); diff --git a/src/renderer/components/dock/create-resource.scss b/src/renderer/components/dock/create-resource.scss deleted file mode 100644 index 7191de3134..0000000000 --- a/src/renderer/components/dock/create-resource.scss +++ /dev/null @@ -1,7 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -.CreateResource { -} diff --git a/src/renderer/components/dock/create-resource.tsx b/src/renderer/components/dock/create-resource.tsx deleted file mode 100644 index 2b6728ede6..0000000000 --- a/src/renderer/components/dock/create-resource.tsx +++ /dev/null @@ -1,184 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import "./create-resource.scss"; - -import React from "react"; -import path from "path"; -import fs from "fs-extra"; -import { GroupSelectOption, Select, SelectOption } from "../select"; -import yaml from "js-yaml"; -import { makeObservable, observable } from "mobx"; -import { observer } from "mobx-react"; -import type { CreateResourceStore } from "./create-resource-store/create-resource.store"; -import type { DockTab } from "./dock-store/dock.store"; -import { EditorPanel } from "./editor-panel"; -import { InfoPanel } from "./info-panel"; -import * as resourceApplierApi from "../../../common/k8s-api/endpoints/resource-applier.api"; -import { Notifications } from "../notifications"; -import logger from "../../../common/logger"; -import type { KubeJsonApiData } from "../../../common/k8s-api/kube-json-api"; -import { getDetailsUrl } from "../kube-detail-params"; -import { apiManager } from "../../../common/k8s-api/api-manager"; -import { prevDefault } from "../../utils"; -import { navigate } from "../../navigation"; -import { withInjectables } from "@ogre-tools/injectable-react"; -import createResourceStoreInjectable - from "./create-resource-store/create-resource-store.injectable"; - -interface Props { - tab: DockTab; -} - -interface Dependencies { - createResourceStore: CreateResourceStore -} - -@observer -class NonInjectedCreateResource extends React.Component { - @observable currentTemplates: Map = new Map(); - @observable error = ""; - @observable templates: GroupSelectOption[] = []; - - constructor(props: Props & Dependencies) { - super(props); - makeObservable(this); - } - - componentDidMount() { - this.props.createResourceStore.getMergedTemplates().then(v => this.updateGroupSelectOptions(v)); - this.props.createResourceStore.watchUserTemplates(() => this.props.createResourceStore.getMergedTemplates().then(v => this.updateGroupSelectOptions(v))); - } - - updateGroupSelectOptions(templates: Record) { - this.templates = Object.entries(templates) - .map(([name, grouping]) => this.convertToGroup(name, grouping)); - } - - convertToGroup(group: string, items: string[]): GroupSelectOption { - const options = items.map(v => ({ label: path.parse(v).name, value: v })); - - return { label: group, options }; - } - - get tabId() { - return this.props.tab.id; - } - - get data() { - return this.props.createResourceStore.getData(this.tabId); - } - - get currentTemplate() { - return this.currentTemplates.get(this.tabId) ?? null; - } - - onChange = (value: string) => { - this.error = ""; // reset first, validation goes later - this.props.createResourceStore.setData(this.tabId, value); - }; - - onError = (error: Error | string) => { - this.error = error.toString(); - }; - - onSelectTemplate = (item: SelectOption) => { - this.currentTemplates.set(this.tabId, item); - fs.readFile(item.value, "utf8").then(v => { - this.props.createResourceStore.setData(this.tabId, v); - }); - }; - - create = async (): Promise => { - if (this.error || !this.data.trim()) { - // do not save when field is empty or there is an error - return null; - } - - // skip empty documents - const resources = yaml.loadAll(this.data).filter(Boolean); - - if (resources.length === 0) { - return void logger.info("Nothing to create"); - } - - const creatingResources = resources.map(async (resource: string) => { - try { - const data: KubeJsonApiData = await resourceApplierApi.update(resource); - const { kind, apiVersion, metadata: { name, namespace }} = data; - const resourceLink = apiManager.lookupApiLink({ kind, apiVersion, name, namespace }); - - const showDetails = () => { - navigate(getDetailsUrl(resourceLink)); - close(); - }; - - const close = Notifications.ok( -

- {kind} {name} successfully created. -

, - ); - } catch (error) { - Notifications.error(error?.toString() ?? "Unknown error occured"); - } - }); - - await Promise.allSettled(creatingResources); - - return undefined; - }; - - renderControls() { - return ( -
- +
+ ); + } + + render() { + const { tabId, data, error } = this; + + return ( +
+ + +
+ ); + } +} + +export const CreateResource = withInjectables(NonInjectedCreateResource, { + getPlaceholder: () => , + + getProps: async (di, props) => ({ + createResourceTabStore: di.inject(createResourceTabStoreInjectable), + createResourceTemplates: await di.inject(createResourceTemplatesInjectable), + ...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 deleted file mode 100644 index e22ceaf273..0000000000 --- a/src/renderer/components/dock/create-terminal-tab/create-terminal-tab.injectable.ts +++ /dev/null @@ -1,17 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ -import { 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 deleted file mode 100644 index cd3c501158..0000000000 --- a/src/renderer/components/dock/create-terminal-tab/create-terminal-tab.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ -import { - DockStore, - DockTabCreateSpecific, - TabKind, -} from "../dock-store/dock.store"; - -interface Dependencies { - dockStore: DockStore; -} - -export const 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 deleted file mode 100644 index 0ba73c3abe..0000000000 --- a/src/renderer/components/dock/create-upgrade-chart-tab/create-upgrade-chart-tab.injectable.ts +++ /dev/null @@ -1,19 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ -import { 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 deleted file mode 100644 index 17c94c6741..0000000000 --- a/src/renderer/components/dock/create-upgrade-chart-tab/create-upgrade-chart-tab.ts +++ /dev/null @@ -1,41 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ -import 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-store.injectable.ts b/src/renderer/components/dock/dock-store/dock-store.injectable.ts deleted file mode 100644 index 19a47dead6..0000000000 --- a/src/renderer/components/dock/dock-store/dock-store.injectable.ts +++ /dev/null @@ -1,18 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ -import { 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-tab-store/create-dock-tab-store.injectable.ts b/src/renderer/components/dock/dock-tab-store/create-dock-tab-store.injectable.ts index 19b5af4084..ed76d8d9a6 100644 --- 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 @@ -4,13 +4,11 @@ */ 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), }; diff --git a/src/renderer/components/dock/dock-tab-store/dock-tab.store.ts b/src/renderer/components/dock/dock-tab-store/dock-tab.store.ts index 5bfcdd504c..ab8d4b2940 100644 --- a/src/renderer/components/dock/dock-tab-store/dock-tab.store.ts +++ b/src/renderer/components/dock/dock-tab-store/dock-tab.store.ts @@ -3,9 +3,9 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import { autorun, observable, reaction } from "mobx"; +import { action, observable, reaction } from "mobx"; import { autoBind, StorageHelper, toJS } from "../../../utils"; -import type { DockStore, TabId } from "../dock-store/dock.store"; +import type { TabId } from "../dock/store"; export interface DockTabStoreOptions { autoInit?: boolean; // load data from storage when `storageKey` is provided and bind events, default: true @@ -14,16 +14,15 @@ export interface DockTabStoreOptions { export type DockTabStorageState = Record; -interface Dependencies { - dockStore: DockStore +interface DockTabStoreDependencies { createStorage: (storageKey: string, options: DockTabStorageState) => StorageHelper> } export class DockTabStore { protected storage?: StorageHelper>; - protected data = observable.map(); + private data = observable.map(); - constructor(protected dependencies: Dependencies, protected options: DockTabStoreOptions) { + constructor(protected dependencies: DockTabStoreDependencies, protected options: DockTabStoreOptions) { autoBind(this); this.options = { @@ -48,17 +47,6 @@ export class DockTabStore { reaction(() => this.toJSON(), data => this.storage.set(data)); }); } - - // clear data for closed tabs - autorun(() => { - const currentTabs = this.dependencies.dockStore.tabs.map(tab => tab.id); - - Array.from(this.data.keys()).forEach(tabId => { - if (!currentTabs.includes(tabId)) { - this.clearData(tabId); - } - }); - }); } protected finalizeDataForSave(data: T): T { @@ -75,8 +63,22 @@ export class DockTabStore { return Object.fromEntries(deepCopy); } + protected getAllData() { + return this.data.toJSON(); + } + + findTabIdFromData(inspecter: (val: T) => any): TabId | undefined { + for (const [tabId, data] of this.data) { + if (inspecter(data)) { + return tabId; + } + } + + return undefined; + } + isReady(tabId: TabId): boolean { - return Boolean(this.getData(tabId) !== undefined); + return this.getData(tabId) !== undefined; } getData(tabId: TabId) { @@ -91,8 +93,11 @@ export class DockTabStore { this.data.delete(tabId); } + @action reset() { - this.data.clear(); + for (const tabId of this.data.keys()) { + this.clearData(tabId); + } this.storage?.reset(); } } diff --git a/src/renderer/components/dock/dock-tab.tsx b/src/renderer/components/dock/dock-tab.tsx index f5d7be54c2..4b91c609ba 100644 --- a/src/renderer/components/dock/dock-tab.tsx +++ b/src/renderer/components/dock/dock-tab.tsx @@ -8,14 +8,14 @@ import "./dock-tab.scss"; import React from "react"; import { observer } from "mobx-react"; import { boundMethod, cssNames, prevDefault, isMiddleClick } from "../../utils"; -import type { DockStore, DockTab as DockTabModel } from "./dock-store/dock.store"; +import type { DockStore, DockTab as DockTabModel } from "./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"; +import dockStoreInjectable from "./dock/store.injectable"; export interface DockTabProps extends TabProps { moreActions?: React.ReactNode; diff --git a/src/renderer/components/dock/dock-tabs.tsx b/src/renderer/components/dock/dock-tabs.tsx index 8902fe439a..d4443cf6c0 100644 --- a/src/renderer/components/dock/dock-tabs.tsx +++ b/src/renderer/components/dock/dock-tabs.tsx @@ -8,9 +8,9 @@ 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/dock.store"; -import { TabKind } from "./dock-store/dock.store"; -import { TerminalTab } from "./terminal-tab"; +import type { DockTab as DockTabModel } from "./dock/store"; +import { TabKind } from "./dock/store"; +import { TerminalTab } from "./terminal/dock-tab"; interface Props { tabs: DockTabModel[] diff --git a/src/renderer/components/dock/dock.tsx b/src/renderer/components/dock/dock.tsx index dcc655c60d..d067931762 100644 --- a/src/renderer/components/dock/dock.tsx +++ b/src/renderer/components/dock/dock.tsx @@ -13,18 +13,19 @@ import { Icon } from "../icon"; import { MenuItem } from "../menu"; import { MenuActions } from "../menu/menu-actions"; import { ResizeDirection, ResizingAnchor } from "../resizing-anchor"; -import { CreateResource } from "./create-resource"; +import { CreateResource } from "./create-resource/view"; import { DockTabs } from "./dock-tabs"; -import { DockStore, DockTab, TabKind } from "./dock-store/dock.store"; -import { EditResource } from "./edit-resource"; -import { InstallChart } from "./install-chart"; -import { LogsDockTab } from "./logs/dock-tab"; -import { TerminalWindow } from "./terminal-window"; -import { UpgradeChart } from "./upgrade-chart"; +import { DockStore, DockTab, TabKind } from "./dock/store"; +import { EditResource } from "./edit-resource/view"; +import { InstallChart } from "./install-chart/view"; +import { LogsDockTab } from "./logs/view"; +import { TerminalWindow } from "./terminal/view"; +import { UpgradeChart } from "./upgrade-chart/view"; 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"; +import createResourceTabInjectable from "./create-resource/create-resource-tab.injectable"; +import dockStoreInjectable from "./dock/store.injectable"; +import createTerminalTabInjectable from "./terminal/create-terminal-tab.injectable"; +import { ErrorBoundary } from "../error-boundary"; interface Props { className?: string; @@ -160,7 +161,9 @@ class NonInjectedDock extends React.Component { )} - {this.renderTabContent()} + + {this.renderTabContent()} + ); } diff --git a/src/renderer/components/dock/dock/close-dock-tab.injectable.ts b/src/renderer/components/dock/dock/close-dock-tab.injectable.ts new file mode 100644 index 0000000000..8d44637cd7 --- /dev/null +++ b/src/renderer/components/dock/dock/close-dock-tab.injectable.ts @@ -0,0 +1,21 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import type { TabId } from "./store"; +import dockStoreInjectable from "./store.injectable"; + +const closeDockTabInjectable = getInjectable({ + instantiate: (di) => { + const dockStore = di.inject(dockStoreInjectable); + + return (tabId: TabId): void => { + dockStore.closeTab(tabId); + }; + }, + + lifecycle: lifecycleEnum.singleton, +}); + +export default closeDockTabInjectable; diff --git a/src/renderer/components/dock/dock/create-dock-tab.injectable.ts b/src/renderer/components/dock/dock/create-dock-tab.injectable.ts new file mode 100644 index 0000000000..1469b2e31b --- /dev/null +++ b/src/renderer/components/dock/dock/create-dock-tab.injectable.ts @@ -0,0 +1,20 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import dockStoreInjectable from "./store.injectable"; +import type { DockTab, DockTabCreate } from "./store"; + +const createDockTabInjectable = getInjectable({ + instantiate: (di) => { + const dockStore = di.inject(dockStoreInjectable); + + return (rawTabDesc: DockTabCreate, addNumber?: boolean): DockTab => + dockStore.createTab(rawTabDesc, addNumber); + }, + + lifecycle: lifecycleEnum.singleton, +}); + +export default createDockTabInjectable; diff --git a/src/renderer/components/dock/dock-store/dock-storage/dock-storage.injectable.ts b/src/renderer/components/dock/dock/dock-storage.injectable.ts similarity index 81% rename from src/renderer/components/dock/dock-store/dock-storage/dock-storage.injectable.ts rename to src/renderer/components/dock/dock/dock-storage.injectable.ts index 56e6edd37e..e58127761f 100644 --- a/src/renderer/components/dock/dock-store/dock-storage/dock-storage.injectable.ts +++ b/src/renderer/components/dock/dock/dock-storage.injectable.ts @@ -3,8 +3,8 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; -import createStorageInjectable from "../../../../utils/create-storage/create-storage.injectable"; -import { DockStorageState, TabKind } from "../dock.store"; +import createStorageInjectable from "../../../utils/create-storage/create-storage.injectable"; +import { DockStorageState, TabKind } from "./store"; const dockStorageInjectable = getInjectable({ instantiate: (di) => { diff --git a/src/renderer/components/dock/dock/rename-tab.injectable.ts b/src/renderer/components/dock/dock/rename-tab.injectable.ts new file mode 100644 index 0000000000..38ceb4dcbe --- /dev/null +++ b/src/renderer/components/dock/dock/rename-tab.injectable.ts @@ -0,0 +1,22 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import dockStoreInjectable from "./store.injectable"; +import type { TabId } from "./store"; + +const renameTabInjectable = getInjectable({ + + instantiate: (di) => { + const dockStore = di.inject(dockStoreInjectable); + + return (tabId: TabId, title: string): void => { + dockStore.renameTab(tabId, title); + }; + }, + + lifecycle: lifecycleEnum.singleton, +}); + +export default renameTabInjectable; diff --git a/src/renderer/components/dock/dock/select-dock-tab.injectable.ts b/src/renderer/components/dock/dock/select-dock-tab.injectable.ts new file mode 100644 index 0000000000..39830db90c --- /dev/null +++ b/src/renderer/components/dock/dock/select-dock-tab.injectable.ts @@ -0,0 +1,21 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import type { TabId } from "./store"; +import dockStoreInjectable from "./store.injectable"; + +const selectDockTabInjectable = getInjectable({ + instantiate: (di) => { + const dockStore = di.inject(dockStoreInjectable); + + return (tabId: TabId): void => { + dockStore.selectTab(tabId); + }; + }, + + lifecycle: lifecycleEnum.singleton, +}); + +export default selectDockTabInjectable; diff --git a/src/renderer/components/dock/dock/store.injectable.ts b/src/renderer/components/dock/dock/store.injectable.ts new file mode 100644 index 0000000000..b61450ea54 --- /dev/null +++ b/src/renderer/components/dock/dock/store.injectable.ts @@ -0,0 +1,35 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { DockStore, TabKind } from "./store"; +import dockStorageInjectable from "./dock-storage.injectable"; +import clearLogTabDataInjectable from "../logs/clear-log-tab-data.injectable"; +import clearUpgradeChartTabDataInjectable from "../upgrade-chart/clear-upgrade-chart-tab-data.injectable"; +import clearCreateResourceTabDataInjectable from "../create-resource/clear-create-resource-tab-data.injectable"; +import clearEditResourceTabDataInjectable from "../edit-resource/clear-edit-resource-tab-data.injectable"; +import clearTerminalTabDataInjectable from "../terminal/clear-terminal-tab-data.injectable"; +import clearInstallChartTabDataInjectable from "../install-chart/clear-install-chart-tab-data.injectable"; +import isLogsTabDataValidInjectable from "../logs/is-logs-tab-data-valid.injectable"; + +const dockStoreInjectable = getInjectable({ + instantiate: (di) => new DockStore({ + storage: di.inject(dockStorageInjectable), + tabDataClearers: { + [TabKind.POD_LOGS]: di.inject(clearLogTabDataInjectable), + [TabKind.UPGRADE_CHART]: di.inject(clearUpgradeChartTabDataInjectable), + [TabKind.CREATE_RESOURCE]: di.inject(clearCreateResourceTabDataInjectable), + [TabKind.EDIT_RESOURCE]: di.inject(clearEditResourceTabDataInjectable), + [TabKind.INSTALL_CHART]: di.inject(clearInstallChartTabDataInjectable), + [TabKind.TERMINAL]: di.inject(clearTerminalTabDataInjectable), + }, + tabDataValidator: { + [TabKind.POD_LOGS]: di.inject(isLogsTabDataValidInjectable), + }, + }), + + lifecycle: lifecycleEnum.singleton, +}); + +export default dockStoreInjectable; diff --git a/src/renderer/components/dock/dock-store/dock.store.ts b/src/renderer/components/dock/dock/store.ts similarity index 93% rename from src/renderer/components/dock/dock-store/dock.store.ts rename to src/renderer/components/dock/dock/store.ts index 6cca0e4d06..3cbe3c03aa 100644 --- a/src/renderer/components/dock/dock-store/dock.store.ts +++ b/src/renderer/components/dock/dock/store.ts @@ -98,11 +98,13 @@ export interface DockTabCloseEvent { } interface Dependencies { - storage: StorageHelper + readonly storage: StorageHelper + readonly tabDataClearers: Record void>; + readonly tabDataValidator: Partial boolean>>; } export class DockStore implements DockStorageState { - constructor(private dependencies: Dependencies) { + constructor(private readonly dependencies: Dependencies) { makeObservable(this); autoBind(this); this.init(); @@ -167,6 +169,16 @@ export class DockStore implements DockStorageState { private init() { // adjust terminal height if window size changes window.addEventListener("resize", throttle(this.adjustHeight, 250)); + + this.whenReady.then(action(() => { + for (const tab of this.tabs) { + const validator = this.dependencies.tabDataValidator[tab.kind]; + + if (validator && !validator(tab.id)) { + this.closeTab(tab.id); + } + } + })); } get maxHeight() { @@ -317,6 +329,7 @@ export class DockStore implements DockStorageState { } this.tabs = this.tabs.filter(tab => tab.id !== tabId); + this.dependencies.tabDataClearers[tab.kind](tab.id); if (this.selectedTabId === tab.id) { if (this.tabs.length) { @@ -330,6 +343,7 @@ export class DockStore implements DockStorageState { } } + @action closeTabs(tabs: DockTab[]) { tabs.forEach(tab => this.closeTab(tab.id)); } 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 deleted file mode 100644 index 393bfa560b..0000000000 --- a/src/renderer/components/dock/edit-resource-store/edit-resource-store.injectable.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ -import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; -import 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/edit-resource.store.ts b/src/renderer/components/dock/edit-resource-store/edit-resource.store.ts deleted file mode 100644 index 92982cb52d..0000000000 --- a/src/renderer/components/dock/edit-resource-store/edit-resource.store.ts +++ /dev/null @@ -1,109 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import { autoBind, noop, StorageHelper } from "../../../utils"; -import { DockTabStorageState, DockTabStore } from "../dock-tab-store/dock-tab.store"; -import { autorun, IReactionDisposer } from "mobx"; -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 - draft?: string; // edited draft in yaml - firstDraft?: string; -} - -interface Dependencies { - dockStore: DockStore - createStorage: (storageKey: string, options: DockTabStorageState) => StorageHelper> -} - -export class EditResourceStore extends DockTabStore { - private watchers = new Map(); - - constructor(protected dependencies: Dependencies) { - super(dependencies, { - storageKey: "edit_resource_store", - }); - - autoBind(this); - } - - protected async init() { - super.init(); - await this.storage.whenReady; - - autorun(() => { - Array.from(this.data).forEach(([tabId, { resource }]) => { - if (this.watchers.get(tabId)) { - return; - } - this.watchers.set(tabId, autorun(() => { - const store = apiManager.getStore(resource); - - if (store) { - const isActiveTab = this.dependencies.dockStore.isOpen && this.dependencies.dockStore.selectedTabId === tabId; - const obj = store.getByPath(resource); - - // preload resource for editing - if (!obj && !store.isLoaded && !store.isLoading && isActiveTab) { - store.loadFromPath(resource).catch(noop); - } - // auto-close tab when resource removed from store - else if (!obj && store.isLoaded) { - this.dependencies.dockStore.closeTab(tabId); - } - } - }, { - delay: 100, // make sure all kube-object stores are initialized - })); - }); - }); - } - - protected finalizeDataForSave({ draft, ...data }: EditingResource): EditingResource { - return data; // skip saving draft to local-storage - } - - isReady(tabId: TabId) { - const tabDataReady = super.isReady(tabId); - - return Boolean(tabDataReady && this.getResource(tabId)); // ready to edit resource - } - - getStore(tabId: TabId): KubeObjectStore | undefined { - return apiManager.getStore(this.getResourcePath(tabId)); - } - - getResource(tabId: TabId): KubeObject | undefined { - return this.getStore(tabId)?.getByPath(this.getResourcePath(tabId)); - } - - getResourcePath(tabId: TabId): string | undefined { - return this.getData(tabId)?.resource; - } - - getTabByResource(object: KubeObject): DockTab { - const [tabId] = Array.from(this.data).find(([, { resource }]) => { - return object.selfLink === resource; - }) || []; - - return this.dependencies.dockStore.getTabById(tabId); - } - - clearInitialDraft(tabId: TabId): void { - delete this.getData(tabId)?.firstDraft; - } - - reset() { - super.reset(); - Array.from(this.watchers).forEach(([tabId, dispose]) => { - this.watchers.delete(tabId); - dispose(); - }); - } -} diff --git a/src/renderer/components/dock/edit-resource-tab/edit-resource-tab.injectable.ts b/src/renderer/components/dock/edit-resource-tab/edit-resource-tab.injectable.ts deleted file mode 100644 index ba94e51376..0000000000 --- a/src/renderer/components/dock/edit-resource-tab/edit-resource-tab.injectable.ts +++ /dev/null @@ -1,19 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ -import { 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: (di) => editResourceTab({ - dockStore: di.inject(dockStoreInjectable), - editResourceStore: di.inject(editResourceStoreInjectable), - }), - - lifecycle: lifecycleEnum.singleton, -}); - -export default editResourceTabInjectable; 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 deleted file mode 100644 index e42004ef69..0000000000 --- a/src/renderer/components/dock/edit-resource-tab/edit-resource-tab.ts +++ /dev/null @@ -1,41 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ -import 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.scss b/src/renderer/components/dock/edit-resource.scss deleted file mode 100644 index 2ae07cdd07..0000000000 --- a/src/renderer/components/dock/edit-resource.scss +++ /dev/null @@ -1,7 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -.EditResource { -} diff --git a/src/renderer/components/dock/edit-resource/clear-edit-resource-tab-data.injectable.ts b/src/renderer/components/dock/edit-resource/clear-edit-resource-tab-data.injectable.ts new file mode 100644 index 0000000000..3b73991af8 --- /dev/null +++ b/src/renderer/components/dock/edit-resource/clear-edit-resource-tab-data.injectable.ts @@ -0,0 +1,21 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import type { TabId } from "../dock/store"; +import editResourceTabStoreInjectable from "./store.injectable"; + +const clearEditResourceTabDataInjectable = getInjectable({ + instantiate: (di) => { + const editResourceTabStore = di.inject(editResourceTabStoreInjectable); + + return (tabId: TabId) => { + editResourceTabStore.clearData(tabId); + }; + }, + + lifecycle: lifecycleEnum.singleton, +}); + +export default clearEditResourceTabDataInjectable; diff --git a/src/renderer/components/dock/edit-resource/edit-resource-tab.injectable.ts b/src/renderer/components/dock/edit-resource/edit-resource-tab.injectable.ts new file mode 100644 index 0000000000..a68639e12b --- /dev/null +++ b/src/renderer/components/dock/edit-resource/edit-resource-tab.injectable.ts @@ -0,0 +1,56 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import editResourceTabStoreInjectable from "./store.injectable"; +import dockStoreInjectable from "../dock/store.injectable"; +import type { KubeObject } from "../../../../common/k8s-api/kube-object"; +import { DockStore, DockTabCreateSpecific, TabId, TabKind } from "../dock/store"; +import type { EditResourceTabStore } from "./store"; +import { runInAction } from "mobx"; + +interface Dependencies { + dockStore: DockStore; + editResourceStore: EditResourceTabStore; +} + +const createEditResourceTab = ({ dockStore, editResourceStore }: Dependencies) => (object: KubeObject, tabParams: DockTabCreateSpecific = {}): TabId => { + // use existing tab if already opened + const tabId = editResourceStore.getTabIdByResource(object); + + if (tabId) { + dockStore.open(); + dockStore.selectTab(tabId); + + return tabId; + } + + return runInAction(() => { + const tab = dockStore.createTab( + { + title: `${object.kind}: ${object.getName()}`, + ...tabParams, + kind: TabKind.EDIT_RESOURCE, + }, + false, + ); + + editResourceStore.setData(tab.id, { + resource: object.selfLink, + }); + + return tab.id; + }); +}; + +const createEditResourceTabInjectable = getInjectable({ + instantiate: (di) => createEditResourceTab({ + dockStore: di.inject(dockStoreInjectable), + editResourceStore: di.inject(editResourceTabStoreInjectable), + }), + + lifecycle: lifecycleEnum.singleton, +}); + +export default createEditResourceTabInjectable; diff --git a/src/renderer/components/dock/edit-resource/store.injectable.ts b/src/renderer/components/dock/edit-resource/store.injectable.ts new file mode 100644 index 0000000000..ae2f462262 --- /dev/null +++ b/src/renderer/components/dock/edit-resource/store.injectable.ts @@ -0,0 +1,17 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { EditResourceTabStore } from "./store"; +import createStorageInjectable from "../../../utils/create-storage/create-storage.injectable"; + +const editResourceTabStoreInjectable = getInjectable({ + instantiate: (di) => new EditResourceTabStore({ + createStorage: di.inject(createStorageInjectable), + }), + + lifecycle: lifecycleEnum.singleton, +}); + +export default editResourceTabStoreInjectable; diff --git a/src/renderer/components/dock/edit-resource/store.ts b/src/renderer/components/dock/edit-resource/store.ts new file mode 100644 index 0000000000..5b8794d758 --- /dev/null +++ b/src/renderer/components/dock/edit-resource/store.ts @@ -0,0 +1,57 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { StorageHelper } from "../../../utils"; +import { DockTabStorageState, DockTabStore } from "../dock-tab-store/dock-tab.store"; +import type { TabId } 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"; + +export interface EditingResource { + resource: string; // resource path, e.g. /api/v1/namespaces/default + draft?: string; // edited draft in yaml + firstDraft?: string; +} + +interface Dependencies { + createStorage: (storageKey: string, options: DockTabStorageState) => StorageHelper> +} + +export class EditResourceTabStore extends DockTabStore { + constructor(protected dependencies: Dependencies) { + super(dependencies, { + storageKey: "edit_resource_store", + }); + } + + protected finalizeDataForSave({ draft, ...data }: EditingResource): EditingResource { + return data; // skip saving draft to local-storage + } + + isReady(tabId: TabId) { + return super.isReady(tabId) && Boolean(this.getResource(tabId)); // ready to edit resource + } + + getStore(tabId: TabId): KubeObjectStore | undefined { + return apiManager.getStore(this.getResourcePath(tabId)); + } + + getResource(tabId: TabId): KubeObject | undefined { + return this.getStore(tabId)?.getByPath(this.getResourcePath(tabId)); + } + + getResourcePath(tabId: TabId): string | undefined { + return this.getData(tabId)?.resource; + } + + getTabIdByResource(object: KubeObject): TabId { + return this.findTabIdFromData(({ resource }) => object.selfLink === resource); + } + + clearInitialDraft(tabId: TabId): void { + delete this.getData(tabId)?.firstDraft; + } +} diff --git a/src/renderer/components/dock/edit-resource.tsx b/src/renderer/components/dock/edit-resource/view.tsx similarity index 67% rename from src/renderer/components/dock/edit-resource.tsx rename to src/renderer/components/dock/edit-resource/view.tsx index ec15e27ab5..aa23af9cea 100644 --- a/src/renderer/components/dock/edit-resource.tsx +++ b/src/renderer/components/dock/edit-resource/view.tsx @@ -3,29 +3,30 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import "./edit-resource.scss"; - import React from "react"; -import { computed, makeObservable, observable } from "mobx"; -import { observer } from "mobx-react"; +import { autorun, computed, makeObservable, observable } from "mobx"; +import { disposeOnUnmount, observer } from "mobx-react"; import yaml from "js-yaml"; -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 type { DockTab, TabId } from "../dock/store"; +import type { EditResourceTabStore } from "./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"; +import editResourceTabStoreInjectable from "./store.injectable"; +import { noop } from "../../../utils"; +import closeDockTabInjectable from "../dock/close-dock-tab.injectable"; interface Props { tab: DockTab; } interface Dependencies { - editResourceStore: EditResourceStore + editResourceStore: EditResourceTabStore; + closeTab: (tabId: TabId) => void; } @observer @@ -37,6 +38,26 @@ class NonInjectedEditResource extends React.Component { makeObservable(this); } + componentDidMount(): void { + disposeOnUnmount(this, [ + autorun(() => { + const store = this.props.editResourceStore.getStore(this.props.tab.id); + const tabData = this.props.editResourceStore.getData(this.props.tab.id); + const obj = this.resource; + + if (!obj) { + if (store.isLoaded) { + // auto-close tab when resource removed from store + this.props.closeTab(this.props.tab.id); + } else if (!store.isLoading) { + // preload resource for editing + store.loadFromPath(tabData.resource).catch(noop); + } + } + }), + ]); + } + get tabId() { return this.props.tab.id; } @@ -132,13 +153,10 @@ class NonInjectedEditResource extends React.Component { } } -export const EditResource = withInjectables( - NonInjectedEditResource, - - { - getProps: (di, props) => ({ - editResourceStore: di.inject(editResourceStoreInjectable), - ...props, - }), - }, -); +export const EditResource = withInjectables(NonInjectedEditResource, { + getProps: (di, props) => ({ + editResourceStore: di.inject(editResourceTabStoreInjectable), + closeTab: di.inject(closeDockTabInjectable), + ...props, + }), +}); diff --git a/src/renderer/components/dock/editor-panel.tsx b/src/renderer/components/dock/editor-panel.tsx index e2500a7513..90e47b1c7f 100644 --- a/src/renderer/components/dock/editor-panel.tsx +++ b/src/renderer/components/dock/editor-panel.tsx @@ -8,11 +8,11 @@ import throttle from "lodash/throttle"; import React from "react"; import { makeObservable, observable, reaction } from "mobx"; import { disposeOnUnmount, observer } from "mobx-react"; -import type { DockStore, TabId } from "./dock-store/dock.store"; +import type { DockStore, TabId } from "./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"; +import dockStoreInjectable from "./dock/store.injectable"; export interface EditorPanelProps { tabId: TabId; diff --git a/src/renderer/components/dock/info-panel.tsx b/src/renderer/components/dock/info-panel.tsx index 0d534b3a6f..91a6d329f8 100644 --- a/src/renderer/components/dock/info-panel.tsx +++ b/src/renderer/components/dock/info-panel.tsx @@ -12,14 +12,14 @@ import { cssNames } from "../../utils"; import { Button } from "../button"; import { Icon } from "../icon"; import { Spinner } from "../spinner"; -import type { DockStore, TabId } from "./dock-store/dock.store"; +import type { DockStore, TabId } from "./dock/store"; import { Notifications } from "../notifications"; import { withInjectables } from "@ogre-tools/injectable-react"; -import dockStoreInjectable from "./dock-store/dock-store.injectable"; +import dockStoreInjectable from "./dock/store.injectable"; interface Props extends OptionalProps { tabId: TabId; - submit?: () => Promise; + submit?: () => Promise; } interface OptionalProps { @@ -80,7 +80,7 @@ class NonInjectedInfoPanel extends Component { try { const result = await this.props.submit(); - if (showNotifications) Notifications.ok(result); + if (showNotifications && result) Notifications.ok(result); } catch (error) { if (showNotifications) Notifications.error(error.toString()); } finally { diff --git a/src/renderer/components/dock/install-chart/clear-install-chart-tab-data.injectable.ts b/src/renderer/components/dock/install-chart/clear-install-chart-tab-data.injectable.ts new file mode 100644 index 0000000000..0221d5dc38 --- /dev/null +++ b/src/renderer/components/dock/install-chart/clear-install-chart-tab-data.injectable.ts @@ -0,0 +1,21 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import type { TabId } from "../dock/store"; +import installChartTabStoreInjectable from "./store.injectable"; + +const clearInstallChartTabDataInjectable = getInjectable({ + instantiate: (di) => { + const installChartTabStore = di.inject(installChartTabStoreInjectable); + + return (tabId: TabId) => { + installChartTabStore.clearData(tabId); + }; + }, + + lifecycle: lifecycleEnum.singleton, +}); + +export default clearInstallChartTabDataInjectable; diff --git a/src/renderer/components/dock/install-chart/create-install-chart-tab.injectable.ts b/src/renderer/components/dock/install-chart/create-install-chart-tab.injectable.ts new file mode 100644 index 0000000000..2e9920c310 --- /dev/null +++ b/src/renderer/components/dock/install-chart/create-install-chart-tab.injectable.ts @@ -0,0 +1,55 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import installChartTabStoreInjectable from "./store.injectable"; +import type { HelmChart } from "../../../../common/k8s-api/endpoints/helm-charts.api"; +import { + DockTab, + DockTabCreate, + DockTabCreateSpecific, + TabKind, +} from "../dock/store"; +import type { InstallChartTabStore } from "./store"; +import createDockTabInjectable from "../dock/create-dock-tab.injectable"; + +interface Dependencies { + createDockTab: (rawTab: DockTabCreate, addNumber: boolean) => DockTab; + installChartStore: InstallChartTabStore; +} + +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; +}; + +const createInstallChartTabInjectable = getInjectable({ + instantiate: (di) => createInstallChartTab({ + installChartStore: di.inject(installChartTabStoreInjectable), + createDockTab: di.inject(createDockTabInjectable), + }), + + lifecycle: lifecycleEnum.singleton, +}); + +export default createInstallChartTabInjectable; diff --git a/src/renderer/components/dock/install-chart.scss b/src/renderer/components/dock/install-chart/install-chart.scss similarity index 100% rename from src/renderer/components/dock/install-chart.scss rename to src/renderer/components/dock/install-chart/install-chart.scss diff --git a/src/renderer/components/dock/install-chart-store/install-chart-store.injectable.ts b/src/renderer/components/dock/install-chart/store.injectable.ts similarity index 73% rename from src/renderer/components/dock/install-chart-store/install-chart-store.injectable.ts rename to src/renderer/components/dock/install-chart/store.injectable.ts index d0fdb731a0..f1d38d744a 100644 --- a/src/renderer/components/dock/install-chart-store/install-chart-store.injectable.ts +++ b/src/renderer/components/dock/install-chart/store.injectable.ts @@ -3,18 +3,16 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; -import { InstallChartStore } from "./install-chart.store"; -import dockStoreInjectable from "../dock-store/dock-store.injectable"; +import { InstallChartTabStore } from "./store"; 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({ +const installChartTabStoreInjectable = getInjectable({ instantiate: (di) => { const createDockTabStore = di.inject(createDockTabStoreInjectable); - return new InstallChartStore({ - dockStore: di.inject(dockStoreInjectable), + return new InstallChartTabStore({ createStorage: di.inject(createStorageInjectable), versionsStore: createDockTabStore(), detailsStore: createDockTabStore(), @@ -23,4 +21,4 @@ const installChartStoreInjectable = getInjectable({ lifecycle: lifecycleEnum.singleton, }); -export default installChartStoreInjectable; +export default installChartTabStoreInjectable; diff --git a/src/renderer/components/dock/install-chart-store/install-chart.store.ts b/src/renderer/components/dock/install-chart/store.ts similarity index 72% rename from src/renderer/components/dock/install-chart-store/install-chart.store.ts rename to src/renderer/components/dock/install-chart/store.ts index 9e8a9d4c32..e322a0ec78 100644 --- a/src/renderer/components/dock/install-chart-store/install-chart.store.ts +++ b/src/renderer/components/dock/install-chart/store.ts @@ -3,12 +3,11 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import { action, autorun, makeObservable } from "mobx"; -import { DockStore, TabId, TabKind } from "../dock-store/dock.store"; +import { action, makeObservable, when } from "mobx"; +import type { TabId } from "../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 { @@ -23,29 +22,18 @@ export interface IChartInstallData { } interface Dependencies { - dockStore: DockStore, - createStorage: (storageKey: string, options: DockTabStorageState) => StorageHelper> - - versionsStore: DockTabStore, - detailsStore: DockTabStore + createStorage: (storageKey: string, options: DockTabStorageState) => StorageHelper>; + versionsStore: DockTabStore; + detailsStore: DockTabStore; } -export class InstallChartStore extends DockTabStore { +export class InstallChartTabStore extends DockTabStore { constructor(protected dependencies: Dependencies) { super( dependencies, { storageKey: "install_charts" }, ); - makeObservable(this); - autorun(() => { - const { selectedTab, isOpen } = dependencies.dockStore; - - if (selectedTab?.kind === TabKind.INSTALL_CHART && isOpen) { - this.loadData(selectedTab.id) - .catch(err => Notifications.error(String(err))); - } - }, { delay: 250 }); } get versions() { @@ -60,6 +48,8 @@ export class InstallChartStore extends DockTabStore { async loadData(tabId: string) { const promises = []; + await when(() => this.isReady(tabId)); + if (!this.getData(tabId).values) { promises.push(this.loadValues(tabId)); } @@ -94,8 +84,4 @@ export class InstallChartStore extends DockTabStore { return this.loadValues(tabId, attempt + 1); } } - - setData(tabId: TabId, data: IChartInstallData){ - super.setData(tabId, data); - } } diff --git a/src/renderer/components/dock/install-chart.tsx b/src/renderer/components/dock/install-chart/view.tsx similarity index 79% rename from src/renderer/components/dock/install-chart.tsx rename to src/renderer/components/dock/install-chart/view.tsx index a240b50979..f162ae13a3 100644 --- a/src/renderer/components/dock/install-chart.tsx +++ b/src/renderer/components/dock/install-chart/view.tsx @@ -8,29 +8,27 @@ import "./install-chart.scss"; import React, { Component } from "react"; import { action, makeObservable, observable } from "mobx"; import { observer } from "mobx-react"; -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 type { IChartInstallData, InstallChartStore } from "./install-chart-store/install-chart.store"; -import { Spinner } from "../spinner"; -import { Icon } from "../icon"; -import { Button } from "../button"; -import { LogsDialog } from "../dialog/logs-dialog"; -import { Select, SelectOption } from "../select"; -import { Input } from "../input"; -import { EditorPanel } from "./editor-panel"; -import { navigate } from "../../navigation"; -import { releaseURL } from "../../../common/routes"; -import type { - IReleaseCreatePayload, - IReleaseUpdateDetails, -} from "../../../common/k8s-api/endpoints/helm-releases.api"; +import type { DockStore, DockTab } from "../dock/store"; +import { InfoPanel } from "../info-panel"; +import { Badge } from "../../badge"; +import { NamespaceSelect } from "../../+namespaces/namespace-select"; +import { prevDefault } from "../../../utils"; +import type { IChartInstallData, InstallChartTabStore } from "./store"; +import { Spinner } from "../../spinner"; +import { Icon } from "../../icon"; +import { Button } from "../../button"; +import { LogsDialog } from "../../dialog/logs-dialog"; +import { Select, SelectOption } from "../../select"; +import { Input } from "../../input"; +import { EditorPanel } from "../editor-panel"; +import { navigate } from "../../../navigation"; +import { releaseURL } from "../../../../common/routes"; +import type { IReleaseCreatePayload, IReleaseUpdateDetails } from "../../../../common/k8s-api/endpoints/helm-releases.api"; 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"; -import createReleaseInjectable from "../+apps-releases/create-release/create-release.injectable"; +import installChartTabStoreInjectable from "./store.injectable"; +import dockStoreInjectable from "../dock/store.injectable"; +import createReleaseInjectable from "../../+apps-releases/create-release/create-release.injectable"; +import { Notifications } from "../../notifications"; interface Props { tab: DockTab; @@ -38,7 +36,7 @@ interface Props { interface Dependencies { createRelease: (payload: IReleaseCreatePayload) => Promise - installChartStore: InstallChartStore + installChartStore: InstallChartTabStore dockStore: DockStore } @@ -52,6 +50,11 @@ class NonInjectedInstallChart extends Component { makeObservable(this); } + componentDidMount(): void { + this.props.installChartStore.loadData(this.tabId) + .catch(err => Notifications.error(String(err))); + } + get chartData() { return this.props.installChartStore.getData(this.tabId); } @@ -221,7 +224,7 @@ export const InstallChart = withInjectables( { getProps: (di, props) => ({ createRelease: di.inject(createReleaseInjectable), - installChartStore: di.inject(installChartStoreInjectable), + installChartStore: di.inject(installChartTabStoreInjectable), dockStore: di.inject(dockStoreInjectable), ...props, }), diff --git a/src/renderer/components/dock/logs/__test__/log-resource-selector.test.tsx b/src/renderer/components/dock/logs/__test__/log-resource-selector.test.tsx index 9858827f86..b5923b98a0 100644 --- a/src/renderer/components/dock/logs/__test__/log-resource-selector.test.tsx +++ b/src/renderer/components/dock/logs/__test__/log-resource-selector.test.tsx @@ -8,7 +8,7 @@ import "@testing-library/jest-dom/extend-expect"; import * as selectEvent from "react-select-event"; import { Pod } from "../../../../../common/k8s-api/endpoints"; import { LogResourceSelector } from "../resource-selector"; -import { dockerPod, deploymentPod1 } from "./pod.mock"; +import { dockerPod, deploymentPod1, deploymentPod2 } from "./pod.mock"; import { ThemeStore } from "../../../../theme.store"; import { UserStore } from "../../../../../common/user-store"; import mockFs from "mock-fs"; @@ -18,7 +18,9 @@ import { renderFor } from "../../../test-utils/renderFor"; import directoryForUserDataInjectable from "../../../../../common/app-paths/directory-for-user-data/directory-for-user-data.injectable"; import callForLogsInjectable from "../call-for-logs.injectable"; import { LogTabViewModel, LogTabViewModelDependencies } from "../logs-view-model"; -import type { TabId } from "../../dock-store/dock.store"; +import type { TabId } from "../../dock/store"; +import userEvent from "@testing-library/user-event"; +import { SearchStore } from "../../../../search-store/search-store"; jest.mock("electron", () => ({ app: { @@ -36,10 +38,6 @@ jest.mock("electron", () => ({ }, })); -const getComponent = (model: LogTabViewModel) => ( - -); - function mockLogTabViewModel(tabId: TabId, deps: Partial): LogTabViewModel { return new LogTabViewModel(tabId, { getLogs: jest.fn(), @@ -49,34 +47,74 @@ function mockLogTabViewModel(tabId: TabId, deps: Partial { +const getOnePodViewModel = (tabId: TabId, deps: Partial = {}): LogTabViewModel => { const selectedPod = new Pod(dockerPod); return mockLogTabViewModel(tabId, { getLogTabData: () => ({ - pods: [selectedPod], - selectedPod, - selectedContainer: selectedPod.getContainers()[0], + selectedPodId: selectedPod.getId(), + selectedContainer: selectedPod.getContainers()[0].name, + namespace: selectedPod.getNs(), + showPrevious: false, + showTimestamps: false, }), + getPodById: (id) => { + if (id === selectedPod.getId()) { + return selectedPod; + } + + return undefined; + }, + ...deps, }); }; -const getFewPodsTabData = (tabId: TabId): LogTabViewModel => { +const getFewPodsTabData = (tabId: TabId, deps: Partial = {}): LogTabViewModel => { const selectedPod = new Pod(deploymentPod1); - const anotherPod = new Pod(dockerPod); + const anotherPod = new Pod(deploymentPod2); return mockLogTabViewModel(tabId, { getLogTabData: () => ({ - pods: [selectedPod, anotherPod], - selectedPod, - selectedContainer: selectedPod.getContainers()[0], + owner: { + uid: "uuid", + kind: "Deployment", + name: "super-deployment", + }, + selectedPodId: selectedPod.getId(), + selectedContainer: selectedPod.getContainers()[0].name, + namespace: selectedPod.getNs(), + showPrevious: false, + showTimestamps: false, }), + getPodById: (id) => { + if (id === selectedPod.getId()) { + return selectedPod; + } + + if (id === anotherPod.getId()) { + return anotherPod; + } + + return undefined; + }, + getPodsByOwnerId: (id) => { + if (id === "uuid") { + return [selectedPod, anotherPod]; + } + + return []; + }, + ...deps, }); }; @@ -109,14 +147,14 @@ describe("", () => { it("renders w/o errors", () => { const model = getOnePodViewModel("foobar"); - const { container } = render(getComponent(model)); + const { container } = render(); expect(container).toBeInstanceOf(HTMLElement); }); it("renders proper namespace", async () => { const model = getOnePodViewModel("foobar"); - const { findByTestId } = render(getComponent(model)); + const { findByTestId } = render(); const ns = await findByTestId("namespace-badge"); expect(ns).toHaveTextContent("default"); @@ -124,7 +162,7 @@ describe("", () => { it("renders proper selected items within dropdowns", async () => { const model = getOnePodViewModel("foobar"); - const { findByText } = render(getComponent(model)); + const { findByText } = render(); expect(await findByText("dockerExporter")).toBeInTheDocument(); expect(await findByText("docker-exporter")).toBeInTheDocument(); @@ -132,33 +170,40 @@ describe("", () => { it("renders sibling pods in dropdown", async () => { const model = getFewPodsTabData("foobar"); - const { container, findByText } = render(getComponent(model)); + const { container, findByText } = render(); selectEvent.openMenu(container.querySelector(".pod-selector")); - - expect(await findByText("dockerExporter", { selector: ".pod-selector-menu .Select__option" })).toBeInTheDocument(); + expect(await findByText("deploymentPod2", { selector: ".pod-selector-menu .Select__option" })).toBeInTheDocument(); expect(await findByText("deploymentPod1", { selector: ".pod-selector-menu .Select__option" })).toBeInTheDocument(); }); it("renders sibling containers in dropdown", async () => { const model = getFewPodsTabData("foobar"); - const { findByText, container } = render(getComponent(model)); - const containerSelector: HTMLElement = container.querySelector(".container-selector"); - - selectEvent.openMenu(containerSelector); + const { findByText, container } = render(); + selectEvent.openMenu(container.querySelector(".container-selector")); expect(await findByText("node-exporter-1")).toBeInTheDocument(); expect(await findByText("init-node-exporter")).toBeInTheDocument(); expect(await findByText("init-node-exporter-1")).toBeInTheDocument(); }); - it("renders pod owner as dropdown title", async () => { + it("renders pod owner as badge", async () => { const model = getFewPodsTabData("foobar"); - const { findByText, container } = render(getComponent(model)); - const podSelector: HTMLElement = container.querySelector(".pod-selector"); + const { findByText } = render(); - selectEvent.openMenu(podSelector); + expect(await findByText("super-deployment", { + exact: false, + })).toBeInTheDocument(); + }); - expect(await findByText("super-deployment")).toBeInTheDocument(); + it("updates tab name if selected pod changes", async () => { + const renameTab = jest.fn(); + const model = getFewPodsTabData("foobar", { renameTab }); + const { findByText, container } = render(); + + selectEvent.openMenu(container.querySelector(".pod-selector")); + + userEvent.click(await findByText("deploymentPod2", { selector: ".pod-selector-menu .Select__option" })); + expect(renameTab).toBeCalledWith("foobar", "Pod deploymentPod2"); }); }); diff --git a/src/renderer/components/dock/logs/__test__/log-search.test.tsx b/src/renderer/components/dock/logs/__test__/log-search.test.tsx new file mode 100644 index 0000000000..24c2001eec --- /dev/null +++ b/src/renderer/components/dock/logs/__test__/log-search.test.tsx @@ -0,0 +1,147 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import React from "react"; +import { screen } from "@testing-library/react"; +import { Pod } from "../../../../../common/k8s-api/endpoints"; +import { dockerPod } from "./pod.mock"; +import { getDiForUnitTesting } from "../../../../getDiForUnitTesting"; +import type { DiRender } from "../../../test-utils/renderFor"; +import { renderFor } from "../../../test-utils/renderFor"; +import { LogTabViewModel, LogTabViewModelDependencies } from "../logs-view-model"; +import type { TabId } from "../../dock/store"; +import { LogSearch } from "../search"; +import userEvent from "@testing-library/user-event"; +import { SearchStore } from "../../../../search-store/search-store"; + +function mockLogTabViewModel(tabId: TabId, deps: Partial): LogTabViewModel { + return new LogTabViewModel(tabId, { + getLogs: jest.fn(), + getLogsWithoutTimestamps: jest.fn(), + getTimestampSplitLogs: jest.fn(), + getLogTabData: jest.fn(), + setLogTabData: jest.fn(), + loadLogs: jest.fn(), + reloadLogs: jest.fn(), + renameTab: jest.fn(), + stopLoadingLogs: jest.fn(), + getPodById: jest.fn(), + getPodsByOwnerId: jest.fn(), + areLogsPresent: jest.fn(), + searchStore: new SearchStore(), + ...deps, + }); +} + +const getOnePodViewModel = (tabId: TabId, deps: Partial = {}): LogTabViewModel => { + const selectedPod = new Pod(dockerPod); + + return mockLogTabViewModel(tabId, { + getLogTabData: () => ({ + selectedPodId: selectedPod.getId(), + selectedContainer: selectedPod.getContainers()[0].name, + namespace: selectedPod.getNs(), + showPrevious: false, + showTimestamps: false, + }), + getPodById: (id) => { + if (id === selectedPod.getId()) { + return selectedPod; + } + + return undefined; + }, + ...deps, + }); +}; + +describe("LogSearch tests", () => { + let render: DiRender; + + beforeEach(async () => { + const di = getDiForUnitTesting({ doGeneralOverrides: true }); + + render = renderFor(di); + + await di.runSetups(); + }); + + it("renders w/o errors", () => { + const model = getOnePodViewModel("foobar"); + const { container } = render( + , + ); + + expect(container).toBeInstanceOf(HTMLElement); + }); + + it("should scroll to new active overlay when clicking the previous button", async () => { + const scrollToOverlay = jest.fn(); + const model = getOnePodViewModel("foobar", { + getLogsWithoutTimestamps: () => [ + "hello", + "world", + ], + }); + + render( + , + ); + + userEvent.click(await screen.findByPlaceholderText("Search...")); + userEvent.keyboard("o"); + userEvent.click(await screen.findByText("keyboard_arrow_up")); + expect(scrollToOverlay).toBeCalled(); + }); + + it("should scroll to new active overlay when clicking the next button", async () => { + const scrollToOverlay = jest.fn(); + const model = getOnePodViewModel("foobar", { + getLogsWithoutTimestamps: () => [ + "hello", + "world", + ], + }); + + render( + , + ); + + userEvent.click(await screen.findByPlaceholderText("Search...")); + userEvent.keyboard("o"); + userEvent.click(await screen.findByText("keyboard_arrow_down")); + expect(scrollToOverlay).toBeCalled(); + }); + + it("next and previous should be disabled initially", async () => { + const scrollToOverlay = jest.fn(); + const model = getOnePodViewModel("foobar", { + getLogsWithoutTimestamps: () => [ + "hello", + "world", + ], + }); + + render( + , + ); + + userEvent.click(await screen.findByText("keyboard_arrow_down")); + userEvent.click(await screen.findByText("keyboard_arrow_up")); + expect(scrollToOverlay).not.toBeCalled(); + }); +}); diff --git a/src/renderer/components/dock/logs/__test__/log-tab.store.test.ts b/src/renderer/components/dock/logs/__test__/log-tab.store.test.ts deleted file mode 100644 index b98b46daf9..0000000000 --- a/src/renderer/components/dock/logs/__test__/log-tab.store.test.ts +++ /dev/null @@ -1,146 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import { 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 { deploymentPod1, deploymentPod2, deploymentPod3, dockerPod } from "./pod.mock"; -import { mockWindow } from "../../../../../../__mocks__/windowMock"; -import { getDiForUnitTesting } from "../../../../getDiForUnitTesting"; -import logTabStoreInjectable from "../tab-store.injectable"; -import type { LogTabStore } from "../tab.store"; -import dockStoreInjectable from "../../dock-store/dock-store.injectable"; -import type { DockStore } from "../../dock-store/dock.store"; -import directoryForUserDataInjectable - from "../../../../../common/app-paths/directory-for-user-data/directory-for-user-data.injectable"; -import mockFs from "mock-fs"; - -mockWindow(); - -podsStore.items.push(new Pod(dockerPod)); -podsStore.items.push(new Pod(deploymentPod1)); -podsStore.items.push(new Pod(deploymentPod2)); - -describe("log tab store", () => { - 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(() => { - UserStore.resetInstance(); - ThemeStore.resetInstance(); - mockFs.restore(); - }); - - it("creates log tab without sibling pods", () => { - const selectedPod = new Pod(dockerPod); - const selectedContainer = selectedPod.getAllContainers()[0]; - - logTabStore.createPodTab({ - selectedPod, - selectedContainer, - }); - - expect(logTabStore.getData(dockStore.selectedTabId)).toEqual({ - pods: [selectedPod], - selectedPod, - selectedContainer, - showTimestamps: false, - previous: false, - }); - }); - - it("creates log tab with sibling pods", () => { - const selectedPod = new Pod(deploymentPod1); - const siblingPod = new Pod(deploymentPod2); - const selectedContainer = selectedPod.getInitContainers()[0]; - - logTabStore.createPodTab({ - selectedPod, - selectedContainer, - }); - - expect(logTabStore.getData(dockStore.selectedTabId)).toEqual({ - pods: [selectedPod, siblingPod], - selectedPod, - selectedContainer, - showTimestamps: false, - previous: false, - }); - }); - - it("removes item from pods list if pod deleted from store", () => { - const selectedPod = new Pod(deploymentPod1); - const selectedContainer = selectedPod.getInitContainers()[0]; - - logTabStore.createPodTab({ - selectedPod, - selectedContainer, - }); - - podsStore.items.pop(); - - expect(logTabStore.getData(dockStore.selectedTabId)).toEqual({ - pods: [selectedPod], - selectedPod, - selectedContainer, - showTimestamps: false, - previous: false, - }); - }); - - it("adds item into pods list if new sibling pod added to store", () => { - const selectedPod = new Pod(deploymentPod1); - const selectedContainer = selectedPod.getInitContainers()[0]; - - logTabStore.createPodTab({ - selectedPod, - selectedContainer, - }); - - podsStore.items.push(new Pod(deploymentPod3)); - - expect(logTabStore.getData(dockStore.selectedTabId)).toEqual({ - pods: [selectedPod, deploymentPod3], - selectedPod, - selectedContainer, - showTimestamps: false, - previous: false, - }); - }); - - // FIXME: this is failed when it's not .only == depends on something above - it.only("closes tab if no pods left in store", async () => { - const selectedPod = new Pod(deploymentPod1); - const selectedContainer = selectedPod.getInitContainers()[0]; - - const id = logTabStore.createPodTab({ - selectedPod, - selectedContainer, - }); - - podsStore.items.clear(); - - expect(logTabStore.getData(dockStore.selectedTabId)).toBeUndefined(); - expect(logTabStore.getData(id)).toBeUndefined(); - expect(dockStore.getTabById(id)).toBeUndefined(); - }); -}); diff --git a/src/renderer/components/dock/logs/are-logs-present.injectable.ts b/src/renderer/components/dock/logs/are-logs-present.injectable.ts new file mode 100644 index 0000000000..f4644c8229 --- /dev/null +++ b/src/renderer/components/dock/logs/are-logs-present.injectable.ts @@ -0,0 +1,19 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import type { TabId } from "../dock/store"; +import logStoreInjectable from "./store.injectable"; + +const areLogsPresentInjectable = getInjectable({ + instantiate: (di) => { + const logStore = di.inject(logStoreInjectable); + + return (tabId: TabId) => logStore.areLogsPresent(tabId); + }, + + lifecycle: lifecycleEnum.singleton, +}); + +export default areLogsPresentInjectable; diff --git a/src/renderer/components/dock/logs/clear-log-tab-data.injectable.ts b/src/renderer/components/dock/logs/clear-log-tab-data.injectable.ts new file mode 100644 index 0000000000..0bc007bdf4 --- /dev/null +++ b/src/renderer/components/dock/logs/clear-log-tab-data.injectable.ts @@ -0,0 +1,21 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import type { TabId } from "../dock/store"; +import logTabStoreInjectable from "./tab-store.injectable"; + +const clearLogTabDataInjectable = getInjectable({ + instantiate: (di) => { + const logTabStore = di.inject(logTabStoreInjectable); + + return (tabId: TabId): void => { + logTabStore.clearData(tabId); + }; + }, + + lifecycle: lifecycleEnum.singleton, +}); + +export default clearLogTabDataInjectable; diff --git a/src/renderer/components/dock/logs/controls.tsx b/src/renderer/components/dock/logs/controls.tsx index 954ccb8478..59aead384b 100644 --- a/src/renderer/components/dock/logs/controls.tsx +++ b/src/renderer/components/dock/logs/controls.tsx @@ -8,17 +8,22 @@ import "./controls.scss"; import React from "react"; import { observer } from "mobx-react"; -import { Pod } from "../../../../common/k8s-api/endpoints"; -import { cssNames, saveFileDialog } from "../../../utils"; +import { cssNames } from "../../../utils"; import { Checkbox } from "../../checkbox"; import { Icon } from "../../icon"; import type { LogTabViewModel } from "./logs-view-model"; +import { withInjectables } from "@ogre-tools/injectable-react"; +import openSaveFileDialogInjectable from "../../../utils/save-file.injectable"; export interface LogControlsProps { model: LogTabViewModel; } -export const LogControls = observer(({ model }: LogControlsProps) => { +interface Dependencies { + openSaveFileDialog: (filename: string, contents: BlobPart | BlobPart[], type: string) => void; +} + +const NonInjectedLogControls = observer(({ openSaveFileDialog, model }: Dependencies & LogControlsProps) => { const tabData = model.logTabData.get(); if (!tabData) { @@ -26,26 +31,25 @@ export const LogControls = observer(({ model }: LogControlsProps) => { } const logs = model.timestampSplitLogs.get(); - const { showTimestamps, previous } = tabData; + const { showTimestamps, showPrevious: previous } = tabData; const since = logs.length ? logs[0][0] : null; - const pod = new Pod(tabData.selectedPod); const toggleTimestamps = () => { model.updateLogTabData({ showTimestamps: !showTimestamps }); }; const togglePrevious = () => { - model.updateLogTabData({ previous: !previous }); + model.updateLogTabData({ showPrevious: !previous }); model.reloadLogs(); }; const downloadLogs = () => { - const fileName = pod.getName(); + const fileName = model.pod.get().getName(); const logsToDownload: string[] = showTimestamps ? model.logs.get() : model.logsWithoutTimestamps.get(); - saveFileDialog(`${fileName}.log`, logsToDownload.join("\n"), "text/plain"); + openSaveFileDialog(`${fileName}.log`, logsToDownload.join("\n"), "text/plain"); }; return ( @@ -81,3 +85,10 @@ export const LogControls = observer(({ model }: LogControlsProps) => { ); }); + +export const LogControls = withInjectables(NonInjectedLogControls, { + getProps: (di, props) => ({ + openSaveFileDialog: di.inject(openSaveFileDialogInjectable), + ...props, + }), +}); diff --git a/src/renderer/components/dock/logs/create-logs-tab.injectable.ts b/src/renderer/components/dock/logs/create-logs-tab.injectable.ts new file mode 100644 index 0000000000..ddef5c16d7 --- /dev/null +++ b/src/renderer/components/dock/logs/create-logs-tab.injectable.ts @@ -0,0 +1,48 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { DockTabCreate, DockTab, TabKind, TabId } from "../dock/store"; +import type { LogTabData } from "./tab-store"; +import * as uuid from "uuid"; +import { runInAction } from "mobx"; +import createDockTabInjectable from "../dock/create-dock-tab.injectable"; +import setLogTabDataInjectable from "./set-log-tab-data.injectable"; + +export type CreateLogsTabData = Pick & Omit, "owner" | "selectedPodId" | "selectedContainer" | "namespace">; + +interface Dependencies { + createDockTab: (rawTabDesc: DockTabCreate, addNumber?: boolean) => DockTab; + setLogTabData: (tabId: string, data: LogTabData) => void; +} + +const createLogsTab = ({ createDockTab, setLogTabData }: Dependencies) => (title: string, data: CreateLogsTabData): TabId => { + const id = `log-tab-${uuid.v4()}`; + + runInAction(() => { + createDockTab({ + id, + title, + kind: TabKind.POD_LOGS, + }, false); + setLogTabData(id, { + showTimestamps: false, + showPrevious: false, + ...data, + }); + }); + + return id; +}; + +const createLogsTabInjectable = getInjectable({ + instantiate: (di) => createLogsTab({ + createDockTab: di.inject(createDockTabInjectable), + setLogTabData: di.inject(setLogTabDataInjectable), + }), + + lifecycle: lifecycleEnum.singleton, +}); + +export default createLogsTabInjectable; diff --git a/src/renderer/components/dock/logs/create-pod-logs-tab.injectable.ts b/src/renderer/components/dock/logs/create-pod-logs-tab.injectable.ts new file mode 100644 index 0000000000..2d9c3f981d --- /dev/null +++ b/src/renderer/components/dock/logs/create-pod-logs-tab.injectable.ts @@ -0,0 +1,31 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import type { IPodContainer, Pod } from "../../../../common/k8s-api/endpoints"; +import type { TabId } from "../dock/store"; +import createLogsTabInjectable from "./create-logs-tab.injectable"; + +export interface PodLogsTabData { + selectedPod: Pod; + selectedContainer: IPodContainer; +} + +const createPodLogsTabInjectable = getInjectable({ + instantiate: (di) => { + const createLogsTab = di.inject(createLogsTabInjectable); + + return ({ selectedPod, selectedContainer }: PodLogsTabData): TabId => + createLogsTab(`Pod ${selectedPod.getName()}`, { + owner: selectedPod.getOwnerRefs()[0], + namespace: selectedPod.getNs(), + selectedContainer: selectedContainer.name, + selectedPodId: selectedPod.getId(), + }); + }, + + lifecycle: lifecycleEnum.singleton, +}); + +export default createPodLogsTabInjectable; diff --git a/src/renderer/components/dock/logs/create-workload-logs-tab.injectable.ts b/src/renderer/components/dock/logs/create-workload-logs-tab.injectable.ts new file mode 100644 index 0000000000..e8e2eb41ed --- /dev/null +++ b/src/renderer/components/dock/logs/create-workload-logs-tab.injectable.ts @@ -0,0 +1,48 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { podsStore } from "../../+workloads-pods/pods.store"; +import type { WorkloadKubeObject } from "../../../../common/k8s-api/workload-kube-object"; +import type { TabId } from "../dock/store"; +import createLogsTabInjectable, { CreateLogsTabData } from "./create-logs-tab.injectable"; + +export interface WorkloadLogsTabData { + workload: WorkloadKubeObject +} + +interface Dependencies { + createLogsTab: (title: string, data: CreateLogsTabData) => TabId; +} + +const createWorkloadLogsTab = ({ createLogsTab }: Dependencies) => ({ workload }: WorkloadLogsTabData): TabId | undefined => { + const pods = podsStore.getPodsByOwnerId(workload.getId()); + + if (pods.length === 0) { + return undefined; + } + + const selectedPod = pods[0]; + + return createLogsTab(`${workload.kind} ${selectedPod.getName()}`, { + selectedContainer: selectedPod.getAllContainers()[0].name, + selectedPodId: selectedPod.getId(), + namespace: selectedPod.getNs(), + owner: { + kind: workload.kind, + name: workload.getName(), + uid: workload.getId(), + }, + }); +}; + +const createWorkloadLogsTabInjectable = getInjectable({ + instantiate: (di) => createWorkloadLogsTab({ + createLogsTab: di.inject(createLogsTabInjectable), + }), + + lifecycle: lifecycleEnum.singleton, +}); + +export default createWorkloadLogsTabInjectable; diff --git a/src/renderer/components/dock/logs/dock-tab.tsx b/src/renderer/components/dock/logs/dock-tab.tsx deleted file mode 100644 index 1ae62b4fe6..0000000000 --- a/src/renderer/components/dock/logs/dock-tab.tsx +++ /dev/null @@ -1,101 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import React from "react"; -import { observer } from "mobx-react"; -import { boundMethod } from "../../../utils"; -import { InfoPanel } from "../info-panel"; -import { LogResourceSelector } from "./resource-selector"; -import { LogList } from "./list"; -import { LogSearch } from "./search"; -import { LogControls } from "./controls"; -import { withInjectables } from "@ogre-tools/injectable-react"; -import logsViewModelInjectable from "./logs-view-model.injectable"; -import type { LogTabViewModel } from "./logs-view-model"; -import type { DockTab } from "../dock-store/dock.store"; - -export interface LogsDockTabProps { - className?: string; - tab: DockTab; -} - -interface Dependencies { - model: LogTabViewModel; -} - -@observer -class NonInjectedLogsDockTab extends React.Component { - private logListElement = React.createRef(); // A reference for VirtualList component - - componentDidMount(): void { - this.props.model.reloadLogs(); - } - - componentWillUnmount(): void { - this.props.model.stopLoadingLogs(); - } - - /** - * Scrolling to active overlay (search word highlight) - */ - @boundMethod - scrollToOverlay() { - const { activeOverlayLine } = this.props.model.searchStore; - - if (!this.logListElement.current || activeOverlayLine === undefined) return; - // Scroll vertically - this.logListElement.current.scrollToItem(activeOverlayLine, "center"); - // Scroll horizontally in timeout since virtual list need some time to prepare its contents - setTimeout(() => { - const overlay = document.querySelector(".PodLogs .list span.active"); - - if (!overlay) return; - overlay.scrollIntoViewIfNeeded(); - }, 100); - } - - render() { - const { model, tab } = this.props; - const { logTabData } = model; - const data = logTabData.get(); - - if (!data) { - return null; - } - - return ( -
- - - -
- )} - showSubmitClose={false} - showButtons={false} - showStatusPanel={false} - /> - - - - ); - } -} - -export const LogsDockTab = withInjectables(NonInjectedLogsDockTab, { - getProps: (di, props) => ({ - model: di.inject(logsViewModelInjectable, { - tabId: props.tab.id, - }), - ...props, - }), -}); diff --git a/src/renderer/components/dock/logs/get-log-tab-data.injectable.ts b/src/renderer/components/dock/logs/get-log-tab-data.injectable.ts index e706fdaafb..35fecebb3a 100644 --- a/src/renderer/components/dock/logs/get-log-tab-data.injectable.ts +++ b/src/renderer/components/dock/logs/get-log-tab-data.injectable.ts @@ -3,10 +3,16 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import type { LogTabData } from "./tab-store"; import logTabStoreInjectable from "./tab-store.injectable"; const getLogTabDataInjectable = getInjectable({ - instantiate: (di) => di.inject(logTabStoreInjectable).getData, + instantiate: (di) => { + const logTabStore = di.inject(logTabStoreInjectable); + + return (tabId: string): LogTabData => logTabStore.getData(tabId); + }, + lifecycle: lifecycleEnum.singleton, }); diff --git a/src/renderer/components/dock/logs/get-logs-without-timestamps.injectable.ts b/src/renderer/components/dock/logs/get-logs-without-timestamps.injectable.ts index 1be01ff443..250039d7e1 100644 --- a/src/renderer/components/dock/logs/get-logs-without-timestamps.injectable.ts +++ b/src/renderer/components/dock/logs/get-logs-without-timestamps.injectable.ts @@ -6,7 +6,13 @@ import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; import logStoreInjectable from "./store.injectable"; const getLogsWithoutTimestampsInjectable = getInjectable({ - instantiate: (di) => di.inject(logStoreInjectable).getLogsWithoutTimestampsByTabId, + instantiate: (di) => { + const logStore = di.inject(logStoreInjectable); + + return (tabId: string): string[] => + logStore.getLogsWithoutTimestamps(tabId); + }, + lifecycle: lifecycleEnum.singleton, }); diff --git a/src/renderer/components/dock/logs/get-logs.injectable.ts b/src/renderer/components/dock/logs/get-logs.injectable.ts index 659a87c46f..acd6d10137 100644 --- a/src/renderer/components/dock/logs/get-logs.injectable.ts +++ b/src/renderer/components/dock/logs/get-logs.injectable.ts @@ -6,7 +6,12 @@ import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; import logStoreInjectable from "./store.injectable"; const getLogsInjectable = getInjectable({ - instantiate: (di) => di.inject(logStoreInjectable).getLogsByTabId, + instantiate: (di) => { + const logStore = di.inject(logStoreInjectable); + + return (tabId: string): string[] => logStore.getLogs(tabId); + }, + lifecycle: lifecycleEnum.singleton, }); diff --git a/src/renderer/components/dock/logs/get-timestamp-split-logs.injectable.ts b/src/renderer/components/dock/logs/get-timestamp-split-logs.injectable.ts index 7cdac68327..4d2189d27e 100644 --- a/src/renderer/components/dock/logs/get-timestamp-split-logs.injectable.ts +++ b/src/renderer/components/dock/logs/get-timestamp-split-logs.injectable.ts @@ -6,7 +6,13 @@ import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; import logStoreInjectable from "./store.injectable"; const getTimestampSplitLogsInjectable = getInjectable({ - instantiate: (di) => di.inject(logStoreInjectable).getTimestampSplitLogsByTabId, + instantiate: (di) => { + const logStore = di.inject(logStoreInjectable); + + return (tabId: string): [string, string][] => + logStore.getTimestampSplitLogs(tabId); + }, + lifecycle: lifecycleEnum.singleton, }); diff --git a/src/renderer/components/dock/logs/is-logs-tab-data-valid.injectable.ts b/src/renderer/components/dock/logs/is-logs-tab-data-valid.injectable.ts new file mode 100644 index 0000000000..03062ecaae --- /dev/null +++ b/src/renderer/components/dock/logs/is-logs-tab-data-valid.injectable.ts @@ -0,0 +1,19 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import type { TabId } from "../dock/store"; +import logTabStoreInjectable from "./tab-store.injectable"; + +const isLogsTabDataValidInjectable = getInjectable({ + instantiate: (di) => { + const logTabStore = di.inject(logTabStoreInjectable); + + return (tabId: TabId) => logTabStore.isDataValid(tabId); + }, + + lifecycle: lifecycleEnum.singleton, +}); + +export default isLogsTabDataValidInjectable; diff --git a/src/renderer/components/dock/logs/list.tsx b/src/renderer/components/dock/logs/list.tsx index b9274087b4..273a6ee32f 100644 --- a/src/renderer/components/dock/logs/list.tsx +++ b/src/renderer/components/dock/logs/list.tsx @@ -19,6 +19,7 @@ import { array, boundMethod, cssNames } from "../../../utils"; import { VirtualList } from "../../virtual-list"; import { ToBottom } from "./to-bottom"; import type { LogTabViewModel } from "../logs/logs-view-model"; +import { Spinner } from "../../spinner"; export interface LogListProps { model: LogTabViewModel; @@ -210,12 +211,18 @@ export class LogList extends React.Component { }; render() { - const rowHeights = array.filled(this.logs.length, this.lineHeight); + if (this.props.model.isLoading.get()) { + return ( +
+ +
+ ); + } if (!this.logs.length) { return (
- There are no logs available for container + There are no logs available for container {this.props.model.logTabData.get()?.selectedContainer}
); } @@ -224,7 +231,7 @@ export class LogList extends React.Component {
di.inject(logStoreInjectable).load, + instantiate: (di) => { + const logStore = di.inject(logStoreInjectable); + + return ( + tabId: string, + pod: IComputedValue, + logTabData: IComputedValue, + ): Promise => logStore.load(tabId, pod, logTabData); + }, + lifecycle: lifecycleEnum.singleton, }); diff --git a/src/renderer/components/dock/logs/log-tab-data.validator.ts b/src/renderer/components/dock/logs/log-tab-data.validator.ts new file mode 100644 index 0000000000..32baf38fbb --- /dev/null +++ b/src/renderer/components/dock/logs/log-tab-data.validator.ts @@ -0,0 +1,38 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import Joi from "joi"; +import type { LogTabData, LogTabOwnerRef } from "./tab-store"; + +export const logTabDataValidator = Joi.object({ + owner: Joi + .object({ + uid: Joi + .string() + .required(), + name: Joi + .string() + .required(), + kind: Joi + .string() + .required(), + }) + .unknown(true) + .optional(), + selectedPodId: Joi + .string() + .required(), + namespace: Joi + .string() + .required(), + selectedContainer: Joi + .string() + .optional(), + showTimestamps: Joi + .boolean() + .required(), + showPrevious: Joi + .boolean() + .required(), +}); diff --git a/src/renderer/components/dock/logs/logs-view-model.injectable.ts b/src/renderer/components/dock/logs/logs-view-model.injectable.ts index 7516e47124..d26a34d583 100644 --- a/src/renderer/components/dock/logs/logs-view-model.injectable.ts +++ b/src/renderer/components/dock/logs/logs-view-model.injectable.ts @@ -4,16 +4,19 @@ */ import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; import { LogTabViewModel } from "./logs-view-model"; -import type { TabId } from "../dock-store/dock.store"; +import type { TabId } from "../dock/store"; import getLogsInjectable from "./get-logs.injectable"; import getLogsWithoutTimestampsInjectable from "./get-logs-without-timestamps.injectable"; import getTimestampSplitLogsInjectable from "./get-timestamp-split-logs.injectable"; -import reloadLoadsInjectable from "./reload-logs.injectable"; +import reloadLogsInjectable from "./reload-logs.injectable"; import getLogTabDataInjectable from "./get-log-tab-data.injectable"; import loadLogsInjectable from "./load-logs.injectable"; import setLogTabDataInjectable from "./set-log-tab-data.injectable"; -import updateTabNameInjectable from "./update-tab-name.injectable"; import stopLoadingLogsInjectable from "./stop-loading-logs.injectable"; +import { podsStore } from "../../+workloads-pods/pods.store"; +import renameTabInjectable from "../dock/rename-tab.injectable"; +import areLogsPresentInjectable from "./are-logs-present.injectable"; +import searchStoreInjectable from "../../../search-store/search-store.injectable"; export interface InstantiateArgs { tabId: TabId; @@ -24,12 +27,16 @@ const logsViewModelInjectable = getInjectable({ getLogs: di.inject(getLogsInjectable), getLogsWithoutTimestamps: di.inject(getLogsWithoutTimestampsInjectable), getTimestampSplitLogs: di.inject(getTimestampSplitLogsInjectable), - reloadLogs: di.inject(reloadLoadsInjectable), + reloadLogs: di.inject(reloadLogsInjectable), getLogTabData: di.inject(getLogTabDataInjectable), setLogTabData: di.inject(setLogTabDataInjectable), loadLogs: di.inject(loadLogsInjectable), - updateTabName: di.inject(updateTabNameInjectable), + renameTab: di.inject(renameTabInjectable), stopLoadingLogs: di.inject(stopLoadingLogsInjectable), + areLogsPresent: di.inject(areLogsPresentInjectable), + getPodById: id => podsStore.getById(id), + getPodsByOwnerId: id => podsStore.getPodsByOwnerId(id), + searchStore: di.inject(searchStoreInjectable), }), lifecycle: lifecycleEnum.transient, }); diff --git a/src/renderer/components/dock/logs/logs-view-model.ts b/src/renderer/components/dock/logs/logs-view-model.ts index 6c2f182355..73ed0c770f 100644 --- a/src/renderer/components/dock/logs/logs-view-model.ts +++ b/src/renderer/components/dock/logs/logs-view-model.ts @@ -2,10 +2,11 @@ * Copyright (c) OpenLens Authors. All rights reserved. * Licensed under MIT License. See LICENSE in root directory for more information. */ -import type { LogTabData } from "./tab.store"; +import type { LogTabData } from "./tab-store"; import { computed, IComputedValue } from "mobx"; -import type { TabId } from "../dock-store/dock.store"; -import { SearchStore } from "../../../search-store/search-store"; +import type { TabId } from "../dock/store"; +import type { SearchStore } from "../../../search-store/search-store"; +import type { Pod } from "../../../../common/k8s-api/endpoints"; export interface LogTabViewModelDependencies { getLogs: (tabId: TabId) => string[]; @@ -13,27 +14,57 @@ export interface LogTabViewModelDependencies { getTimestampSplitLogs: (tabId: TabId) => [string, string][]; getLogTabData: (tabId: TabId) => LogTabData; setLogTabData: (tabId: TabId, data: LogTabData) => void; - loadLogs: (tabId: TabId, logTabData: IComputedValue) => Promise; - reloadLogs: (tabId: TabId, logTabData: IComputedValue) => Promise; - updateTabName: (tabId: TabId) => void; + loadLogs: (tabId: TabId, pod: IComputedValue, logTabData: IComputedValue) => Promise; + reloadLogs: (tabId: TabId, pod: IComputedValue, logTabData: IComputedValue) => Promise; + renameTab: (tabId: TabId, title: string) => void; stopLoadingLogs: (tabId: TabId) => void; + getPodById: (id: string) => Pod | undefined; + getPodsByOwnerId: (id: string) => Pod[]; + areLogsPresent: (tabId: TabId) => boolean; + searchStore: SearchStore; } export class LogTabViewModel { constructor(protected readonly tabId: TabId, private readonly dependencies: LogTabViewModelDependencies) {} + get searchStore() { + return this.dependencies.searchStore; + } + + readonly isLoading = computed(() => this.dependencies.areLogsPresent(this.tabId)); readonly logs = computed(() => this.dependencies.getLogs(this.tabId)); readonly logsWithoutTimestamps = computed(() => this.dependencies.getLogsWithoutTimestamps(this.tabId)); readonly timestampSplitLogs = computed(() => this.dependencies.getTimestampSplitLogs(this.tabId)); readonly logTabData = computed(() => this.dependencies.getLogTabData(this.tabId)); - readonly searchStore = new SearchStore(); + readonly pods = computed(() => { + const data = this.logTabData.get(); + + if (!data) { + return []; + } + + if (typeof data.owner?.uid === "string") { + return this.dependencies.getPodsByOwnerId(data.owner.uid); + } + + return [this.dependencies.getPodById(data.selectedPodId)]; + }); + readonly pod = computed(() => { + const data = this.logTabData.get(); + + if (!data) { + return undefined; + } + + return this.dependencies.getPodById(data.selectedPodId); + }); updateLogTabData = (partialData: Partial) => { this.dependencies.setLogTabData(this.tabId, { ...this.logTabData.get(), ...partialData }); }; - loadLogs = () => this.dependencies.loadLogs(this.tabId, this.logTabData); - reloadLogs = () => this.dependencies.reloadLogs(this.tabId, this.logTabData); - updateTabName = () => this.dependencies.updateTabName(this.tabId); + loadLogs = () => this.dependencies.loadLogs(this.tabId, this.pod, this.logTabData); + reloadLogs = () => this.dependencies.reloadLogs(this.tabId, this.pod, this.logTabData); + renameTab = (title: string) => this.dependencies.renameTab(this.tabId, title); stopLoadingLogs = () => this.dependencies.stopLoadingLogs(this.tabId); } diff --git a/src/renderer/components/dock/logs/reload-logs.injectable.ts b/src/renderer/components/dock/logs/reload-logs.injectable.ts index 9fa917bf4b..4ba55c4b58 100644 --- a/src/renderer/components/dock/logs/reload-logs.injectable.ts +++ b/src/renderer/components/dock/logs/reload-logs.injectable.ts @@ -3,11 +3,23 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import type { IComputedValue } from "mobx"; +import type { Pod } from "../../../../common/k8s-api/endpoints"; import logStoreInjectable from "./store.injectable"; +import type { LogTabData } from "./tab-store"; + +const reloadLogsInjectable = getInjectable({ + instantiate: (di) => { + const logStore = di.inject(logStoreInjectable); + + return ( + tabId: string, + pod: IComputedValue, + logTabData: IComputedValue, + ): Promise => logStore.reload(tabId, pod, logTabData); + }, -const reloadLoadsInjectable = getInjectable({ - instantiate: (di) => di.inject(logStoreInjectable).reload, lifecycle: lifecycleEnum.singleton, }); -export default reloadLoadsInjectable; +export default reloadLogsInjectable; diff --git a/src/renderer/components/dock/logs/resource-selector.tsx b/src/renderer/components/dock/logs/resource-selector.tsx index bca2c30f45..459069fd61 100644 --- a/src/renderer/components/dock/logs/resource-selector.tsx +++ b/src/renderer/components/dock/logs/resource-selector.tsx @@ -5,19 +5,25 @@ import "./resource-selector.scss"; -import React, { useEffect } from "react"; +import React from "react"; import { observer } from "mobx-react"; -import { Pod } from "../../../../common/k8s-api/endpoints"; import { Badge } from "../../badge"; import { Select, SelectOption } from "../../select"; -import { podsStore } from "../../+workloads-pods/pods.store"; import type { LogTabViewModel } from "./logs-view-model"; +import type { IPodContainer, Pod } from "../../../../common/k8s-api/endpoints"; export interface LogResourceSelectorProps { model: LogTabViewModel; } +function getSelectOptions(containers: IPodContainer[]): SelectOption[] { + return containers.map(container => ({ + value: container.name, + label: container.name, + })); +} + export const LogResourceSelector = observer(({ model }: LogResourceSelectorProps) => { const tabData = model.logTabData.get(); @@ -25,66 +31,61 @@ export const LogResourceSelector = observer(({ model }: LogResourceSelectorProps return null; } - const { selectedPod, selectedContainer, pods } = tabData; - const pod = new Pod(selectedPod); - const containers = pod.getContainers(); - const initContainers = pod.getInitContainers(); + const { selectedContainer, owner } = tabData; + const pods = model.pods.get(); + const pod = model.pod.get(); - const onContainerChange = (option: SelectOption) => { + if (!pod) { + return null; + } + + const onContainerChange = (option: SelectOption) => { model.updateLogTabData({ - selectedContainer: containers - .concat(initContainers) - .find(container => container.name === option.value), + selectedContainer: option.value, }); - model.reloadLogs(); }; - const onPodChange = (option: SelectOption) => { - const selectedPod = podsStore.getByName(option.value, pod.getNs()); - - model.updateLogTabData({ selectedPod }); - model.updateTabName(); - }; - - const getSelectOptions = (items: string[]) => { - return items.map(item => { - return { - value: item, - label: item, - }; + const onPodChange = ({ value }: SelectOption) => { + model.updateLogTabData({ + selectedPodId: value.getId(), + selectedContainer: value.getAllContainers()[0]?.name, }); + model.renameTab(`Pod ${value.getName()}`); + model.reloadLogs(); }; const containerSelectOptions = [ { - label: `Containers`, - options: getSelectOptions(containers.map(container => container.name)), + label: "Containers", + options: getSelectOptions(pod.getContainers()), }, { - label: `Init Containers`, - options: getSelectOptions(initContainers.map(container => container.name)), + label: "Init Containers", + options: getSelectOptions(pod.getInitContainers()), }, ]; - const podSelectOptions = [ - { - label: pod.getOwnerRefs()[0]?.name, - options: getSelectOptions(pods.map(pod => pod.metadata.name)), - }, - ]; - - useEffect(() => { - model.reloadLogs(); - }, [selectedPod]); + const podSelectOptions = pods.map(pod => ({ + label: pod.getName(), + value: pod, + })); return (
Namespace + { + owner && ( + <> + Owner + + ) + } Pod void; - toPrevOverlay: () => void; - toNextOverlay: () => void; + onSearch?: (query: string) => void; + scrollToOverlay: (lineNumber: number | undefined) => void; model: LogTabViewModel; } - -export const LogSearch = observer(({ onSearch, toPrevOverlay, toNextOverlay, model }: PodLogSearchProps) => { - const tabData = model.logTabData.get(); +export const LogSearch = observer(({ onSearch, scrollToOverlay, model: { logTabData, searchStore, ...model }}: PodLogSearchProps) => { + const tabData = logTabData.get(); if (!tabData) { return null; @@ -29,27 +27,23 @@ export const LogSearch = observer(({ onSearch, toPrevOverlay, toNextOverlay, mod const logs = tabData.showTimestamps ? model.logs.get() : model.logsWithoutTimestamps.get(); - const { setNextOverlayActive, setPrevOverlayActive, searchQuery, occurrences, activeFind, totalFinds } = model.searchStore; + const { setNextOverlayActive, setPrevOverlayActive, searchQuery, occurrences, activeFind, totalFinds } = searchStore; const jumpDisabled = !searchQuery || !occurrences.length; - const findCounts = ( -
- {activeFind}/{totalFinds} -
- ); const setSearch = (query: string) => { - model.searchStore.onSearch(logs, query); - onSearch(query); + searchStore.onSearch(logs, query); + onSearch?.(query); + scrollToOverlay(searchStore.activeOverlayLine); }; const onPrevOverlay = () => { setPrevOverlayActive(); - toPrevOverlay(); + scrollToOverlay(searchStore.activeOverlayLine); }; const onNextOverlay = () => { setNextOverlayActive(); - toNextOverlay(); + scrollToOverlay(searchStore.activeOverlayLine); }; const onClear = () => { @@ -58,13 +52,17 @@ export const LogSearch = observer(({ onSearch, toPrevOverlay, toNextOverlay, mod const onKeyDown = (evt: React.KeyboardEvent) => { if (evt.key === "Enter") { - onNextOverlay(); + if (evt.shiftKey) { + onPrevOverlay(); + } else { + onNextOverlay(); + } } }; useEffect(() => { // Refresh search when logs changed - model.searchStore.onSearch(logs); + searchStore.onSearch(logs); }, [logs]); return ( @@ -73,7 +71,11 @@ export const LogSearch = observer(({ onSearch, toPrevOverlay, toNextOverlay, mod value={searchQuery} onChange={setSearch} showClearIcon={true} - contentRight={totalFinds > 0 && findCounts} + contentRight={totalFinds > 0 && ( +
+ {activeFind}/{totalFinds} +
+ )} onClear={onClear} onKeyDown={onKeyDown} /> diff --git a/src/renderer/components/dock/logs/set-log-tab-data.injectable.ts b/src/renderer/components/dock/logs/set-log-tab-data.injectable.ts index ac783dfcc7..db84635bf1 100644 --- a/src/renderer/components/dock/logs/set-log-tab-data.injectable.ts +++ b/src/renderer/components/dock/logs/set-log-tab-data.injectable.ts @@ -3,10 +3,16 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import type { LogTabData } from "./tab-store"; import logTabStoreInjectable from "./tab-store.injectable"; const setLogTabDataInjectable = getInjectable({ - instantiate: (di) => di.inject(logTabStoreInjectable).setData, + instantiate: (di) => { + const logTabStore = di.inject(logTabStoreInjectable); + + return (tabId: string, data: LogTabData): void => logTabStore.setData(tabId, data); + }, + lifecycle: lifecycleEnum.singleton, }); diff --git a/src/renderer/components/dock/logs/stop-loading-logs.injectable.ts b/src/renderer/components/dock/logs/stop-loading-logs.injectable.ts index afe2542083..db0df464cf 100644 --- a/src/renderer/components/dock/logs/stop-loading-logs.injectable.ts +++ b/src/renderer/components/dock/logs/stop-loading-logs.injectable.ts @@ -6,7 +6,12 @@ import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; import logStoreInjectable from "./store.injectable"; const stopLoadingLogsInjectable = getInjectable({ - instantiate: (di) => di.inject(logStoreInjectable).stopLoadingLogs, + instantiate: (di) => { + const logStore = di.inject(logStoreInjectable); + + return (tabId: string): void => logStore.stopLoadingLogs(tabId); + }, + lifecycle: lifecycleEnum.singleton, }); diff --git a/src/renderer/components/dock/logs/store.ts b/src/renderer/components/dock/logs/store.ts index 7c0116252f..fb0c446119 100644 --- a/src/renderer/components/dock/logs/store.ts +++ b/src/renderer/components/dock/logs/store.ts @@ -3,12 +3,11 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import { computed, observable, makeObservable, IComputedValue } from "mobx"; - -import { IPodLogsQuery, Pod } from "../../../../common/k8s-api/endpoints"; -import { autoBind, getOrInsertWith, interval, IntervalFn } from "../../../utils"; -import type { TabId } from "../dock-store/dock.store"; -import type { LogTabData } from "./tab.store"; +import { observable, IComputedValue, when } from "mobx"; +import type { IPodLogsQuery, Pod } from "../../../../common/k8s-api/endpoints"; +import { getOrInsertWith, interval, IntervalFn } from "../../../utils"; +import type { TabId } from "../dock/store"; +import type { LogTabData } from "./tab-store"; type PodLogLine = string; @@ -19,15 +18,12 @@ interface Dependencies { } export class LogStore { - @observable protected podLogs = observable.map(); + protected podLogs = observable.map(); protected refreshers = new Map(); - constructor(private dependencies: Dependencies) { - makeObservable(this); - autoBind(this); - } + constructor(private dependencies: Dependencies) {} - handlerError(tabId: TabId, error: any): void { + protected handlerError(tabId: TabId, error: any): void { if (error.error && !(error.message || error.reason || error.code)) { error = error.error; } @@ -47,24 +43,24 @@ export class LogStore { * Also, it handles loading errors, rewriting whole logs with error * messages */ - load = async (tabId: TabId, logTabData: IComputedValue) => { + public async load(tabId: TabId, computedPod: IComputedValue, logTabData: IComputedValue): Promise { try { - const logs = await this.loadLogs(logTabData, { - tailLines: this.getLinesByTabId(tabId) + logLinesToLoad, + const logs = await this.loadLogs(computedPod, logTabData, { + tailLines: this.getLogLines(tabId) + logLinesToLoad, }); - this.getRefresher(tabId, logTabData).start(); + this.getRefresher(tabId, computedPod, logTabData).start(); this.podLogs.set(tabId, logs); } catch (error) { this.handlerError(tabId, error); } - }; + } - private getRefresher(tabId: TabId, logTabData: IComputedValue): IntervalFn { + private getRefresher(tabId: TabId, computedPod: IComputedValue, logTabData: IComputedValue): IntervalFn { return getOrInsertWith(this.refreshers, tabId, () => ( interval(10, () => { if (this.podLogs.has(tabId)) { - this.loadMore(tabId, logTabData); + this.loadMore(tabId, computedPod, logTabData); } }) )); @@ -84,14 +80,14 @@ export class LogStore { * starting from last line received. * @param tabId */ - loadMore = async (tabId: TabId, logTabData: IComputedValue) => { + public async loadMore(tabId: TabId, computedPod: IComputedValue, logTabData: IComputedValue): Promise { if (!this.podLogs.get(tabId).length) { return; } try { const oldLogs = this.podLogs.get(tabId); - const logs = await this.loadLogs(logTabData, { + const logs = await this.loadLogs(computedPod, logTabData, { sinceTime: this.getLastSinceTime(tabId), }); @@ -100,7 +96,7 @@ export class LogStore { } catch (error) { this.handlerError(tabId, error); } - }; + } /** * Main logs loading function adds necessary data to payload and makes @@ -109,17 +105,19 @@ export class LogStore { * @param params request parameters described in IPodLogsQuery interface * @returns A fetch request promise */ - private async loadLogs(logTabData: IComputedValue, params: Partial): Promise { - const { selectedContainer, previous, selectedPod } = logTabData.get(); - const pod = new Pod(selectedPod); + private async loadLogs(computedPod: IComputedValue, logTabData: IComputedValue, params: Partial): Promise { + await when(() => Boolean(computedPod.get() && logTabData.get()), { timeout: 5_000 }); + + const { selectedContainer, showPrevious } = logTabData.get(); + const pod = computedPod.get(); const namespace = pod.getNs(); const name = pod.getName(); const result = await this.dependencies.callForLogs({ namespace, name }, { ...params, timestamps: true, // Always setting timestamp to separate old logs from new ones - container: selectedContainer.name, - previous, + container: selectedContainer, + previous: showPrevious, }); return result.trimEnd().split("\n"); @@ -130,26 +128,29 @@ export class LogStore { * Converts logs into a string array * @returns Length of log lines */ - @computed get lines(): number { return this.logs.length; } - public getLinesByTabId = (tabId: TabId): number => { - return this.getLogsByTabId(tabId).length; - }; + getLogLines(tabId: TabId): number{ + return this.getLogs(tabId).length; + } - public getLogsByTabId = (tabId: TabId): string[] => { + areLogsPresent(tabId: TabId): boolean { + return !this.podLogs.has(tabId); + } + + getLogs(tabId: TabId): string[]{ return this.podLogs.get(tabId) ?? []; - }; + } - public getLogsWithoutTimestampsByTabId = (tabId: TabId): string[] => { - return this.getLogsByTabId(tabId).map(this.removeTimestamps); - }; + getLogsWithoutTimestamps(tabId: TabId): string[]{ + return this.getLogs(tabId).map(this.removeTimestamps); + } - public getTimestampSplitLogsByTabId = (tabId: TabId): [string, string][] => { - return this.getLogsByTabId(tabId).map(this.splitOutTimestamp); - }; + getTimestampSplitLogs(tabId: TabId): [string, string][]{ + return this.getLogs(tabId).map(this.splitOutTimestamp); + } /** * @deprecated This now only returns the empty array @@ -173,7 +174,7 @@ export class LogStore { * (this allows to avoid getting the last stamp in the selection) * @param tabId */ - getLastSinceTime(tabId: TabId) { + getLastSinceTime(tabId: TabId): string { const logs = this.podLogs.get(tabId); const timestamps = this.getTimestamps(logs[logs.length - 1]); const stamp = new Date(timestamps ? timestamps[0] : null); @@ -183,7 +184,7 @@ export class LogStore { return stamp.toISOString(); } - splitOutTimestamp = (logs: string): [string, string] => { + splitOutTimestamp(logs: string): [string, string] { const extraction = /^(\d+\S+)(.*)/m.exec(logs); if (!extraction || extraction.length < 3) { @@ -191,23 +192,23 @@ export class LogStore { } return [extraction[1], extraction[2]]; - }; + } - getTimestamps(logs: string) { + getTimestamps(logs: string): RegExpMatchArray { return logs.match(/^\d+\S+/gm); } - removeTimestamps = (logs: string) => { + removeTimestamps(logs: string): string { return logs.replace(/^\d+.*?\s/gm, ""); - }; + } - clearLogs(tabId: TabId) { + clearLogs(tabId: TabId): void { this.podLogs.delete(tabId); } - reload = (tabId: TabId, logTabData: IComputedValue) => { + reload(tabId: TabId, computedPod: IComputedValue, logTabData: IComputedValue): Promise { this.clearLogs(tabId); - return this.load(tabId, logTabData); - }; + return this.load(tabId, computedPod, logTabData); + } } diff --git a/src/renderer/components/dock/logs/tab-store.injectable.ts b/src/renderer/components/dock/logs/tab-store.injectable.ts index 2ccc2067dc..61d4667f55 100644 --- a/src/renderer/components/dock/logs/tab-store.injectable.ts +++ b/src/renderer/components/dock/logs/tab-store.injectable.ts @@ -3,13 +3,11 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; -import { LogTabStore } from "./tab.store"; -import dockStoreInjectable from "../dock-store/dock-store.injectable"; +import { LogTabStore } from "./tab-store"; import createStorageInjectable from "../../../utils/create-storage/create-storage.injectable"; const logTabStoreInjectable = getInjectable({ instantiate: (di) => new LogTabStore({ - dockStore: di.inject(dockStoreInjectable), createStorage: di.inject(createStorageInjectable), }), diff --git a/src/renderer/components/dock/logs/tab-store.ts b/src/renderer/components/dock/logs/tab-store.ts new file mode 100644 index 0000000000..b2649472e0 --- /dev/null +++ b/src/renderer/components/dock/logs/tab-store.ts @@ -0,0 +1,81 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { DockTabStorageState, DockTabStore } from "../dock-tab-store/dock-tab.store"; +import type { StorageHelper } from "../../../utils"; +import type { TabId } from "../dock/store"; +import { logTabDataValidator } from "./log-tab-data.validator"; + +export interface LogTabOwnerRef { + /** + * The uid of the owner + */ + uid: string; + /** + * The name of the owner + */ + name: string; + /** + * The kind of the owner + */ + kind: string; +} + +export interface LogTabData { + /** + * The owning workload for this logging tab + */ + owner?: LogTabOwnerRef; + + /** + * The uid of the currently selected pod + */ + selectedPodId: string; + + /** + * The namespace of the pods/workload + */ + namespace: string; + + /** + * The name of the currently selected container within the currently selected + * pod + */ + selectedContainer: string; + + /** + * Whether to show timestamps in the logs + */ + showTimestamps: boolean; + + /** + * Whether to show the logs of the previous container instance + */ + showPrevious: boolean; +} + +interface Dependencies { + createStorage: (storageKey: string, options: DockTabStorageState) => StorageHelper> +} + +export class LogTabStore extends DockTabStore { + constructor(protected dependencies: Dependencies) { + super(dependencies, { + storageKey: "pod_logs", + }); + } + + /** + * Returns true if the data for `tabId` is valid + */ + isDataValid(tabId: TabId): boolean { + if (!this.getData(tabId)) { + return true; + } + + return !logTabDataValidator.validate(this.getData(tabId)).error; + } +} + diff --git a/src/renderer/components/dock/logs/tab.store.ts b/src/renderer/components/dock/logs/tab.store.ts deleted file mode 100644 index 83310f740e..0000000000 --- a/src/renderer/components/dock/logs/tab.store.ts +++ /dev/null @@ -1,139 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import uniqueId from "lodash/uniqueId"; -import { reaction } from "mobx"; -import { podsStore } from "../../+workloads-pods/pods.store"; - -import { IPodContainer, Pod } from "../../../../common/k8s-api/endpoints"; -import type { WorkloadKubeObject } from "../../../../common/k8s-api/workload-kube-object"; -import logger from "../../../../common/logger"; -import { DockTabStorageState, DockTabStore } from "../dock-tab-store/dock-tab.store"; -import { DockStore, DockTabCreateSpecific, TabKind } from "../dock-store/dock.store"; -import type { StorageHelper } from "../../../utils"; - -export interface LogTabData { - pods: Pod[]; - selectedPod: Pod; - selectedContainer: IPodContainer - showTimestamps?: boolean - previous?: boolean -} - -interface PodLogsTabData { - selectedPod: Pod - selectedContainer: IPodContainer -} - -interface WorkloadLogsTabData { - workload: WorkloadKubeObject -} - -interface Dependencies { - dockStore: DockStore - createStorage: (storageKey: string, options: DockTabStorageState) => StorageHelper> -} - -export class LogTabStore extends DockTabStore { - constructor(protected dependencies: Dependencies) { - super(dependencies, { - storageKey: "pod_logs", - }); - - reaction(() => podsStore.items.length, () => this.updateTabsData()); - } - - createPodTab({ selectedPod, selectedContainer }: PodLogsTabData): string { - const podOwner = selectedPod.getOwnerRefs()[0]; - const pods = podsStore.getPodsByOwnerId(podOwner?.uid); - const title = `Pod ${selectedPod.getName()}`; - - return this.createLogsTab(title, { - pods: pods.length ? pods : [selectedPod], - selectedPod, - selectedContainer, - }); - } - - createWorkloadTab({ workload }: WorkloadLogsTabData): void { - const pods = podsStore.getPodsByOwnerId(workload.getId()); - - if (!pods.length) return; - - const selectedPod = pods[0]; - const selectedContainer = selectedPod.getAllContainers()[0]; - const title = `${workload.kind} ${selectedPod.getName()}`; - - this.createLogsTab(title, { - pods, - selectedPod, - selectedContainer, - }); - } - - updateTabName(tabId: string) { - const { selectedPod } = this.getData(tabId); - - this.dependencies.dockStore.renameTab(tabId, `Pod ${selectedPod.metadata.name}`); - } - - private createDockTab(tabParams: DockTabCreateSpecific) { - this.dependencies.dockStore.createTab({ - ...tabParams, - kind: TabKind.POD_LOGS, - }, false); - } - - private createLogsTab(title: string, data: LogTabData): string { - const id = uniqueId("log-tab-"); - - this.createDockTab({ id, title }); - this.setData(id, { - ...data, - showTimestamps: false, - previous: false, - }); - - return id; - } - - private updateTabsData() { - for (const [tabId, tabData] of this.data) { - try { - if (!tabData.selectedPod) { - tabData.selectedPod = tabData.pods[0]; - } - - const pod = new Pod(tabData.selectedPod); - const pods = podsStore.getPodsByOwnerId(pod.getOwnerRefs()[0]?.uid); - const isSelectedPodInList = pods.find(item => item.getId() == pod.getId()); - const selectedPod = isSelectedPodInList ? pod : pods[0]; - const selectedContainer = isSelectedPodInList ? tabData.selectedContainer : pod.getAllContainers()[0]; - - if (pods.length > 0) { - this.setData(tabId, { - ...tabData, - selectedPod, - selectedContainer, - pods, - }); - - this.updateTabName(tabId); - } else { - this.closeTab(tabId); - } - } catch (error) { - logger.error(`[LOG-TAB-STORE]: failed to set data for tabId=${tabId} deleting`, error); - this.data.delete(tabId); - } - } - } - - private closeTab(tabId: string) { - this.clearData(tabId); - this.dependencies.dockStore.closeTab(tabId); - } -} - diff --git a/src/renderer/components/dock/logs/view.tsx b/src/renderer/components/dock/logs/view.tsx new file mode 100644 index 0000000000..c73933b12f --- /dev/null +++ b/src/renderer/components/dock/logs/view.tsx @@ -0,0 +1,101 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import React, { createRef, useEffect } from "react"; +import { observer } from "mobx-react"; +import { InfoPanel } from "../info-panel"; +import { LogResourceSelector } from "./resource-selector"; +import { LogList } from "./list"; +import { LogSearch } from "./search"; +import { LogControls } from "./controls"; +import { withInjectables } from "@ogre-tools/injectable-react"; +import logsViewModelInjectable from "./logs-view-model.injectable"; +import type { LogTabViewModel } from "./logs-view-model"; +import type { DockTab } from "../dock/store"; +import { cssNames, Disposer } from "../../../utils"; +import type { KubeWatchSubscribeStoreOptions } from "../../../kube-watch-api/kube-watch-api"; +import subscribeStoresInjectable from "../../../kube-watch-api/subscribe-stores.injectable"; +import type { KubeObjectStore } from "../../../../common/k8s-api/kube-object.store"; +import type { KubeObject } from "../../../../common/k8s-api/kube-object"; +import { podsStore } from "../../+workloads-pods/pods.store"; + +export interface LogsDockTabProps { + className?: string; + tab: DockTab; +} + +interface Dependencies { + model: LogTabViewModel; + subscribeStores: (stores: KubeObjectStore[], opts?: KubeWatchSubscribeStoreOptions) => Disposer; +} + +const NonInjectedLogsDockTab = observer(({ className, tab, model, subscribeStores }: Dependencies & LogsDockTabProps) => { + const logListElement = createRef(); + const data = model.logTabData.get(); + + useEffect(() => { + model.reloadLogs(); + + return model.stopLoadingLogs; + }, []); + useEffect(() => subscribeStores([ + podsStore, + ], { + namespaces: data ? [data.namespace] : [], + }), [data?.namespace]); + + const scrollToOverlay = (overlayLine: number | undefined) => { + if (!logListElement.current || overlayLine === undefined) { + return; + } + + // Scroll vertically + logListElement.current.scrollToItem(overlayLine, "center"); + // Scroll horizontally in timeout since virtual list need some time to prepare its contents + setTimeout(() => { + const overlay = document.querySelector(".PodLogs .list span.active"); + + if (!overlay) return; + overlay.scrollIntoViewIfNeeded(); + }, 100); + }; + + if (!data) { + return null; + } + + return ( +
+ + + +
+ )} + showSubmitClose={false} + showButtons={false} + showStatusPanel={false} + /> + + +
+ ); +}); + + +export const LogsDockTab = withInjectables(NonInjectedLogsDockTab, { + getProps: (di, props) => ({ + model: di.inject(logsViewModelInjectable, { + tabId: props.tab.id, + }), + subscribeStores: di.inject(subscribeStoresInjectable), + ...props, + }), +}); diff --git a/src/renderer/components/dock/terminal-store/terminal.store.ts b/src/renderer/components/dock/terminal-store/terminal.store.ts deleted file mode 100644 index 9203edb87b..0000000000 --- a/src/renderer/components/dock/terminal-store/terminal.store.ts +++ /dev/null @@ -1,152 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import { autorun, observable, when } from "mobx"; -import { autoBind, noop } from "../../../utils"; -import type { Terminal } from "../terminal/terminal"; -import { TerminalApi, TerminalChannels } from "../../../api/terminal-api"; -import { - DockStore, - DockTab, - DockTabCreate, - TabId, - TabKind, -} from "../dock-store/dock.store"; -import { WebSocketApiState } from "../../../api/websocket-api"; -import { Notifications } from "../../notifications"; - -export interface ITerminalTab extends DockTab { - node?: string; // activate node shell mode -} - -interface Dependencies { - createTerminalTab: () => DockTabCreate - dockStore: DockStore - createTerminal: (tabId: TabId, api: TerminalApi) => Terminal -} - -export class TerminalStore { - protected terminals = new Map(); - protected connections = observable.map(); - - constructor(private dependencies: Dependencies) { - autoBind(this); - - // connect active tab - autorun(() => { - const { selectedTab, isOpen } = dependencies.dockStore; - - if (selectedTab?.kind === TabKind.TERMINAL && isOpen) { - this.connect(selectedTab.id); - } - }); - // disconnect closed tabs - autorun(() => { - const currentTabs = dependencies.dockStore.tabs.map(tab => tab.id); - - for (const [tabId] of this.connections) { - if (!currentTabs.includes(tabId)) this.disconnect(tabId); - } - }); - } - - connect(tabId: TabId) { - if (this.isConnected(tabId)) { - return; - } - const tab: ITerminalTab = this.dependencies.dockStore.getTabById(tabId); - const api = new TerminalApi({ - id: tabId, - node: tab.node, - }); - const terminal = this.dependencies.createTerminal(tabId, api); - - this.connections.set(tabId, api); - this.terminals.set(tabId, terminal); - - api.connect(); - } - - disconnect(tabId: TabId) { - if (!this.isConnected(tabId)) { - return; - } - const terminal = this.terminals.get(tabId); - const terminalApi = this.connections.get(tabId); - - terminal.destroy(); - terminalApi.destroy(); - this.connections.delete(tabId); - this.terminals.delete(tabId); - } - - reconnect(tabId: TabId) { - this.connections.get(tabId)?.connect(); - } - - isConnected(tabId: TabId) { - return Boolean(this.connections.get(tabId)); - } - - isDisconnected(tabId: TabId) { - return this.connections.get(tabId)?.readyState === WebSocketApiState.CLOSED; - } - - async sendCommand(command: string, options: { enter?: boolean; newTab?: boolean; tabId?: TabId } = {}) { - const { enter, newTab, tabId } = options; - - if (tabId) { - this.dependencies.dockStore.selectTab(tabId); - } - - if (newTab) { - const tab = this.dependencies.createTerminalTab(); - - await when(() => this.connections.has(tab.id)); - - const shellIsReady = when(() => this.connections.get(tab.id).isReady); - const notifyVeryLong = setTimeout(() => { - shellIsReady.cancel(); - Notifications.info( - "If terminal shell is not ready please check your shell init files, if applicable.", - { - timeout: 4_000, - }, - ); - }, 10_000); - - await shellIsReady.catch(noop); - clearTimeout(notifyVeryLong); - } - - const terminalApi = this.connections.get(this.dependencies.dockStore.selectedTabId); - - if (terminalApi) { - if (enter) { - command += "\r"; - } - - terminalApi.sendMessage({ - type: TerminalChannels.STDIN, - data: command, - }); - } else { - console.warn( - "The selected tab is does not have a connection. Cannot send command.", - { tabId: this.dependencies.dockStore.selectedTabId, command }, - ); - } - } - - getTerminal(tabId: TabId) { - return this.terminals.get(tabId); - } - - reset() { - [...this.connections].forEach(([tabId]) => { - this.disconnect(tabId); - }); - } -} diff --git a/src/renderer/components/dock/terminal-tab.scss b/src/renderer/components/dock/terminal-tab.scss deleted file mode 100644 index faa0f5a7f3..0000000000 --- a/src/renderer/components/dock/terminal-tab.scss +++ /dev/null @@ -1,8 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -.TerminalTab { - -} diff --git a/src/renderer/components/dock/terminal-window.tsx b/src/renderer/components/dock/terminal-window.tsx deleted file mode 100644 index 5d76f69543..0000000000 --- a/src/renderer/components/dock/terminal-window.tsx +++ /dev/null @@ -1,74 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import "./terminal-window.scss"; - -import React from "react"; -import { disposeOnUnmount, observer } from "mobx-react"; -import { cssNames } from "../../utils"; -import type { Terminal } from "./terminal/terminal"; -import type { TerminalStore } from "./terminal-store/terminal.store"; -import { ThemeStore } from "../../theme.store"; -import { DockTab, TabKind, TabId, DockStore } from "./dock-store/dock.store"; -import { withInjectables } from "@ogre-tools/injectable-react"; -import dockStoreInjectable from "./dock-store/dock-store.injectable"; -import terminalStoreInjectable from "./terminal-store/terminal-store.injectable"; - -interface Props { - tab: DockTab; -} - -interface Dependencies { - dockStore: DockStore - terminalStore: TerminalStore -} - -@observer -class NonInjectedTerminalWindow extends React.Component { - public elem: HTMLElement; - public terminal: Terminal; - - componentDidMount() { - disposeOnUnmount(this, [ - this.props.dockStore.onTabChange(({ tabId }) => this.activate(tabId), { - tabKind: TabKind.TERMINAL, - fireImmediately: true, - }), - - // refresh terminal available space (cols/rows) when resized - this.props.dockStore.onResize(() => this.terminal?.fitLazy(), { - fireImmediately: true, - }), - ]); - } - - activate(tabId: TabId) { - this.terminal?.detach(); // detach previous - this.terminal = this.props.terminalStore.getTerminal(tabId); - this.terminal.attachTo(this.elem); - } - - render() { - return ( -
this.elem = elem} - /> - ); - } -} - -export const TerminalWindow = withInjectables( - NonInjectedTerminalWindow, - - { - getProps: (di, props) => ({ - dockStore: di.inject(dockStoreInjectable), - terminalStore: di.inject(terminalStoreInjectable), - ...props, - }), - }, -); - diff --git a/src/renderer/components/dock/terminal/clear-terminal-tab-data.injectable.ts b/src/renderer/components/dock/terminal/clear-terminal-tab-data.injectable.ts new file mode 100644 index 0000000000..4396e3ee20 --- /dev/null +++ b/src/renderer/components/dock/terminal/clear-terminal-tab-data.injectable.ts @@ -0,0 +1,21 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import type { TabId } from "../dock/store"; +import terminalStoreInjectable from "./store.injectable"; + +const clearTerminalTabDataInjectable = getInjectable({ + instantiate: (di) => { + const terminalStore = di.inject(terminalStoreInjectable); + + return (tabId: TabId): void => { + terminalStore.destroy(tabId); + }; + }, + + lifecycle: lifecycleEnum.singleton, +}); + +export default clearTerminalTabDataInjectable; diff --git a/src/renderer/components/dock/terminal/create-terminal-tab.injectable.ts b/src/renderer/components/dock/terminal/create-terminal-tab.injectable.ts new file mode 100644 index 0000000000..9348bd49ef --- /dev/null +++ b/src/renderer/components/dock/terminal/create-terminal-tab.injectable.ts @@ -0,0 +1,24 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import dockStoreInjectable from "../dock/store.injectable"; +import { DockTabCreateSpecific, TabKind } from "../dock/store"; + +const createTerminalTabInjectable = getInjectable({ + instantiate: (di) => { + const dockStore = di.inject(dockStoreInjectable); + + return (tabParams: DockTabCreateSpecific = {}) => + dockStore.createTab({ + title: `Terminal`, + ...tabParams, + kind: TabKind.TERMINAL, + }); + }, + + lifecycle: lifecycleEnum.singleton, +}); + +export default createTerminalTabInjectable; diff --git a/src/renderer/components/dock/terminal/create-terminal.injectable.ts b/src/renderer/components/dock/terminal/create-terminal.injectable.ts index b52b24d13f..2dc6f04941 100644 --- a/src/renderer/components/dock/terminal/create-terminal.injectable.ts +++ b/src/renderer/components/dock/terminal/create-terminal.injectable.ts @@ -4,20 +4,11 @@ */ import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; import { Terminal } from "./terminal"; -import type { TabId } from "../dock-store/dock.store"; +import type { TabId } from "../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); - }, - + instantiate: () => (tabId: TabId, api: TerminalApi) => new Terminal(tabId, api), lifecycle: lifecycleEnum.singleton, }); diff --git a/src/renderer/components/dock/terminal-tab.tsx b/src/renderer/components/dock/terminal/dock-tab.tsx similarity index 70% rename from src/renderer/components/dock/terminal-tab.tsx rename to src/renderer/components/dock/terminal/dock-tab.tsx index a633ffb062..2fc9d436d2 100644 --- a/src/renderer/components/dock/terminal-tab.tsx +++ b/src/renderer/components/dock/terminal/dock-tab.tsx @@ -3,19 +3,17 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import "./terminal-tab.scss"; - import React from "react"; import { observer } from "mobx-react"; -import { boundMethod, cssNames } from "../../utils"; -import { DockTab, DockTabProps } from "./dock-tab"; -import { Icon } from "../icon"; -import type { TerminalStore } from "./terminal-store/terminal.store"; -import type { DockStore } from "./dock-store/dock.store"; +import { boundMethod, cssNames } from "../../../utils"; +import { DockTab, DockTabProps } from "../dock-tab"; +import { Icon } from "../../icon"; +import type { TerminalStore } from "./store"; +import type { DockStore } from "../dock/store"; import { reaction } from "mobx"; import { withInjectables } from "@ogre-tools/injectable-react"; -import dockStoreInjectable from "./dock-store/dock-store.injectable"; -import terminalStoreInjectable from "./terminal-store/terminal-store.injectable"; +import dockStoreInjectable from "../dock/store.injectable"; +import terminalStoreInjectable from "./store.injectable"; interface Props extends DockTabProps { } @@ -73,15 +71,11 @@ class NonInjectedTerminalTab extends React.Component { } } -export const TerminalTab = withInjectables( - NonInjectedTerminalTab, - - { - getProps: (di, props) => ({ - dockStore: di.inject(dockStoreInjectable), - terminalStore: di.inject(terminalStoreInjectable), - ...props, - }), - }, -); +export const TerminalTab = withInjectables(NonInjectedTerminalTab, { + getProps: (di, props) => ({ + dockStore: di.inject(dockStoreInjectable), + terminalStore: di.inject(terminalStoreInjectable), + ...props, + }), +}); diff --git a/src/renderer/components/dock/terminal/get-terminal-api.injectable.ts b/src/renderer/components/dock/terminal/get-terminal-api.injectable.ts new file mode 100644 index 0000000000..7724380c50 --- /dev/null +++ b/src/renderer/components/dock/terminal/get-terminal-api.injectable.ts @@ -0,0 +1,20 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import type { TerminalApi } from "../../../api/terminal-api"; +import type { TabId } from "../dock/store"; +import terminalStoreInjectable from "./store.injectable"; + +const getTerminalApiInjectable = getInjectable({ + instantiate: (di) => { + const terminalStore = di.inject(terminalStoreInjectable); + + return (tabId: TabId): TerminalApi => terminalStore.getTerminalApi(tabId); + }, + + lifecycle: lifecycleEnum.singleton, +}); + +export default getTerminalApiInjectable; diff --git a/src/renderer/components/dock/terminal/send-command.injectable.ts b/src/renderer/components/dock/terminal/send-command.injectable.ts new file mode 100644 index 0000000000..6e6b970b21 --- /dev/null +++ b/src/renderer/components/dock/terminal/send-command.injectable.ts @@ -0,0 +1,91 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { when } from "mobx"; +import { TerminalApi, TerminalChannels } from "../../../api/terminal-api"; +import { noop } from "../../../utils"; +import { Notifications } from "../../notifications"; +import selectDockTabInjectable from "../dock/select-dock-tab.injectable"; +import type { DockTab, TabId } from "../dock/store"; +import createTerminalTabInjectable from "./create-terminal-tab.injectable"; +import getTerminalApiInjectable from "./get-terminal-api.injectable"; + +interface Dependencies { + selectTab: (tabId: TabId) => void; + createTerminalTab: () => DockTab; + getTerminalApi: (tabId: TabId) => TerminalApi; +} + +export interface SendCommandOptions { + /** + * Emit an enter after the command + */ + enter?: boolean; + + /** + * @deprecated This option is ignored and infered to be `true` if `tabId` is not provided + */ + newTab?: any; + + /** + * Specify a specific terminal tab to send this command to + */ + tabId?: TabId; +} + +const sendCommand = ({ selectTab, createTerminalTab, getTerminalApi }: Dependencies) => async (command: string, options: SendCommandOptions = {}): Promise => { + let { tabId } = options; + + if (tabId) { + selectTab(tabId); + } else { + tabId = createTerminalTab().id; + } + + await when(() => Boolean(getTerminalApi(tabId))); + + const terminalApi = getTerminalApi(tabId); + const shellIsReady = when(() =>terminalApi.isReady); + const notifyVeryLong = setTimeout(() => { + shellIsReady.cancel(); + Notifications.info( + "If terminal shell is not ready please check your shell init files, if applicable.", + { + timeout: 4_000, + }, + ); + }, 10_000); + + await shellIsReady.catch(noop); + clearTimeout(notifyVeryLong); + + if (terminalApi) { + if (options.enter) { + command += "\r"; + } + + terminalApi.sendMessage({ + type: TerminalChannels.STDIN, + data: command, + }); + } else { + console.warn( + "The selected tab is does not have a connection. Cannot send command.", + { tabId, command }, + ); + } +}; + +const sendCommandInjectable = getInjectable({ + instantiate: (di) => sendCommand({ + createTerminalTab: di.inject(createTerminalTabInjectable), + selectTab: di.inject(selectDockTabInjectable), + getTerminalApi: di.inject(getTerminalApiInjectable), + }), + + lifecycle: lifecycleEnum.singleton, +}); + +export default sendCommandInjectable; diff --git a/src/renderer/components/dock/terminal-store/terminal-store.injectable.ts b/src/renderer/components/dock/terminal/store.injectable.ts similarity index 52% rename from src/renderer/components/dock/terminal-store/terminal-store.injectable.ts rename to src/renderer/components/dock/terminal/store.injectable.ts index 6e0990b31e..06c4c4d7f4 100644 --- a/src/renderer/components/dock/terminal-store/terminal-store.injectable.ts +++ b/src/renderer/components/dock/terminal/store.injectable.ts @@ -3,15 +3,11 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; -import { TerminalStore } from "./terminal.store"; -import createTerminalTabInjectable from "../create-terminal-tab/create-terminal-tab.injectable"; -import dockStoreInjectable from "../dock-store/dock-store.injectable"; -import createTerminalInjectable from "../terminal/create-terminal.injectable"; +import { TerminalStore } from "./store"; +import createTerminalInjectable from "./create-terminal.injectable"; const terminalStoreInjectable = getInjectable({ instantiate: (di) => new TerminalStore({ - createTerminalTab: di.inject(createTerminalTabInjectable), - dockStore: di.inject(dockStoreInjectable), createTerminal: di.inject(createTerminalInjectable), }), diff --git a/src/renderer/components/dock/terminal/store.ts b/src/renderer/components/dock/terminal/store.ts new file mode 100644 index 0000000000..a54dccb820 --- /dev/null +++ b/src/renderer/components/dock/terminal/store.ts @@ -0,0 +1,87 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { action, observable } from "mobx"; +import type { Terminal } from "./terminal"; +import { TerminalApi } from "../../../api/terminal-api"; +import type { DockTab, TabId } from "../dock/store"; +import { WebSocketApiState } from "../../../api/websocket-api"; + +export interface ITerminalTab extends DockTab { + node?: string; // activate node shell mode +} + +interface Dependencies { + createTerminal: (tabId: TabId, api: TerminalApi) => Terminal; +} + +export class TerminalStore { + protected terminals = new Map(); + protected connections = observable.map(); + + constructor(private dependencies: Dependencies) { + } + + @action + connect(tab: ITerminalTab) { + if (this.isConnected(tab.id)) { + return; + } + const api = new TerminalApi({ + id: tab.id, + node: tab.node, + }); + const terminal = this.dependencies.createTerminal(tab.id, api); + + this.connections.set(tab.id, api); + this.terminals.set(tab.id, terminal); + + api.connect(); + } + + @action + destroy(tabId: TabId) { + const terminal = this.terminals.get(tabId); + const terminalApi = this.connections.get(tabId); + + terminal?.destroy(); + terminalApi?.destroy(); + this.connections.delete(tabId); + this.terminals.delete(tabId); + } + + /** + * @deprecated use `this.destroy()` instead + */ + disconnect(tabId: TabId) { + this.destroy(tabId); + } + + reconnect(tabId: TabId) { + this.connections.get(tabId)?.connect(); + } + + isConnected(tabId: TabId) { + return Boolean(this.connections.get(tabId)); + } + + isDisconnected(tabId: TabId) { + return this.connections.get(tabId)?.readyState === WebSocketApiState.CLOSED; + } + + getTerminal(tabId: TabId) { + return this.terminals.get(tabId); + } + + getTerminalApi(tabId: TabId) { + return this.connections.get(tabId); + } + + reset() { + [...this.connections].forEach(([tabId]) => { + this.destroy(tabId); + }); + } +} diff --git a/src/renderer/components/dock/terminal-window.scss b/src/renderer/components/dock/terminal/terminal-window.scss similarity index 100% rename from src/renderer/components/dock/terminal-window.scss rename to src/renderer/components/dock/terminal/terminal-window.scss diff --git a/src/renderer/components/dock/terminal/terminal.ts b/src/renderer/components/dock/terminal/terminal.ts index 718c855b97..022f71f8aa 100644 --- a/src/renderer/components/dock/terminal/terminal.ts +++ b/src/renderer/components/dock/terminal/terminal.ts @@ -7,7 +7,7 @@ import debounce from "lodash/debounce"; import { reaction } from "mobx"; import { Terminal as XTerm } from "xterm"; import { FitAddon } from "xterm-addon-fit"; -import type { DockStore, TabId } from "../dock-store/dock.store"; +import type { TabId } from "../dock/store"; import { TerminalApi, TerminalChannels } from "../../../api/terminal-api"; import { ThemeStore } from "../../../theme.store"; import { disposer } from "../../../utils"; @@ -18,12 +18,7 @@ import { clipboard } from "electron"; import logger from "../../../../common/logger"; import type { TerminalConfig } from "../../../../common/user-store/preferences-helpers"; -interface Dependencies { - dockStore: DockStore -} - export class Terminal { - private terminalConfig: TerminalConfig = UserStore.getInstance().terminalConfig; public static get spawningPool() { @@ -56,12 +51,6 @@ export class Terminal { return this.xterm.element.querySelector(".xterm-viewport"); } - get isActive() { - const { isOpen, selectedTabId } = this.dependencies.dockStore; - - return isOpen && selectedTabId === this.tabId; - } - attachTo(parentElem: HTMLElement) { parentElem.appendChild(this.elem); this.onActivate(); @@ -75,7 +64,7 @@ export class Terminal { } } - constructor(private dependencies: Dependencies, public tabId: TabId, protected api: TerminalApi) { + constructor(public tabId: TabId, protected api: TerminalApi) { // enable terminal addons this.xterm.loadAddon(this.fitAddon); @@ -107,7 +96,6 @@ export class Terminal { reaction(() => UserStore.getInstance().terminalConfig.fontFamily, this.setFontFamily, { fireImmediately: true, }), - dependencies.dockStore.onResize(this.onResize), () => onDataHandler.dispose(), () => this.fitAddon.dispose(), () => this.api.removeAllListeners(), @@ -126,7 +114,7 @@ export class Terminal { fit = () => { // Since this function is debounced we need to read this value as late as possible - if (!this.isActive || !this.xterm) { + if (!this.xterm) { return; } diff --git a/src/renderer/components/dock/terminal/view.tsx b/src/renderer/components/dock/terminal/view.tsx new file mode 100644 index 0000000000..f1e0279539 --- /dev/null +++ b/src/renderer/components/dock/terminal/view.tsx @@ -0,0 +1,74 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import "./terminal-window.scss"; + +import React from "react"; +import { disposeOnUnmount, observer } from "mobx-react"; +import { cssNames } from "../../../utils"; +import type { Terminal } from "./terminal"; +import type { TerminalStore } from "./store"; +import { ThemeStore } from "../../../theme.store"; +import type { DockTab, DockStore } from "../dock/store"; +import { withInjectables } from "@ogre-tools/injectable-react"; +import dockStoreInjectable from "../dock/store.injectable"; +import terminalStoreInjectable from "./store.injectable"; + +interface Props { + tab: DockTab; +} + +interface Dependencies { + dockStore: DockStore; + terminalStore: TerminalStore; +} + +@observer +class NonInjectedTerminalWindow extends React.Component { + public elem: HTMLElement; + public terminal: Terminal; + + componentDidMount() { + this.props.terminalStore.connect(this.props.tab); + this.terminal = this.props.terminalStore.getTerminal(this.props.tab.id); + this.terminal.attachTo(this.elem); + + disposeOnUnmount(this, [ + // refresh terminal available space (cols/rows) when resized + this.props.dockStore.onResize(() => this.terminal.onResize(), { + fireImmediately: true, + }), + ]); + } + + componentDidUpdate(): void { + this.terminal.detach(); + this.props.terminalStore.connect(this.props.tab); + this.terminal = this.props.terminalStore.getTerminal(this.props.tab.id); + this.terminal.attachTo(this.elem); + } + + componentWillUnmount(): void { + this.terminal.detach(); + } + + render() { + return ( +
this.elem = elem} + /> + ); + } +} + +export const TerminalWindow = withInjectables(NonInjectedTerminalWindow, { + getProps: (di, props) => ({ + dockStore: di.inject(dockStoreInjectable), + terminalStore: di.inject(terminalStoreInjectable), + ...props, + }), +}); + diff --git a/src/renderer/components/dock/upgrade-chart-store/upgrade-chart.store.ts b/src/renderer/components/dock/upgrade-chart-store/upgrade-chart.store.ts deleted file mode 100644 index f6e6a2dde2..0000000000 --- a/src/renderer/components/dock/upgrade-chart-store/upgrade-chart.store.ts +++ /dev/null @@ -1,128 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import { - action, - autorun, - computed, - IReactionDisposer, - reaction, - makeObservable, -} from "mobx"; -import { DockStore, DockTab, TabId, TabKind } from "../dock-store/dock.store"; -import { DockTabStorageState, DockTabStore } from "../dock-tab-store/dock-tab.store"; -import { - getReleaseValues, - HelmRelease, -} from "../../../../common/k8s-api/endpoints/helm-releases.api"; -import { iter, StorageHelper } from "../../../utils"; -import type { IAsyncComputed } from "@ogre-tools/injectable-react"; - -export interface IChartUpgradeData { - releaseName: string; - releaseNamespace: string; -} - -interface Dependencies { - releases: IAsyncComputed - valuesStore: DockTabStore - dockStore: DockStore - createStorage: (storageKey: string, options: DockTabStorageState) => StorageHelper> -} - -export class UpgradeChartStore extends DockTabStore { - private watchers = new Map(); - - @computed private get releaseNameReverseLookup(): Map { - return new Map(iter.map(this.data, ([id, { releaseName }]) => [releaseName, id])); - } - - get values() { - return this.dependencies.valuesStore; - } - - constructor(protected dependencies : Dependencies) { - super(dependencies, { - storageKey: "chart_releases", - }); - - makeObservable(this); - - autorun(() => { - const { selectedTab, isOpen } = dependencies.dockStore; - - if (selectedTab?.kind === TabKind.UPGRADE_CHART && isOpen) { - this.loadData(selectedTab.id); - } - }, { delay: 250 }); - - autorun(() => { - const objects = [...this.data.values()]; - - objects.forEach(({ releaseName }) => this.createReleaseWatcher(releaseName)); - }); - } - - private createReleaseWatcher(releaseName: string) { - if (this.watchers.get(releaseName)) { - return; - } - const dispose = reaction(() => { - const release = this.dependencies.releases.value.get().find(release => release.getName() === releaseName); - - return release?.getRevision(); // watch changes only by revision - }, - release => { - const releaseTab = this.getTabByRelease(releaseName); - - if (!releaseTab) { - return; - } - - // auto-reload values if was loaded before - if (release) { - if (this.dependencies.dockStore.selectedTab === releaseTab && this.values.getData(releaseTab.id)) { - this.loadValues(releaseTab.id); - } - } - // clean up watcher, close tab if release not exists / was removed - else { - dispose(); - this.watchers.delete(releaseName); - this.dependencies.dockStore.closeTab(releaseTab.id); - } - }); - - this.watchers.set(releaseName, dispose); - } - - isLoading(tabId = this.dependencies.dockStore.selectedTabId) { - const values = this.values.getData(tabId); - - return values === undefined; - } - - @action - async loadData(tabId: TabId) { - const values = this.values.getData(tabId); - - await Promise.all([ - !values && this.loadValues(tabId), - ]); - } - - @action - async loadValues(tabId: TabId) { - this.values.clearData(tabId); // reset - const { releaseName, releaseNamespace } = this.getData(tabId); - const values = await getReleaseValues(releaseName, releaseNamespace, true); - - this.values.setData(tabId, values); - } - - getTabByRelease(releaseName: string): DockTab { - return this.dependencies.dockStore.getTabById(this.releaseNameReverseLookup.get(releaseName)); - } -} diff --git a/src/renderer/components/dock/upgrade-chart/clear-upgrade-chart-tab-data.injectable.ts b/src/renderer/components/dock/upgrade-chart/clear-upgrade-chart-tab-data.injectable.ts new file mode 100644 index 0000000000..e6515a4749 --- /dev/null +++ b/src/renderer/components/dock/upgrade-chart/clear-upgrade-chart-tab-data.injectable.ts @@ -0,0 +1,21 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import type { TabId } from "../dock/store"; +import upgradeChartTabStoreInjectable from "./store.injectable"; + +const clearUpgradeChartTabDataInjectable = getInjectable({ + instantiate: (di) => { + const upgradeChartTabStore = di.inject(upgradeChartTabStoreInjectable); + + return (tabId: TabId) => { + upgradeChartTabStore.clearData(tabId); + }; + }, + + lifecycle: lifecycleEnum.singleton, +}); + +export default clearUpgradeChartTabDataInjectable; diff --git a/src/renderer/components/dock/upgrade-chart/create-upgrade-chart-tab.injectable.ts b/src/renderer/components/dock/upgrade-chart/create-upgrade-chart-tab.injectable.ts new file mode 100644 index 0000000000..0d3de6158a --- /dev/null +++ b/src/renderer/components/dock/upgrade-chart/create-upgrade-chart-tab.injectable.ts @@ -0,0 +1,56 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import upgradeChartTabStoreInjectable from "./store.injectable"; +import dockStoreInjectable from "../dock/store.injectable"; +import type { HelmRelease } from "../../../../common/k8s-api/endpoints/helm-releases.api"; +import { DockStore, DockTabCreateSpecific, TabId, TabKind } from "../dock/store"; +import type { UpgradeChartTabStore } from "./store"; +import { runInAction } from "mobx"; + +interface Dependencies { + upgradeChartStore: UpgradeChartTabStore; + dockStore: DockStore +} + +const createUpgradeChartTab = ({ upgradeChartStore, dockStore }: Dependencies) => (release: HelmRelease, tabParams: DockTabCreateSpecific = {}): TabId => { + const tabId = upgradeChartStore.getTabIdByRelease(release.getName()); + + if (tabId) { + dockStore.open(); + dockStore.selectTab(tabId); + + return tabId; + } + + return runInAction(() => { + const 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.id; + }); +}; + +const createUpgradeChartTabInjectable = getInjectable({ + instantiate: (di) => createUpgradeChartTab({ + upgradeChartStore: di.inject(upgradeChartTabStoreInjectable), + dockStore: di.inject(dockStoreInjectable), + }), + + lifecycle: lifecycleEnum.singleton, +}); + +export default createUpgradeChartTabInjectable; diff --git a/src/renderer/components/dock/upgrade-chart-store/upgrade-chart-store.injectable.ts b/src/renderer/components/dock/upgrade-chart/store.injectable.ts similarity index 55% rename from src/renderer/components/dock/upgrade-chart-store/upgrade-chart-store.injectable.ts rename to src/renderer/components/dock/upgrade-chart/store.injectable.ts index d0732e7fdb..13e69e9b10 100644 --- a/src/renderer/components/dock/upgrade-chart-store/upgrade-chart-store.injectable.ts +++ b/src/renderer/components/dock/upgrade-chart/store.injectable.ts @@ -3,27 +3,21 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; -import { UpgradeChartStore } from "./upgrade-chart.store"; -import dockStoreInjectable from "../dock-store/dock-store.injectable"; +import { UpgradeChartTabStore } from "./store"; import createDockTabStoreInjectable from "../dock-tab-store/create-dock-tab-store.injectable"; import createStorageInjectable from "../../../utils/create-storage/create-storage.injectable"; -import releasesInjectable from "../../+apps-releases/releases.injectable"; -const upgradeChartStoreInjectable = getInjectable({ +const upgradeChartTabStoreInjectable = getInjectable({ instantiate: (di) => { const createDockTabStore = di.inject(createDockTabStoreInjectable); - const valuesStore = createDockTabStore(); - - return new UpgradeChartStore({ - releases: di.inject(releasesInjectable), - dockStore: di.inject(dockStoreInjectable), + return new UpgradeChartTabStore({ createStorage: di.inject(createStorageInjectable), - valuesStore, + valuesStore: createDockTabStore(), }); }, lifecycle: lifecycleEnum.singleton, }); -export default upgradeChartStoreInjectable; +export default upgradeChartTabStoreInjectable; diff --git a/src/renderer/components/dock/upgrade-chart/store.ts b/src/renderer/components/dock/upgrade-chart/store.ts new file mode 100644 index 0000000000..ddc1a78188 --- /dev/null +++ b/src/renderer/components/dock/upgrade-chart/store.ts @@ -0,0 +1,51 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { action, computed, makeObservable } from "mobx"; +import type { TabId } from "../dock/store"; +import { DockTabStorageState, DockTabStore } from "../dock-tab-store/dock-tab.store"; +import { getReleaseValues } from "../../../../common/k8s-api/endpoints/helm-releases.api"; +import type { StorageHelper } from "../../../utils"; + +export interface IChartUpgradeData { + releaseName: string; + releaseNamespace: string; +} + +interface Dependencies { + valuesStore: DockTabStore; + createStorage: (storageKey: string, options: DockTabStorageState) => StorageHelper>; +} + +export class UpgradeChartTabStore extends DockTabStore { + @computed private get releaseNameReverseLookup(): Map { + return new Map(this.getAllData().map(([id, { releaseName }]) => [releaseName, id])); + } + + get values() { + return this.dependencies.valuesStore; + } + + constructor(protected dependencies : Dependencies) { + super(dependencies, { + storageKey: "chart_releases", + }); + + makeObservable(this); + } + + @action + async reloadValues(tabId: TabId) { + this.values.clearData(tabId); // reset + const { releaseName, releaseNamespace } = this.getData(tabId); + const values = await getReleaseValues(releaseName, releaseNamespace, true); + + this.values.setData(tabId, values); + } + + getTabIdByRelease(releaseName: string): TabId { + return this.releaseNameReverseLookup.get(releaseName); + } +} diff --git a/src/renderer/components/dock/upgrade-chart.scss b/src/renderer/components/dock/upgrade-chart/upgrade-chart.scss similarity index 100% rename from src/renderer/components/dock/upgrade-chart.scss rename to src/renderer/components/dock/upgrade-chart/upgrade-chart.scss diff --git a/src/renderer/components/dock/upgrade-chart.tsx b/src/renderer/components/dock/upgrade-chart/view.tsx similarity index 64% rename from src/renderer/components/dock/upgrade-chart.tsx rename to src/renderer/components/dock/upgrade-chart/view.tsx index ce25d1815f..bff0491610 100644 --- a/src/renderer/components/dock/upgrade-chart.tsx +++ b/src/renderer/components/dock/upgrade-chart/view.tsx @@ -8,27 +8,20 @@ import "./upgrade-chart.scss"; import React from "react"; import { action, makeObservable, observable, reaction } from "mobx"; import { disposeOnUnmount, observer } from "mobx-react"; -import { cssNames } from "../../utils"; -import type { DockTab } from "./dock-store/dock.store"; -import { InfoPanel } from "./info-panel"; -import type { UpgradeChartStore } from "./upgrade-chart-store/upgrade-chart.store"; -import { Spinner } from "../spinner"; -import { Badge } from "../badge"; -import { EditorPanel } from "./editor-panel"; -import { - helmChartStore, - IChartVersion, -} from "../+apps-helm-charts/helm-chart.store"; -import type { - HelmRelease, - IReleaseUpdateDetails, - IReleaseUpdatePayload, -} from "../../../common/k8s-api/endpoints/helm-releases.api"; -import { Select, SelectOption } from "../select"; +import { cssNames } from "../../../utils"; +import type { DockTab } from "../dock/store"; +import { InfoPanel } from "../info-panel"; +import type { UpgradeChartTabStore } from "./store"; +import { Spinner } from "../../spinner"; +import { Badge } from "../../badge"; +import { EditorPanel } from "../editor-panel"; +import { helmChartStore, IChartVersion } from "../../+apps-helm-charts/helm-chart.store"; +import type { HelmRelease, IReleaseUpdateDetails, IReleaseUpdatePayload } from "../../../../common/k8s-api/endpoints/helm-releases.api"; +import { Select, SelectOption } from "../../select"; import { IAsyncComputed, withInjectables } from "@ogre-tools/injectable-react"; -import upgradeChartStoreInjectable from "./upgrade-chart-store/upgrade-chart-store.injectable"; -import updateReleaseInjectable from "../+apps-releases/update-release/update-release.injectable"; -import releasesInjectable from "../+apps-releases/releases.injectable"; +import upgradeChartTabStoreInjectable from "./store.injectable"; +import updateReleaseInjectable from "../../+apps-releases/update-release/update-release.injectable"; +import releasesInjectable from "../../+apps-releases/releases.injectable"; interface Props { className?: string; @@ -36,9 +29,9 @@ interface Props { } interface Dependencies { - releases: IAsyncComputed - upgradeChartStore: UpgradeChartStore - updateRelease: (name: string, namespace: string, payload: IReleaseUpdatePayload) => Promise + releases: IAsyncComputed; + upgradeChartTabStore: UpgradeChartTabStore; + updateRelease: (name: string, namespace: string, payload: IReleaseUpdatePayload) => Promise; } @observer @@ -53,10 +46,21 @@ export class NonInjectedUpgradeChart extends React.Component this.release, () => this.loadVersions()), + reaction( + () => this.release, + release => this.reloadVersions(release), + { + fireImmediately: true, + }, + ), + reaction( + () => this.release?.getRevision(), + () => this.reloadValues(), + { + fireImmediately: true, + }, + ), ]); } @@ -65,7 +69,7 @@ export class NonInjectedUpgradeChart extends React.Component { this.error = ""; - this.props.upgradeChartStore.values.setData(this.tabId, value); + this.props.upgradeChartTabStore.values.setData(this.tabId, value); }); onError = action((error: Error | string) => { @@ -125,7 +136,7 @@ export class NonInjectedUpgradeChart extends React.Component; } const currentVersion = release.getVersion(); @@ -168,15 +179,11 @@ export class NonInjectedUpgradeChart extends React.Component( - NonInjectedUpgradeChart, - - { - getProps: (di, props) => ({ - releases: di.inject(releasesInjectable), - updateRelease: di.inject(updateReleaseInjectable), - upgradeChartStore: di.inject(upgradeChartStoreInjectable), - ...props, - }), - }, -); +export const UpgradeChart = withInjectables(NonInjectedUpgradeChart, { + getProps: (di, props) => ({ + releases: di.inject(releasesInjectable), + updateRelease: di.inject(updateReleaseInjectable), + upgradeChartTabStore: di.inject(upgradeChartTabStoreInjectable), + ...props, + }), +}); diff --git a/src/renderer/components/input/search-input.tsx b/src/renderer/components/input/search-input.tsx index a004041e9d..36bcc135c4 100644 --- a/src/renderer/components/input/search-input.tsx +++ b/src/renderer/components/input/search-input.tsx @@ -50,13 +50,9 @@ export class SearchInput extends React.Component { @boundMethod onKeyDown(evt: React.KeyboardEvent) { - if (this.props.onKeyDown) { - this.props.onKeyDown(evt); - } - // clear on escape-key - const escapeKey = evt.nativeEvent.code === "Escape"; + this.props.onKeyDown?.(evt); - if (escapeKey) { + if (evt.nativeEvent.code === "Escape") { this.clear(); evt.stopPropagation(); } @@ -87,6 +83,7 @@ export class SearchInput extends React.Component { onKeyDown={this.onKeyDown} iconRight={rightIcon} ref={this.inputRef} + blurOnEnter={false} /> ); } diff --git a/src/renderer/components/item-object-list/content.tsx b/src/renderer/components/item-object-list/content.tsx new file mode 100644 index 0000000000..ccfdddd7d3 --- /dev/null +++ b/src/renderer/components/item-object-list/content.tsx @@ -0,0 +1,271 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import "./item-list-layout.scss"; + +import React, { ReactNode } from "react"; +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, cssNames, IClassName, isReactNode, prevDefault, stopPropagation } from "../../utils"; +import { AddRemoveButtons, AddRemoveButtonsProps } from "../add-remove-buttons"; +import { NoItems } from "../no-items"; +import { Spinner } from "../spinner"; +import type { ItemObject, ItemStore } from "../../../common/item.store"; +import { Filter, pageFilters } from "./page-filters.store"; +import { ThemeStore } from "../../theme.store"; +import { MenuActions } from "../menu/menu-actions"; +import { MenuItem } from "../menu"; +import { Checkbox } from "../checkbox"; +import { UserStore } from "../../../common/user-store"; + +interface ItemListLayoutContentProps { + getFilters: () => Filter[] + tableId?: string; + className: IClassName; + getItems: () => I[]; + store: ItemStore; + getIsReady: () => boolean; // show loading indicator while not ready + isSelectable?: boolean; // show checkbox in rows for selecting items + isConfigurable?: boolean; + copyClassNameFromHeadCells?: boolean; + sortingCallbacks?: TableSortCallbacks; + tableProps?: Partial>; // low-level table configuration + renderTableHeader: TableCellProps[] | null; + renderTableContents: (item: I) => (ReactNode | TableCellProps)[]; + renderItemMenu?: (item: I, store: ItemStore) => ReactNode; + customizeTableRowProps?: (item: I) => Partial; + addRemoveButtons?: Partial; + virtual?: boolean; + + // item details view + hasDetailsView?: boolean; + detailsItem?: I; + onDetails?: (item: I) => void; + + // other + customizeRemoveDialog?: (selectedItems: I[]) => Partial; + + /** + * Message to display when a store failed to load + * + * @default "Failed to load items" + */ + failedToLoadMessage?: React.ReactNode; +} + +@observer +export class ItemListLayoutContent extends React.Component> { + constructor(props: ItemListLayoutContentProps) { + super(props); + makeObservable(this); + } + + @computed get failedToLoad() { + return this.props.store.failedLoading; + } + + @boundMethod + getRow(uid: string) { + const { + isSelectable, renderTableHeader, renderTableContents, renderItemMenu, + store, hasDetailsView, onDetails, + copyClassNameFromHeadCells, customizeTableRowProps, detailsItem, + } = this.props; + const { isSelected } = store; + const item = this.props.getItems().find(item => item.getId() == uid); + + if (!item) return null; + const itemId = item.getId(); + + return ( + onDetails(item)) : undefined} + {...customizeTableRowProps(item)} + > + {isSelectable && ( + store.toggleSelection(item))} + /> + )} + { + renderTableContents(item).map((content, index) => { + const cellProps: TableCellProps = isReactNode(content) ? { children: content } : content; + const headCell = renderTableHeader?.[index]; + + if (copyClassNameFromHeadCells && headCell) { + cellProps.className = cssNames(cellProps.className, headCell.className); + } + + if (!headCell || this.showColumn(headCell)) { + return ; + } + + return null; + }) + } + {renderItemMenu && ( + +
+ {renderItemMenu(item, store)} +
+
+ )} +
+ ); + } + + @boundMethod + removeItemsDialog() { + const { customizeRemoveDialog, store } = this.props; + const { selectedItems, removeSelectedItems } = store; + const visibleMaxNamesCount = 5; + const selectedNames = selectedItems.map(ns => ns.getName()).slice(0, visibleMaxNamesCount).join(", "); + const dialogCustomProps = customizeRemoveDialog ? customizeRemoveDialog(selectedItems) : {}; + const selectedCount = selectedItems.length; + const tailCount = selectedCount > visibleMaxNamesCount ? selectedCount - visibleMaxNamesCount : 0; + const tail = tailCount > 0 ? <>, and {tailCount} more : null; + const message = selectedCount <= 1 ?

Remove item {selectedNames}?

:

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

; + + ConfirmDialog.open({ + ok: removeSelectedItems, + labelOk: "Remove", + message, + ...dialogCustomProps, + }); + } + + renderNoItems() { + if (this.failedToLoad) { + return {this.props.failedToLoadMessage}; + } + + if (!this.props.getIsReady()) { + return ; + } + + if (this.props.getFilters().length > 0) { + return ( + + No items found. +

+ pageFilters.reset()} className="contrast"> + Reset filters? + +

+
+ ); + } + + return ; + } + + renderItems() { + if (this.props.virtual) { + return null; + } + + return this.props.getItems().map(item => this.getRow(item.getId())); + } + + renderTableHeader() { + const { customizeTableRowProps, renderTableHeader, isSelectable, isConfigurable, store } = this.props; + + if (!renderTableHeader) { + return null; + } + + const enabledItems = this.props.getItems().filter(item => !customizeTableRowProps(item).disabled); + + return ( + + {isSelectable && ( + store.toggleSelectionAll(enabledItems))} + /> + )} + {renderTableHeader.map((cellProps, index) => ( + this.showColumn(cellProps) && ( + + ) + ))} + + {isConfigurable && this.renderColumnVisibilityMenu()} + + + ); + } + + render() { + const { + store, hasDetailsView, addRemoveButtons = {}, virtual, sortingCallbacks, + detailsItem, className, tableProps = {}, tableId, + } = this.props; + const { selectedItems } = store; + const selectedItemId = detailsItem && detailsItem.getId(); + const classNames = cssNames(className, "box", "grow", ThemeStore.getInstance().activeTheme.type); + + return ( +
+ + {this.renderTableHeader()} + {this.renderItems()} +
+ +
+ ); + } + + showColumn({ id: columnId, showWithColumn }: TableCellProps): boolean { + const { tableId, isConfigurable } = this.props; + + return !isConfigurable || !UserStore.getInstance().isTableColumnHidden(tableId, columnId, showWithColumn); + } + + renderColumnVisibilityMenu() { + const { renderTableHeader, tableId } = this.props; + + return ( + + {renderTableHeader.map((cellProps, index) => ( + !cellProps.showWithColumn && ( + + `} + value={this.showColumn(cellProps)} + onChange={() => UserStore.getInstance().toggleTableColumnVisibility(tableId, cellProps.id)} + /> + + ) + ))} + + ); + } +} diff --git a/src/renderer/components/item-object-list/filters.tsx b/src/renderer/components/item-object-list/filters.tsx new file mode 100644 index 0000000000..5dd3c13dc2 --- /dev/null +++ b/src/renderer/components/item-object-list/filters.tsx @@ -0,0 +1,29 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import "./item-list-layout.scss"; + +import React from "react"; +import { PageFiltersList } from "./page-filters-list"; +import { observer } from "mobx-react"; +import type { Filter } from "./page-filters.store"; + +export interface ItemListLayoutFilterProps { + getIsReady: () => boolean + getFilters: () => Filter[] + getFiltersAreShown: () => boolean + hideFilters: boolean +} + +export const ItemListLayoutFilters = observer(({ getFilters, getFiltersAreShown, getIsReady, hideFilters }: ItemListLayoutFilterProps) => { + const filters = getFilters(); + + if (!getIsReady() || !filters.length || hideFilters || !getFiltersAreShown()) { + return null; + } + + return ; +}); + diff --git a/src/renderer/components/item-object-list/header.tsx b/src/renderer/components/item-object-list/header.tsx new file mode 100644 index 0000000000..ce5f19fd05 --- /dev/null +++ b/src/renderer/components/item-object-list/header.tsx @@ -0,0 +1,105 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import "./item-list-layout.scss"; + +import React, { ReactNode } from "react"; +import { observer } from "mobx-react"; +import { cssNames, IClassName } from "../../utils"; +import type { ItemObject, ItemStore } from "../../../common/item.store"; +import type { Filter } from "./page-filters.store"; +import type { HeaderCustomizer, HeaderPlaceholders, SearchFilter } from "./list-layout"; +import { SearchInputUrl } from "../input"; + +export interface ItemListLayoutHeaderProps { + getItems: () => I[]; + getFilters: () => Filter[]; + toggleFilters: () => void; + + store: ItemStore; + searchFilters?: SearchFilter[]; + + // header (title, filtering, searching, etc.) + showHeader?: boolean; + headerClassName?: IClassName; + renderHeaderTitle?: + | ReactNode + | ((parent: ItemListLayoutHeader) => ReactNode); + customizeHeader?: HeaderCustomizer | HeaderCustomizer[]; +} + +@observer +export class ItemListLayoutHeader extends React.Component< + ItemListLayoutHeaderProps +> { + render() { + const { + showHeader, + customizeHeader, + renderHeaderTitle, + headerClassName, + searchFilters, + getItems, + store, + getFilters, + toggleFilters, + } = this.props; + + if (!showHeader) { + return null; + } + + const renderInfo = () => { + const allItemsCount = store.getTotalCount(); + const itemsCount = getItems().length; + + if (getFilters().length > 0) { + return ( + <> + Filtered: {itemsCount} / {allItemsCount} + + ); + } + + return allItemsCount === 1 + ? `${allItemsCount} item` + : `${allItemsCount} items`; + }; + + const customizeHeaderFunctions = [customizeHeader].flat().filter(Boolean); + const renderedTitle = typeof renderHeaderTitle === "function" + ? renderHeaderTitle(this) + : renderHeaderTitle; + + const { + filters, + info, + searchProps, + title, + } = customizeHeaderFunctions.reduce( + (prevPlaceholders, customizer) => customizer(prevPlaceholders), + { + title:
{renderedTitle}
, + info: renderInfo(), + searchProps: {}, + }, + ); + + return ( +
+ {title} + { + info && ( +
+ {info} +
+ ) + } + {filters} + {searchFilters.length > 0 && searchProps && } +
+ ); + } +} diff --git a/src/renderer/components/item-object-list/index.tsx b/src/renderer/components/item-object-list/index.tsx index 6861509d03..bb05729496 100644 --- a/src/renderer/components/item-object-list/index.tsx +++ b/src/renderer/components/item-object-list/index.tsx @@ -3,4 +3,4 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -export * from "./item-list-layout"; +export * from "./list-layout"; diff --git a/src/renderer/components/item-object-list/item-list-layout.tsx b/src/renderer/components/item-object-list/item-list-layout.tsx deleted file mode 100644 index 2d6a91fe70..0000000000 --- a/src/renderer/components/item-object-list/item-list-layout.tsx +++ /dev/null @@ -1,549 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import "./item-list-layout.scss"; -import groupBy from "lodash/groupBy"; - -import React, { ReactNode } from "react"; -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, - 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"; -import type { ItemObject, ItemStore } from "../../../common/item.store"; -import { SearchInputUrlProps, SearchInputUrl } from "../input"; -import { Filter, FilterType, pageFilters } from "./page-filters.store"; -import { PageFiltersList } from "./page-filters-list"; -import { ThemeStore } from "../../theme.store"; -import { MenuActions } from "../menu/menu-actions"; -import { MenuItem } from "../menu"; -import { Checkbox } from "../checkbox"; -import { UserStore } from "../../../common/user-store"; -import type { NamespaceStore } from "../+namespaces/namespace-store/namespace.store"; -import namespaceStoreInjectable from "../+namespaces/namespace-store/namespace-store.injectable"; -import { withInjectables } from "@ogre-tools/injectable-react"; -import itemListLayoutStorageInjectable - from "./item-list-layout-storage/item-list-layout-storage.injectable"; - - -export type SearchFilter = (item: I) => string | number | (string | number)[]; -export type SearchFilters = Record>; -export type ItemsFilter = (items: I[]) => I[]; -export type ItemsFilters = Record>; - -export interface HeaderPlaceholders { - title?: ReactNode; - searchProps?: SearchInputUrlProps; - filters?: ReactNode; - info?: ReactNode; -} - -export type HeaderCustomizer = (placeholders: HeaderPlaceholders) => HeaderPlaceholders; -export interface ItemListLayoutProps { - tableId?: string; - className: IClassName; - items?: I[]; - store: ItemStore; - dependentStores?: ItemStore[]; - preloadStores?: boolean; - hideFilters?: boolean; - searchFilters?: SearchFilter[]; - /** @deprecated */ - filterItems?: ItemsFilter[]; - - // header (title, filtering, searching, etc.) - showHeader?: boolean; - headerClassName?: IClassName; - renderHeaderTitle?: ReactNode | ((parent: NonInjectedItemListLayout) => ReactNode); - customizeHeader?: HeaderCustomizer | HeaderCustomizer[]; - - // items list configuration - isReady?: boolean; // show loading indicator while not ready - isSelectable?: boolean; // show checkbox in rows for selecting items - isConfigurable?: boolean; - copyClassNameFromHeadCells?: boolean; - sortingCallbacks?: TableSortCallbacks; - tableProps?: Partial>; // low-level table configuration - renderTableHeader: TableCellProps[] | null; - renderTableContents: (item: I) => (ReactNode | TableCellProps)[]; - renderItemMenu?: (item: I, store: ItemStore) => ReactNode; - customizeTableRowProps?: (item: I) => Partial; - addRemoveButtons?: Partial; - virtual?: boolean; - - // item details view - hasDetailsView?: boolean; - detailsItem?: I; - onDetails?: (item: I) => void; - - // other - customizeRemoveDialog?: (selectedItems: I[]) => Partial; - renderFooter?: (parent: NonInjectedItemListLayout) => React.ReactNode; - - /** - * Message to display when a store failed to load - * - * @default "Failed to load items" - */ - failedToLoadMessage?: React.ReactNode; - - filterCallbacks?: ItemsFilters; -} - -const defaultProps: Partial> = { - showHeader: true, - isSelectable: true, - isConfigurable: false, - copyClassNameFromHeadCells: true, - preloadStores: true, - dependentStores: [], - searchFilters: [], - customizeHeader: [], - filterItems: [], - hasDetailsView: true, - onDetails: noop, - virtual: true, - customizeTableRowProps: () => ({}), - failedToLoadMessage: "Failed to load items", -}; - -interface Dependencies { - namespaceStore: NamespaceStore; - itemListLayoutStorage: StorageHelper<{ showFilters: boolean }>; -} - -@observer -class NonInjectedItemListLayout extends React.Component & Dependencies> { - static defaultProps = defaultProps as object; - - constructor(props: ItemListLayoutProps & Dependencies) { - super(props); - makeObservable(this); - } - - get showFilters(): boolean { - return this.props.itemListLayoutStorage.get().showFilters; - } - - set showFilters(showFilters: boolean) { - this.props.itemListLayoutStorage.merge({ showFilters }); - } - - async componentDidMount() { - const { isConfigurable, tableId, preloadStores } = this.props; - - if (isConfigurable && !tableId) { - throw new Error("[ItemListLayout]: configurable list require props.tableId to be specified"); - } - - if (isConfigurable && !UserStore.getInstance().hiddenTableColumns.has(tableId)) { - UserStore.getInstance().hiddenTableColumns.set(tableId, new ObservableToggleSet()); - } - - if (preloadStores) { - this.loadStores(); - } - } - - private loadStores() { - const { store, dependentStores } = this.props; - const stores = Array.from(new Set([store, ...dependentStores])); - - stores.forEach(store => store.loadAll(this.props.namespaceStore.contextNamespaces)); - } - - private filterCallbacks: ItemsFilters = { - [FilterType.SEARCH]: items => { - const { searchFilters } = this.props; - const search = pageFilters.getValues(FilterType.SEARCH)[0] || ""; - - if (search && searchFilters.length) { - const normalizeText = (text: string) => String(text).toLowerCase(); - const searchTexts = [search].map(normalizeText); - - return items.filter(item => { - return searchFilters.some(getTexts => { - const sourceTexts: string[] = [getTexts(item)].flat().map(normalizeText); - - return sourceTexts.some(source => searchTexts.some(search => source.includes(search))); - }); - }); - } - - return items; - }, - }; - - @computed get isReady() { - return this.props.isReady ?? this.props.store.isLoaded; - } - - @computed get failedToLoad() { - return this.props.store.failedLoading; - } - - @computed get filters() { - let { activeFilters } = pageFilters; - const { searchFilters } = this.props; - - if (searchFilters.length === 0) { - activeFilters = activeFilters.filter(({ type }) => type !== FilterType.SEARCH); - } - - return activeFilters; - } - - applyFilters(filters: ItemsFilter[], items: I[]): I[] { - if (!filters || !filters.length) return items; - - return filters.reduce((items, filter) => filter(items), items); - } - - @computed get items() { - const { filters, filterCallbacks, props } = this; - const filterGroups = groupBy(filters, ({ type }) => type); - - const filterItems: ItemsFilter[] = []; - - Object.entries(filterGroups).forEach(([type, filtersGroup]) => { - const filterCallback = filterCallbacks[type] ?? props.filterCallbacks?.[type]; - - if (filterCallback && filtersGroup.length > 0) { - filterItems.push(filterCallback); - } - }); - - const items = this.props.items ?? this.props.store.items; - - return this.applyFilters(filterItems.concat(this.props.filterItems), items); - } - - @boundMethod - getRow(uid: string) { - const { - isSelectable, renderTableHeader, renderTableContents, renderItemMenu, - store, hasDetailsView, onDetails, - copyClassNameFromHeadCells, customizeTableRowProps, detailsItem, - } = this.props; - const { isSelected } = store; - const item = this.items.find(item => item.getId() == uid); - - if (!item) return null; - const itemId = item.getId(); - - return ( - onDetails(item)) : undefined} - {...customizeTableRowProps(item)} - > - {isSelectable && ( - store.toggleSelection(item))} - /> - )} - { - renderTableContents(item).map((content, index) => { - const cellProps: TableCellProps = isReactNode(content) ? { children: content } : content; - const headCell = renderTableHeader?.[index]; - - if (copyClassNameFromHeadCells && headCell) { - cellProps.className = cssNames(cellProps.className, headCell.className); - } - - if (!headCell || this.showColumn(headCell)) { - return ; - } - - return null; - }) - } - {renderItemMenu && ( - -
- {renderItemMenu(item, store)} -
-
- )} -
- ); - } - - @boundMethod - removeItemsDialog() { - const { customizeRemoveDialog, store } = this.props; - const { selectedItems, removeSelectedItems } = store; - const visibleMaxNamesCount = 5; - const selectedNames = selectedItems.map(ns => ns.getName()).slice(0, visibleMaxNamesCount).join(", "); - const dialogCustomProps = customizeRemoveDialog ? customizeRemoveDialog(selectedItems) : {}; - const selectedCount = selectedItems.length; - const tailCount = selectedCount > visibleMaxNamesCount ? selectedCount - visibleMaxNamesCount : 0; - const tail = tailCount > 0 ? <>, and {tailCount} more : null; - const message = selectedCount <= 1 ?

Remove item {selectedNames}?

:

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

; - - ConfirmDialog.open({ - ok: removeSelectedItems, - labelOk: "Remove", - message, - ...dialogCustomProps, - }); - } - - @boundMethod - toggleFilters() { - this.showFilters = !this.showFilters; - } - - renderFilters() { - const { hideFilters } = this.props; - const { isReady, filters } = this; - - if (!isReady || !filters.length || hideFilters || !this.showFilters) { - return null; - } - - return ; - } - - renderNoItems() { - if (this.failedToLoad) { - return {this.props.failedToLoadMessage}; - } - - if (!this.isReady) { - return ; - } - - if (this.filters.length > 0) { - return ( - - No items found. -

- pageFilters.reset()} className="contrast"> - Reset filters? - -

-
- ); - } - - return ; - } - - renderItems() { - if (this.props.virtual) { - return null; - } - - return this.items.map(item => this.getRow(item.getId())); - } - - renderHeaderContent(placeholders: HeaderPlaceholders): ReactNode { - const { searchFilters } = this.props; - const { title, filters, searchProps, info } = placeholders; - - return ( - <> - {title} - { - info && ( -
- {info} -
- ) - } - {filters} - {searchFilters.length > 0 && searchProps && } - - ); - } - - renderInfo() { - const { items, filters } = this; - const allItemsCount = this.props.store.getTotalCount(); - const itemsCount = items.length; - - if (filters.length > 0) { - return ( - <>Filtered: {itemsCount} / {allItemsCount} - ); - } - - return allItemsCount === 1 ? `${allItemsCount} item` : `${allItemsCount} items`; - } - - renderHeader() { - const { showHeader, customizeHeader, renderHeaderTitle, headerClassName } = this.props; - - if (!showHeader) { - return null; - } - - const title = typeof renderHeaderTitle === "function" ? renderHeaderTitle(this) : renderHeaderTitle; - const customizeHeaders = [customizeHeader].flat().filter(Boolean); - const initialPlaceholders: HeaderPlaceholders = { - title:
{title}
, - info: this.renderInfo(), - searchProps: {}, - }; - const headerPlaceholders = customizeHeaders.reduce((prevPlaceholders, customizer) => customizer(prevPlaceholders), initialPlaceholders); - const header = this.renderHeaderContent(headerPlaceholders); - - return ( -
- {header} -
- ); - } - - renderTableHeader() { - const { customizeTableRowProps, renderTableHeader, isSelectable, isConfigurable, store } = this.props; - - if (!renderTableHeader) { - return null; - } - - const enabledItems = this.items.filter(item => !customizeTableRowProps(item).disabled); - - return ( - - {isSelectable && ( - store.toggleSelectionAll(enabledItems))} - /> - )} - {renderTableHeader.map((cellProps, index) => ( - this.showColumn(cellProps) && ( - - ) - ))} - - {isConfigurable && this.renderColumnVisibilityMenu()} - - - ); - } - - renderList() { - const { - store, hasDetailsView, addRemoveButtons = {}, virtual, sortingCallbacks, - detailsItem, className, tableProps = {}, tableId, - } = this.props; - const { removeItemsDialog, items } = this; - const { selectedItems } = store; - const selectedItemId = detailsItem && detailsItem.getId(); - const classNames = cssNames(className, "box", "grow", ThemeStore.getInstance().activeTheme.type); - - return ( -
- - {this.renderTableHeader()} - {this.renderItems()} -
- -
- ); - } - - showColumn({ id: columnId, showWithColumn }: TableCellProps): boolean { - const { tableId, isConfigurable } = this.props; - - return !isConfigurable || !UserStore.getInstance().isTableColumnHidden(tableId, columnId, showWithColumn); - } - - renderColumnVisibilityMenu() { - const { renderTableHeader, tableId } = this.props; - - return ( - - {renderTableHeader.map((cellProps, index) => ( - !cellProps.showWithColumn && ( - - `} - value={this.showColumn(cellProps)} - onChange={() => UserStore.getInstance().toggleTableColumnVisibility(tableId, cellProps.id)} - /> - - ) - ))} - - ); - } - - renderFooter() { - return this.props.renderFooter?.(this); - } - - render() { - const { className } = this.props; - - return ( -
- {this.renderHeader()} - {this.renderFilters()} - {this.renderList()} - {this.renderFooter()} -
- ); - } -} - -export function ItemListLayout( - 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/item-object-list/list-layout.tsx b/src/renderer/components/item-object-list/list-layout.tsx new file mode 100644 index 0000000000..39211d0a6b --- /dev/null +++ b/src/renderer/components/item-object-list/list-layout.tsx @@ -0,0 +1,313 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import "./item-list-layout.scss"; + +import React, { ReactNode } from "react"; +import { computed, makeObservable, untracked } from "mobx"; +import type { ConfirmDialogParams } from "../confirm-dialog"; +import type { + TableCellProps, + TableProps, + TableRowProps, + TableSortCallbacks, +} from "../table"; +import { + boundMethod, + cssNames, + IClassName, + noop, + ObservableToggleSet, + StorageHelper, +} from "../../utils"; +import type { AddRemoveButtonsProps } from "../add-remove-buttons"; +import type { ItemObject, ItemStore } from "../../../common/item.store"; +import type { SearchInputUrlProps } from "../input"; +import { Filter, FilterType, pageFilters } from "./page-filters.store"; +import { PageFiltersList } from "./page-filters-list"; +import { UserStore } from "../../../common/user-store"; +import type { NamespaceStore } from "../+namespaces/namespace-store/namespace.store"; +import namespaceStoreInjectable from "../+namespaces/namespace-store/namespace-store.injectable"; +import { withInjectables } from "@ogre-tools/injectable-react"; +import itemListLayoutStorageInjectable + from "./storage.injectable"; +import { ItemListLayoutContent } from "./content"; +import { ItemListLayoutHeader } from "./header"; +import groupBy from "lodash/groupBy"; +import { ItemListLayoutFilters } from "./filters"; +import { observer } from "mobx-react"; + +export type SearchFilter = (item: I) => string | number | (string | number)[]; +export type SearchFilters = Record>; +export type ItemsFilter = (items: I[]) => I[]; +export type ItemsFilters = Record>; + +export interface HeaderPlaceholders { + title?: ReactNode; + searchProps?: SearchInputUrlProps; + filters?: ReactNode; + info?: ReactNode; +} + +export type HeaderCustomizer = (placeholders: HeaderPlaceholders) => HeaderPlaceholders; +export interface ItemListLayoutProps { + tableId?: string; + className: IClassName; + items?: I[]; + getItems?: () => I[]; + store: ItemStore; + dependentStores?: ItemStore[]; + preloadStores?: boolean; + hideFilters?: boolean; + searchFilters?: SearchFilter[]; + /** @deprecated */ + filterItems?: ItemsFilter[]; + + // header (title, filtering, searching, etc.) + showHeader?: boolean; + headerClassName?: IClassName; + renderHeaderTitle?: ReactNode | ((parent: NonInjectedItemListLayout) => ReactNode); + customizeHeader?: HeaderCustomizer | HeaderCustomizer[]; + + // items list configuration + isReady?: boolean; // show loading indicator while not ready + isSelectable?: boolean; // show checkbox in rows for selecting items + isConfigurable?: boolean; + copyClassNameFromHeadCells?: boolean; + sortingCallbacks?: TableSortCallbacks; + tableProps?: Partial>; // low-level table configuration + renderTableHeader: TableCellProps[] | null; + renderTableContents: (item: I) => (ReactNode | TableCellProps)[]; + renderItemMenu?: (item: I, store: ItemStore) => ReactNode; + customizeTableRowProps?: (item: I) => Partial; + addRemoveButtons?: Partial; + virtual?: boolean; + + // item details view + hasDetailsView?: boolean; + detailsItem?: I; + onDetails?: (item: I) => void; + + // other + customizeRemoveDialog?: (selectedItems: I[]) => Partial; + renderFooter?: (parent: NonInjectedItemListLayout) => React.ReactNode; + + /** + * Message to display when a store failed to load + * + * @default "Failed to load items" + */ + failedToLoadMessage?: React.ReactNode; + + filterCallbacks?: ItemsFilters; +} + +const defaultProps: Partial> = { + showHeader: true, + isSelectable: true, + isConfigurable: false, + copyClassNameFromHeadCells: true, + preloadStores: true, + dependentStores: [], + searchFilters: [], + customizeHeader: [], + filterItems: [], + hasDetailsView: true, + onDetails: noop, + virtual: true, + customizeTableRowProps: () => ({}), + failedToLoadMessage: "Failed to load items", +}; + +interface Dependencies { + namespaceStore: NamespaceStore; + itemListLayoutStorage: StorageHelper<{ showFilters: boolean }>; +} + +@observer +class NonInjectedItemListLayout extends React.Component & Dependencies> { + static defaultProps = defaultProps as object; + + constructor(props: ItemListLayoutProps & Dependencies) { + super(props); + makeObservable(this); + } + + async componentDidMount() { + const { isConfigurable, tableId, preloadStores } = this.props; + + if (isConfigurable && !tableId) { + throw new Error("[ItemListLayout]: configurable list require props.tableId to be specified"); + } + + if (isConfigurable && !UserStore.getInstance().hiddenTableColumns.has(tableId)) { + UserStore.getInstance().hiddenTableColumns.set(tableId, new ObservableToggleSet()); + } + + if (preloadStores) { + this.loadStores(); + } + } + + private loadStores() { + const { store, dependentStores } = this.props; + const stores = Array.from(new Set([store, ...dependentStores])); + + stores.forEach(store => store.loadAll(this.props.namespaceStore.contextNamespaces)); + } + + get showFilters(): boolean { + return this.props.itemListLayoutStorage.get().showFilters; + } + + set showFilters(showFilters: boolean) { + this.props.itemListLayoutStorage.merge({ showFilters }); + } + + @computed get filters() { + let { activeFilters } = pageFilters; + const { searchFilters } = this.props; + + if (searchFilters.length === 0) { + activeFilters = activeFilters.filter(({ type }) => type !== FilterType.SEARCH); + } + + return activeFilters; + } + + @boundMethod + toggleFilters() { + this.showFilters = !this.showFilters; + } + + @computed get isReady() { + return this.props.isReady ?? this.props.store.isLoaded; + } + + renderFilters() { + const { hideFilters } = this.props; + const { isReady, filters } = this; + + if (!isReady || !filters.length || hideFilters || !this.showFilters) { + return null; + } + + return ; + } + + private filterCallbacks: ItemsFilters = { + [FilterType.SEARCH]: items => { + const { searchFilters } = this.props; + const search = pageFilters.getValues(FilterType.SEARCH)[0] || ""; + + if (search && searchFilters.length) { + const normalizeText = (text: string) => String(text).toLowerCase(); + const searchTexts = [search].map(normalizeText); + + return items.filter(item => { + return searchFilters.some(getTexts => { + const sourceTexts: string[] = [getTexts(item)].flat().map(normalizeText); + + return sourceTexts.some(source => searchTexts.some(search => source.includes(search))); + }); + }); + } + + return items; + }, + }; + + @computed get items() { + const filterGroups = groupBy(this.filters, ({ type }) => type); + + const filterItems: ItemsFilter[] = []; + + Object.entries(filterGroups).forEach(([type, filtersGroup]) => { + const filterCallback = this.filterCallbacks[type] ?? this.props.filterCallbacks?.[type]; + + if (filterCallback && filtersGroup.length > 0) { + filterItems.push(filterCallback); + } + }); + + const items = this.props.getItems ? this.props.getItems() : (this.props.items ?? this.props.store.items); + + return applyFilters(filterItems.concat(this.props.filterItems), items); + } + + render() { + return untracked(() => ( +
+ this.items} + getFilters={() => this.filters} + toggleFilters={this.toggleFilters} + store={this.props.store} + searchFilters={this.props.searchFilters} + showHeader={this.props.showHeader} + headerClassName={this.props.headerClassName} + renderHeaderTitle={this.props.renderHeaderTitle} + customizeHeader={this.props.customizeHeader} + /> + + this.isReady} + getFilters={() => this.filters} + getFiltersAreShown={() => this.showFilters} + hideFilters={this.props.hideFilters} + /> + + this.items} + getFilters={() => this.filters} + tableId={this.props.tableId} + className={this.props.className} + store={this.props.store} + getIsReady={() => this.isReady} + isSelectable={this.props.isSelectable} + isConfigurable={this.props.isConfigurable} + copyClassNameFromHeadCells={this.props.copyClassNameFromHeadCells} + sortingCallbacks={this.props.sortingCallbacks} + tableProps={this.props.tableProps} + renderTableHeader={this.props.renderTableHeader} + renderTableContents={this.props.renderTableContents} + renderItemMenu={this.props.renderItemMenu} + customizeTableRowProps={this.props.customizeTableRowProps} + addRemoveButtons={this.props.addRemoveButtons} + virtual={this.props.virtual} + hasDetailsView={this.props.hasDetailsView} + detailsItem={this.props.detailsItem} + onDetails={this.props.onDetails} + customizeRemoveDialog={this.props.customizeRemoveDialog} + failedToLoadMessage={this.props.failedToLoadMessage} + /> + + {this.props.renderFooter?.(this)} +
+ )); + } +} + +const InjectedItemListLayout = withInjectables>(NonInjectedItemListLayout, { + getProps: (di, props) => ({ + namespaceStore: di.inject(namespaceStoreInjectable), + itemListLayoutStorage: di.inject(itemListLayoutStorageInjectable), + ...props, + }), +}); + +export function ItemListLayout(props: ItemListLayoutProps) { + return ; +} + +function applyFilters(filters: ItemsFilter[], items: I[]): I[] { + if (!filters || !filters.length) { + return items; + } + + return filters.reduce((items, filter) => filter(items), items); +} 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/storage.injectable.ts similarity index 85% rename from src/renderer/components/item-object-list/item-list-layout-storage/item-list-layout-storage.injectable.ts rename to src/renderer/components/item-object-list/storage.injectable.ts index 9033e5fdfe..a105d35472 100644 --- a/src/renderer/components/item-object-list/item-list-layout-storage/item-list-layout-storage.injectable.ts +++ b/src/renderer/components/item-object-list/storage.injectable.ts @@ -3,7 +3,7 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; -import createStorageInjectable from "../../../utils/create-storage/create-storage.injectable"; +import createStorageInjectable from "../../utils/create-storage/create-storage.injectable"; const itemListLayoutStorageInjectable = getInjectable({ instantiate: (di) => { 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 304694da0f..de1253fd65 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 @@ -10,7 +10,7 @@ import { computed, makeObservable, observable, reaction } from "mobx"; import { disposeOnUnmount, observer } from "mobx-react"; import { cssNames, Disposer } from "../../utils"; import type { KubeObject } from "../../../common/k8s-api/kube-object"; -import { ItemListLayout, ItemListLayoutProps } from "../item-object-list/item-list-layout"; +import { ItemListLayout, ItemListLayoutProps } from "../item-object-list/list-layout"; import type { KubeObjectStore } from "../../../common/k8s-api/kube-object.store"; import { KubeObjectMenu } from "../kube-object-menu"; import { NamespaceSelectFilter } from "../+namespaces/namespace-select-filter"; @@ -99,14 +99,14 @@ class NonInjectedKubeObjectListLayout extends React.Compon } render() { - const { className, customizeHeader, store, items = store.contextItems, ...layoutProps } = this.props; + const { className, customizeHeader, store, items, ...layoutProps } = this.props; const placeholderString = ResourceNames[ResourceKindMap[store.api.kind]] || store.api.kind; return ( this.props.items || store.contextItems} preloadStores={false} // loading handled in kubeWatchApi.subscribeStores() detailsItem={this.selectedItem} customizeHeader={[ diff --git a/src/renderer/components/kube-object-menu/kube-object-menu.test.tsx b/src/renderer/components/kube-object-menu/kube-object-menu.test.tsx index 935c4b9208..9c22de1b27 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 @@ -17,8 +17,7 @@ import { getDiForUnitTesting } from "../../getDiForUnitTesting"; import clusterInjectable from "./dependencies/cluster.injectable"; import hideDetailsInjectable from "./dependencies/hide-details.injectable"; -import editResourceTabInjectable from "../dock/edit-resource-tab/edit-resource-tab.injectable"; -import { TabKind } from "../dock/dock-store/dock.store"; +import createEditResourceTabInjectable from "../dock/edit-resource/edit-resource-tab.injectable"; import kubeObjectMenuRegistryInjectable from "./dependencies/kube-object-menu-items/kube-object-menu-registry.injectable"; import { DiRender, renderFor } from "../test-utils/renderFor"; import type { Cluster } from "../../../common/cluster/cluster"; @@ -54,12 +53,7 @@ describe("kube-object-menu", () => { di.override(hideDetailsInjectable, () => () => {}); - di.override(editResourceTabInjectable, () => () => ({ - id: "irrelevant", - kind: TabKind.TERMINAL, - pinned: false, - title: "irrelevant", - })); + di.override(createEditResourceTabInjectable, () => () => "irrelevant"); addDynamicMenuItem({ di, diff --git a/src/renderer/components/kube-object-menu/kube-object-menu.tsx b/src/renderer/components/kube-object-menu/kube-object-menu.tsx index f4f3a81b58..6d425ab9c3 100644 --- a/src/renderer/components/kube-object-menu/kube-object-menu.tsx +++ b/src/renderer/components/kube-object-menu/kube-object-menu.tsx @@ -11,7 +11,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 "../dock/edit-resource-tab/edit-resource-tab.injectable"; +import createEditResourceTabInjectable from "../dock/edit-resource/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"; @@ -27,7 +27,7 @@ interface Dependencies { kubeObjectMenuItems: React.ElementType[]; clusterName: string; hideDetails: () => void; - editResourceTab: (kubeObject: KubeObject) => void; + createEditResourceTab: (kubeObject: KubeObject) => void; } class NonInjectedKubeObjectMenu extends React.Component & Dependencies> { @@ -51,7 +51,7 @@ class NonInjectedKubeObjectMenu extends React.Co @boundMethod async update() { this.props.hideDetails(); - this.props.editResourceTab(this.props.object); + this.props.createEditResourceTab(this.props.object); } @boundMethod @@ -117,7 +117,7 @@ export function KubeObjectMenu( getProps: (di, props) => ({ clusterName: di.inject(clusterNameInjectable), apiManager: di.inject(apiManagerInjectable), - editResourceTab: di.inject(editResourceTabInjectable), + createEditResourceTab: di.inject(createEditResourceTabInjectable), hideDetails: di.inject(hideDetailsInjectable), kubeObjectMenuItems: di.inject(kubeObjectMenuItemsInjectable, { 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 0190adcc71..9b557a2f6c 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 @@ -20,15 +20,14 @@ jest.mock("../../../../common/ipc"); jest.mock("../../../ipc"); jest.mock("../../../../common/vars", () => { - const SemVer = require("semver").SemVer; - - const versionStub = new SemVer("1.0.0"); + const { SemVer } = require("semver"); return { + ...jest.requireActual<{}>("../../../../common/vars"), __esModule: true, isWindows: null, isLinux: null, - appSemVer: versionStub, + appSemVer: new SemVer("1.0.0"), }; }); 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 e5d491401c..db61d3e8d0 100644 --- a/src/renderer/components/layout/top-bar/top-bar.test.tsx +++ b/src/renderer/components/layout/top-bar/top-bar.test.tsx @@ -14,15 +14,15 @@ 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"; +import isLinuxInjectable from "../../../../common/vars/is-linux.injectable"; +import isWindowsInjectable from "../../../../common/vars/is-windows.injectable"; jest.mock("../../../../common/vars", () => { - const SemVer = require("semver").SemVer; - - const versionStub = new SemVer("1.0.0"); + const { SemVer } = require("semver"); return { - isMac: true, - appSemVer: versionStub, + ...jest.requireActual<{}>("../../../../common/vars"), + appSemVer: new SemVer("1.0.0"), }; }); @@ -98,30 +98,30 @@ describe("", () => { }); it("renders home button", async () => { - const { getByTestId } = render(); + const { findByTestId } = render(); - expect(await getByTestId("home-button")).toBeInTheDocument(); + expect(await findByTestId("home-button")).toBeInTheDocument(); }); it("renders history arrows", async () => { - const { getByTestId } = render(); + const { findByTestId } = render(); - expect(await getByTestId("history-back")).toBeInTheDocument(); - expect(await getByTestId("history-forward")).toBeInTheDocument(); + expect(await findByTestId("history-back")).toBeInTheDocument(); + expect(await findByTestId("history-forward")).toBeInTheDocument(); }); it("enables arrow by ipc event", async () => { - const { getByTestId } = render(); + const { findByTestId } = render(); - expect(await getByTestId("history-back")).not.toHaveClass("disabled"); - expect(await getByTestId("history-forward")).not.toHaveClass("disabled"); + expect(await findByTestId("history-back")).not.toHaveClass("disabled"); + expect(await findByTestId("history-forward")).not.toHaveClass("disabled"); }); it("triggers browser history back and forward", async () => { - const { getByTestId } = render(); + const { findByTestId } = render(); - const prevButton = await getByTestId("history-back"); - const nextButton = await getByTestId("history-forward"); + const prevButton = await findByTestId("history-back"); + const nextButton = await findByTestId("history-forward"); fireEvent.click(prevButton); @@ -144,12 +144,15 @@ describe("", () => { }, ])); - const { getByTestId } = render(); + const { findByTestId } = render(); - expect(await getByTestId(testId)).toHaveTextContent(text); + expect(await findByTestId(testId)).toHaveTextContent(text); }); - it("doesn't show windows title buttons", () => { + it("doesn't show windows title buttons on macos", () => { + di.override(isLinuxInjectable, () => false); + di.override(isWindowsInjectable, () => false); + const { queryByTestId } = render(); expect(queryByTestId("window-menu")).not.toBeInTheDocument(); @@ -157,4 +160,16 @@ describe("", () => { expect(queryByTestId("window-maximize")).not.toBeInTheDocument(); expect(queryByTestId("window-close")).not.toBeInTheDocument(); }); + + it("does show windows title buttons on linux", () => { + di.override(isLinuxInjectable, () => true); + di.override(isWindowsInjectable, () => false); + + const { queryByTestId } = render(); + + expect(queryByTestId("window-menu")).toBeInTheDocument(); + expect(queryByTestId("window-minimize")).toBeInTheDocument(); + expect(queryByTestId("window-maximize")).toBeInTheDocument(); + expect(queryByTestId("window-close")).toBeInTheDocument(); + }); }); diff --git a/src/renderer/components/layout/top-bar/top-bar.tsx b/src/renderer/components/layout/top-bar/top-bar.tsx index dc801a5424..3030cbad57 100644 --- a/src/renderer/components/layout/top-bar/top-bar.tsx +++ b/src/renderer/components/layout/top-bar/top-bar.tsx @@ -13,18 +13,21 @@ import { ipcRendererOn } from "../../../../common/ipc"; import { watchHistoryState } from "../../../remote-helpers/history-updater"; import { isActiveRoute, navigate } from "../../../navigation"; import { catalogRoute, catalogURL } from "../../../../common/routes"; -import { isLinux, isWindows } from "../../../../common/vars"; import { cssNames } from "../../../utils"; import topBarItemsInjectable from "./top-bar-items/top-bar-items.injectable"; import { withInjectables } from "@ogre-tools/injectable-react"; import type { TopBarRegistration } from "./top-bar-registration"; import { emitOpenAppMenuAsContextMenu, requestWindowAction } from "../../../ipc"; import { WindowAction } from "../../../../common/ipc/window"; +import isLinuxInjectable from "../../../../common/vars/is-linux.injectable"; +import isWindowsInjectable from "../../../../common/vars/is-windows.injectable"; -interface Props extends React.HTMLAttributes {} +export interface TopBarProps extends React.HTMLAttributes {} interface Dependencies { items: IComputedValue; + isWindows: boolean; + isLinux: boolean; } const prevEnabled = observable.box(false); @@ -38,7 +41,7 @@ ipcRendererOn("history:can-go-forward", (event, state: boolean) => { nextEnabled.set(state); }); -const NonInjectedTopBar = (({ items, children, ...rest }: Props & Dependencies) => { +const NonInjectedTopBar = observer(({ items, children, isWindows, isLinux, ...rest }: TopBarProps & Dependencies) => { const elem = useRef(); const openAppContextMenu = () => { @@ -161,9 +164,11 @@ const renderRegisteredItems = (items: TopBarRegistration[]) => ( -export const TopBar = withInjectables(observer(NonInjectedTopBar), { +export const TopBar = withInjectables(NonInjectedTopBar, { getProps: (di, props) => ({ items: di.inject(topBarItemsInjectable), + isLinux: di.inject(isLinuxInjectable), + isWindows: di.inject(isWindowsInjectable), ...props, }), }); diff --git a/src/renderer/getDiForUnitTesting.tsx b/src/renderer/getDiForUnitTesting.tsx index 901b7edbf8..c353008371 100644 --- a/src/renderer/getDiForUnitTesting.tsx +++ b/src/renderer/getDiForUnitTesting.tsx @@ -8,8 +8,10 @@ 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"; +import writeJsonFileInjectable from "../common/fs/write-json-file.injectable"; +import readJsonFileInjectable from "../common/fs/read-json-file.injectable"; +import readDirInjectable from "../common/fs/read-dir.injectable"; +import readFileInjectable from "../common/fs/read-file.injectable"; export const getDiForUnitTesting = ({ doGeneralOverrides } = { doGeneralOverrides: false }) => { const di = createContainer(); @@ -31,6 +33,14 @@ export const getDiForUnitTesting = ({ doGeneralOverrides } = { doGeneralOverride if (doGeneralOverrides) { di.override(getValueFromRegisteredChannelInjectable, () => () => undefined); + di.override(readDirInjectable, () => () => { + throw new Error("Tried to read contents of a directory from file system without specifying explicit override."); + }); + + di.override(readFileInjectable, () => () => { + throw new Error("Tried to read a file from file system without specifying explicit override."); + }); + di.override(writeJsonFileInjectable, () => () => { throw new Error("Tried to write JSON file to file system without specifying explicit override."); }); diff --git a/src/renderer/kube-watch-api/kube-watch-api.ts b/src/renderer/kube-watch-api/kube-watch-api.ts index 4b9a802723..f38ffcec1c 100644 --- a/src/renderer/kube-watch-api/kube-watch-api.ts +++ b/src/renderer/kube-watch-api/kube-watch-api.ts @@ -27,7 +27,6 @@ class WrappedAbortController extends AbortController { interface SubscribeStoreParams { store: KubeObjectStore; parent: AbortController; - watchChanges: boolean; namespaces: string[]; onLoadFailure?: (err: any) => void; } @@ -75,7 +74,7 @@ export interface KubeWatchSubscribeStoreOptions { /** * A function that is called when listing fails. If set then blocks errors - * being rejected with + * from rejecting promises */ onLoadFailure?: (err: any) => void; } @@ -89,12 +88,16 @@ export class KubeWatchApi { constructor(private dependencies: Dependencies) {} - private subscribeStore({ store, parent, watchChanges, namespaces, onLoadFailure }: SubscribeStoreParams): Disposer { - if (this.#watch.inc(store) > 1) { + private subscribeStore({ store, parent, namespaces, onLoadFailure }: SubscribeStoreParams): Disposer { + const isNamespaceFilterWatch = !namespaces; + + if (isNamespaceFilterWatch && this.#watch.inc(store) > 1) { // don't load or subscribe to a store more than once return () => this.#watch.dec(store); } + namespaces ??= this.dependencies.clusterFrameContext?.contextNamespaces ?? []; + let childController = new WrappedAbortController(parent); const unsubscribe = disposer(); @@ -117,7 +120,7 @@ export class KubeWatchApi { */ loadThenSubscribe(namespaces).catch(noop); - const cancelReloading = watchChanges + const cancelReloading = isNamespaceFilterWatch && store.api.isNamespaced ? reaction( // Note: must slice because reaction won't fire if it isn't there () => [this.dependencies.clusterFrameContext.contextNamespaces.slice(), this.dependencies.clusterFrameContext.hasSelectedAll] as const, @@ -141,7 +144,7 @@ export class KubeWatchApi { : noop; // don't watch namespaces if namespaces were provided return () => { - if (this.#watch.dec(store) === 0) { + if (isNamespaceFilterWatch && this.#watch.dec(store) === 0) { // only stop the subcribe if this is the last one cancelReloading(); childController.abort(); @@ -156,8 +159,7 @@ export class KubeWatchApi { ...stores.map(store => this.subscribeStore({ store, parent, - watchChanges: !namespaces && store.api.isNamespaced, - namespaces: namespaces ?? this.dependencies.clusterFrameContext?.contextNamespaces ?? [], + namespaces, onLoadFailure, })), ); diff --git a/src/renderer/kube-watch-api/subscribe-stores.injectable.ts b/src/renderer/kube-watch-api/subscribe-stores.injectable.ts new file mode 100644 index 0000000000..35eb3a722e --- /dev/null +++ b/src/renderer/kube-watch-api/subscribe-stores.injectable.ts @@ -0,0 +1,13 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import kubeWatchApiInjectable from "./kube-watch-api.injectable"; + +const subscribeStoresInjectable = getInjectable({ + instantiate: (di) => di.inject(kubeWatchApiInjectable).subscribeStores, + lifecycle: lifecycleEnum.singleton, +}); + +export default subscribeStoresInjectable; 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 0000000000..3d5d08f484 --- /dev/null +++ b/src/renderer/search-store/search-store.injectable.ts @@ -0,0 +1,13 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { SearchStore } from "./search-store"; + +const searchStoreInjectable = getInjectable({ + instantiate: () => new SearchStore(), + lifecycle: lifecycleEnum.transient, +}); + +export default searchStoreInjectable; diff --git a/src/renderer/search-store/search-store.test.ts b/src/renderer/search-store/search-store.test.ts index fa8623274c..f3fc9a4289 100644 --- a/src/renderer/search-store/search-store.test.ts +++ b/src/renderer/search-store/search-store.test.ts @@ -9,6 +9,7 @@ import { stdout, stderr } from "process"; import { getDiForUnitTesting } from "../getDiForUnitTesting"; import directoryForUserDataInjectable from "../../common/app-paths/directory-for-user-data/directory-for-user-data.injectable"; +import searchStoreInjectable from "./search-store.injectable"; jest.mock("electron", () => ({ app: { @@ -34,7 +35,7 @@ describe("search store tests", () => { await di.runSetups(); - searchStore = new SearchStore(); + searchStore = di.inject(searchStoreInjectable); }); it("does nothing with empty search query", () => { diff --git a/src/renderer/utils/create-storage/create-storage.injectable.ts b/src/renderer/utils/create-storage/create-storage.injectable.ts index 1f665bd002..729123a3ad 100644 --- a/src/renderer/utils/create-storage/create-storage.injectable.ts +++ b/src/renderer/utils/create-storage/create-storage.injectable.ts @@ -5,8 +5,8 @@ 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"; +import readJsonFileInjectable from "../../../common/fs/read-json-file.injectable"; +import writeJsonFileInjectable from "../../../common/fs/write-json-file.injectable"; const createStorageInjectable = getInjectable({ instantiate: (di) => diff --git a/src/renderer/utils/save-file.injectable.ts b/src/renderer/utils/save-file.injectable.ts new file mode 100644 index 0000000000..593c6955dc --- /dev/null +++ b/src/renderer/utils/save-file.injectable.ts @@ -0,0 +1,13 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { saveFileDialog } from "./saveFile"; + +const openSaveFileDialogInjectable = getInjectable({ + instantiate: () => saveFileDialog, + lifecycle: lifecycleEnum.singleton, +}); + +export default openSaveFileDialogInjectable; diff --git a/webpack.extensions.ts b/webpack.extensions.ts index a4b06bf9f8..330684adb4 100644 --- a/webpack.extensions.ts +++ b/webpack.extensions.ts @@ -34,6 +34,10 @@ export default function generateExtensionTypes(): webpack.Configuration { stats: "errors-warnings", module: { rules: [ + { + test: /\.node$/, + loader: "ignore-loader", + }, { test: /\.tsx?$/, loader: "ts-loader", diff --git a/yarn.lock b/yarn.lock index dd2ff2256c..226a290633 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7135,6 +7135,11 @@ ignore-by-default@^1.0.1: resolved "https://registry.yarnpkg.com/ignore-by-default/-/ignore-by-default-1.0.1.tgz#48ca6d72f6c6a3af00a9ad4ae6876be3889e2b09" integrity sha1-SMptcvbGo68Aqa1K5odr44ieKwk= +ignore-loader@^0.1.2: + version "0.1.2" + resolved "https://registry.yarnpkg.com/ignore-loader/-/ignore-loader-0.1.2.tgz#d81f240376d0ba4f0d778972c3ad25874117a463" + integrity sha1-2B8kA3bQuk8Nd4lyw60lh0EXpGM= + ignore-walk@^3.0.1: version "3.0.3" resolved "https://registry.yarnpkg.com/ignore-walk/-/ignore-walk-3.0.3.tgz#017e2447184bfeade7c238e4aefdd1e8f95b1e37"