diff --git a/.yarnrc b/.yarnrc index 756f21586a..c8e7a49bbe 100644 --- a/.yarnrc +++ b/.yarnrc @@ -1,3 +1,3 @@ disturl "https://atom.io/download/electron" -target "13.6.1" +target "14.2.4" runtime "electron" diff --git a/docs/extensions/guides/catalog.md b/docs/extensions/guides/catalog.md index f428f323e0..6ddc5755c7 100644 --- a/docs/extensions/guides/catalog.md +++ b/docs/extensions/guides/catalog.md @@ -57,6 +57,14 @@ Will register a new view for the KubernetesCluster category, and because the pri The default list view has a priority of 50 and and custom views with priority (defaulting to 50) >= 50 will be displayed afterwards. +#### Styling Custom Views + +By default, custom view blocks are styled with [Flexbox](https://developer.mozilla.org/en-US/docs/Learn/CSS/CSS_layout/Flexbox). Some details comes from this. + +- To set fixed height of a custom block, use `max-height` css rule. +- To set flexible height, use `height`. +- Otherwise, custom view will have height of it's contents. + ## Entities An entity is the data within the catalog. diff --git a/integration/__tests__/app-preferences.tests.ts b/integration/__tests__/app-preferences.tests.ts index 98b7fd41d0..d342106631 100644 --- a/integration/__tests__/app-preferences.tests.ts +++ b/integration/__tests__/app-preferences.tests.ts @@ -11,6 +11,7 @@ */ import type { ElectronApplication, Page } from "playwright"; import * as utils from "../helpers/utils"; +import { isWindows } from "../../src/common/vars"; describe("preferences page tests", () => { let window: Page, cleanup: () => Promise; @@ -33,7 +34,8 @@ describe("preferences page tests", () => { await cleanup(); }, 10*60*1000); - it('shows "preferences" and can navigate through the tabs', async () => { + // skip on windows due to suspected playwright issue with Electron 14 + utils.itIf(!isWindows)('shows "preferences" and can navigate through the tabs', async () => { const pages = [ { id: "application", diff --git a/integration/__tests__/command-palette.tests.ts b/integration/__tests__/command-palette.tests.ts index da45b078f6..b1d43bcd2e 100644 --- a/integration/__tests__/command-palette.tests.ts +++ b/integration/__tests__/command-palette.tests.ts @@ -5,6 +5,7 @@ import type { ElectronApplication, Page } from "playwright"; import * as utils from "../helpers/utils"; +import { isWindows } from "../../src/common/vars"; describe("Lens command palette", () => { let window: Page, cleanup: () => Promise, app: ElectronApplication; @@ -19,7 +20,8 @@ describe("Lens command palette", () => { }, 10*60*1000); describe("menu", () => { - it("opens command dialog from menu", async () => { + // skip on windows due to suspected playwright issue with Electron 14 + utils.itIf(!isWindows)("opens command dialog from menu", async () => { await app.evaluate(async ({ app }) => { await app.applicationMenu .getMenuItemById("view") diff --git a/integration/helpers/utils.ts b/integration/helpers/utils.ts index b2a4f2ed4f..dff57bc356 100644 --- a/integration/helpers/utils.ts +++ b/integration/helpers/utils.ts @@ -40,7 +40,7 @@ async function getMainWindow(app: ElectronApplication, timeout = 50_000): Promis throw new Error(`Lens did not open the main window within ${timeout}ms`); } -export async function start() { +async function attemptStart() { const CICD = path.join(os.tmpdir(), "lens-integration-testing", uuid.v4()); // Make sure that the directory is clear @@ -77,6 +77,19 @@ export async function start() { } } +export async function start() { + // this is an attempted workaround for an issue with playwright not always getting the main window when using Electron 14.2.4 (observed on windows) + for (let i = 0; ; i++) { + try { + return await attemptStart(); + } catch (error) { + if (i === 4) { + throw error; + } + } + } +} + export async function clickWelcomeButton(window: Page) { await window.click("[data-testid=welcome-menu-container] li a"); } diff --git a/package.json b/package.json index 03ff28fa93..398eff127c 100644 --- a/package.json +++ b/package.json @@ -191,7 +191,6 @@ } }, "dependencies": { - "@electron/remote": "^1.2.2", "@hapi/call": "^8.0.1", "@hapi/subtext": "^7.0.3", "@kubernetes/client-node": "^0.16.1", @@ -334,7 +333,7 @@ "css-loader": "^5.2.7", "deepdash": "^5.3.9", "dompurify": "^2.3.4", - "electron": "^13.6.1", + "electron": "^14.2.4", "electron-builder": "^22.14.5", "electron-notarize": "^0.3.0", "esbuild": "^0.13.15", diff --git a/src/common/catalog-entities/kubernetes-cluster.ts b/src/common/catalog-entities/kubernetes-cluster.ts index 813d5d3866..dcc732a2ca 100644 --- a/src/common/catalog-entities/kubernetes-cluster.ts +++ b/src/common/catalog-entities/kubernetes-cluster.ts @@ -5,12 +5,12 @@ import { catalogCategoryRegistry } from "../catalog/catalog-category-registry"; import { CatalogEntity, CatalogEntityActionContext, CatalogEntityContextMenuContext, CatalogEntityMetadata, CatalogEntityStatus, CatalogCategory, CatalogCategorySpec } from "../catalog"; -import { clusterActivateHandler, clusterDisconnectHandler } from "../cluster-ipc"; import { ClusterStore } from "../cluster-store/cluster-store"; -import { broadcastMessage, requestMain } from "../ipc"; +import { broadcastMessage } from "../ipc"; import { app } from "electron"; import type { CatalogEntitySpec } from "../catalog/catalog-entity"; import { IpcRendererNavigationEvents } from "../../renderer/navigation/events"; +import { requestClusterActivation, requestClusterDisconnection } from "../../renderer/ipc"; export interface KubernetesClusterPrometheusMetrics { address?: { @@ -69,7 +69,7 @@ export class KubernetesCluster extends CatalogEntity requestMain(clusterDisconnectHandler, this.metadata.uid), + onClick: () => requestClusterDisconnection(this.metadata.uid), }); break; case LensKubernetesClusterStatus.DISCONNECTED: diff --git a/src/common/cluster-store/cluster-store.ts b/src/common/cluster-store/cluster-store.ts index 41617e11a8..aebdb79045 100644 --- a/src/common/cluster-store/cluster-store.ts +++ b/src/common/cluster-store/cluster-store.ts @@ -2,7 +2,7 @@ * Copyright (c) OpenLens Authors. All rights reserved. * Licensed under MIT License. See LICENSE in root directory for more information. */ - + import { ipcMain, ipcRenderer, webFrame } from "electron"; import { action, comparer, computed, makeObservable, observable, reaction } from "mobx"; @@ -11,16 +11,16 @@ import { Cluster } from "../cluster/cluster"; import migrations from "../../migrations/cluster-store"; import logger from "../../main/logger"; import { appEventBus } from "../app-event-bus/event-bus"; -import { ipcMainHandle, requestMain } from "../ipc"; +import { ipcMainHandle } from "../ipc"; import { disposer, toJS } from "../utils"; import type { ClusterModel, ClusterId, ClusterState } from "../cluster-types"; +import { requestInitialClusterStates } from "../../renderer/ipc"; +import { clusterStates } from "../ipc/cluster"; export interface ClusterStoreModel { clusters?: ClusterModel[]; } -const initialStates = "cluster:states"; - interface Dependencies { createCluster: (model: ClusterModel) => Cluster } @@ -49,18 +49,18 @@ export class ClusterStore extends BaseStore { async loadInitialOnRenderer() { logger.info("[CLUSTER-STORE] requesting initial state sync"); - for (const { id, state } of await requestMain(initialStates)) { + for (const { id, state } of await requestInitialClusterStates()) { this.getById(id)?.setState(state); } } provideInitialFromMain() { - ipcMainHandle(initialStates, () => { - return this.clustersList.map(cluster => ({ + ipcMainHandle(clusterStates, () => ( + this.clustersList.map(cluster => ({ id: cluster.id, state: cluster.getState(), - })); - }); + })) + )); } protected pushStateToViewsAutomatically() { diff --git a/src/common/cluster/cluster.ts b/src/common/cluster/cluster.ts index fcb790243e..6a4a9b8746 100644 --- a/src/common/cluster/cluster.ts +++ b/src/common/cluster/cluster.ts @@ -5,7 +5,7 @@ import { ipcMain } from "electron"; import { action, comparer, computed, makeObservable, observable, reaction, when } from "mobx"; -import { broadcastMessage, ClusterListNamespaceForbiddenChannel } from "../ipc"; +import { broadcastMessage } from "../ipc"; import type { ContextHandler } from "../../main/context-handler/context-handler"; import { AuthorizationV1Api, CoreV1Api, HttpError, KubeConfig, V1ResourceAttributes } from "@kubernetes/client-node"; import type { Kubectl } from "../../main/kubectl/kubectl"; @@ -20,6 +20,7 @@ import type { ClusterState, ClusterRefreshOptions, ClusterMetricsResourceType, C import { ClusterMetadataKey, initialNodeShellImage, ClusterStatus } from "../cluster-types"; import { disposer, toJS } from "../utils"; import type { Response } from "request"; +import { clusterListNamespaceForbiddenChannel } from "../ipc/cluster"; interface Dependencies { directoryForKubeConfigs: string, @@ -645,7 +646,7 @@ export class Cluster implements ClusterModel, ClusterState { const { response } = error as HttpError & { response: Response }; logger.info("[CLUSTER]: listing namespaces is forbidden, broadcasting", { clusterId: this.id, error: response.body }); - broadcastMessage(ClusterListNamespaceForbiddenChannel, this.id); + broadcastMessage(clusterListNamespaceForbiddenChannel, this.id); } return namespaceList; diff --git a/src/common/hotbar-store.ts b/src/common/hotbar-store.ts index 9375ba7231..3b4593111e 100644 --- a/src/common/hotbar-store.ts +++ b/src/common/hotbar-store.ts @@ -10,8 +10,9 @@ import { toJS } from "./utils"; import { CatalogEntity } from "./catalog"; import { catalogEntity } from "../main/catalog-sources/general"; import logger from "../main/logger"; -import { broadcastMessage, HotbarTooManyItems } from "./ipc"; +import { broadcastMessage } from "./ipc"; import { defaultHotbarCells, getEmptyHotbar, Hotbar, CreateHotbarData, CreateHotbarOptions } from "./hotbar-types"; +import { hotbarTooManyItemsChannel } from "./ipc/hotbar"; export interface HotbarStoreModel { hotbars: Hotbar[]; @@ -182,7 +183,7 @@ export class HotbarStore extends BaseStore { if (emptyCellIndex != -1) { hotbar.items[emptyCellIndex] = newItem; } else { - broadcastMessage(HotbarTooManyItems); + broadcastMessage(hotbarTooManyItemsChannel); } } else if (0 <= cellIndex && cellIndex < hotbar.items.length) { hotbar.items[cellIndex] = newItem; diff --git a/src/common/ipc/catalog.ts b/src/common/ipc/catalog.ts index 6bc4492083..3d316d211c 100644 --- a/src/common/ipc/catalog.ts +++ b/src/common/ipc/catalog.ts @@ -3,14 +3,17 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -export enum CatalogIpcEvents { - /** - * This is broadcast on whenever there is an update to any catalog item - */ - ITEMS = "catalog:items", +/** + * This is used to activate a specific entity in the renderer main frame + */ +export const catalogEntityRunListener = "catalog-entity:run"; - /** - * This can be sent from renderer to main to initialize a broadcast of ITEMS - */ - INIT = "catalog:init", -} +/** + * This is broadcast on whenever there is an update to any catalog item + */ +export const catalogItemsChannel = "catalog:items"; + +/** + * This can be sent from renderer to main to initialize a broadcast of ITEMS + */ +export const catalogInitChannel = "catalog:init"; diff --git a/src/common/ipc/cluster.ipc.ts b/src/common/ipc/cluster.ipc.ts deleted file mode 100644 index 5e44735b23..0000000000 --- a/src/common/ipc/cluster.ipc.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. - */ - -/** - * This channel is broadcast on whenever the cluster fails to list namespaces - * during a refresh and no `accessibleNamespaces` have been set. - */ -export const ClusterListNamespaceForbiddenChannel = "cluster:list-namespace-forbidden"; - -export type ListNamespaceForbiddenArgs = [clusterId: string]; - -export function isListNamespaceForbiddenArgs(args: unknown[]): args is ListNamespaceForbiddenArgs { - return args.length === 1 && typeof args[0] === "string"; -} diff --git a/src/common/cluster-ipc.ts b/src/common/ipc/cluster.ts similarity index 60% rename from src/common/cluster-ipc.ts rename to src/common/ipc/cluster.ts index 373bafcfad..9f69ff42d5 100644 --- a/src/common/cluster-ipc.ts +++ b/src/common/ipc/cluster.ts @@ -13,3 +13,16 @@ export const clusterSetDeletingHandler = "cluster:deleting:set"; export const clusterClearDeletingHandler = "cluster:deleting:clear"; export const clusterKubectlApplyAllHandler = "cluster:kubectl-apply-all"; export const clusterKubectlDeleteAllHandler = "cluster:kubectl-delete-all"; +export const clusterStates = "cluster:states"; + +/** + * This channel is broadcast on whenever the cluster fails to list namespaces + * during a refresh and no `accessibleNamespaces` have been set. + */ +export const clusterListNamespaceForbiddenChannel = "cluster:list-namespace-forbidden"; + +export type ListNamespaceForbiddenArgs = [clusterId: string]; + +export function isListNamespaceForbiddenArgs(args: unknown[]): args is ListNamespaceForbiddenArgs { + return args.length === 1 && typeof args[0] === "string"; +} diff --git a/src/renderer/remote-helpers/index.ts b/src/common/ipc/dialog.ts similarity index 67% rename from src/renderer/remote-helpers/index.ts rename to src/common/ipc/dialog.ts index b15edbcc83..eab621a280 100644 --- a/src/renderer/remote-helpers/index.ts +++ b/src/common/ipc/dialog.ts @@ -3,6 +3,4 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import * as dialog from "./dialog"; - -export { dialog }; +export const openFilePickingDialogChannel = "dialog:open:file-picking"; diff --git a/src/common/ipc/extension-handling.ts b/src/common/ipc/extension-handling.ts new file mode 100644 index 0000000000..1474a6d18d --- /dev/null +++ b/src/common/ipc/extension-handling.ts @@ -0,0 +1,9 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +export const extensionDiscoveryStateChannel = "extension-discovery:state"; +export const bundledExtensionsLoaded = "extension-loader:bundled-extensions-loaded"; +export const extensionLoaderFromMainChannel = "extension-loader:main:state"; +export const extensionLoaderFromRendererChannel = "extension-loader:renderer:state"; diff --git a/src/common/ipc/extension-loader.ipc.ts b/src/common/ipc/extension-loader.ipc.ts deleted file mode 100644 index 48bfedd970..0000000000 --- a/src/common/ipc/extension-loader.ipc.ts +++ /dev/null @@ -1,5 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ -export const BundledExtensionsLoaded = "extension-loader:bundled-extensions-loaded"; diff --git a/src/common/ipc/hotbar.ts b/src/common/ipc/hotbar.ts index e245e5dfb8..3617ebd6c5 100644 --- a/src/common/ipc/hotbar.ts +++ b/src/common/ipc/hotbar.ts @@ -3,4 +3,4 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -export const HotbarTooManyItems = "hotbar:too-many-items"; +export const hotbarTooManyItemsChannel = "hotbar:too-many-items"; diff --git a/src/common/ipc/index.ts b/src/common/ipc/index.ts index 9b5bb3a663..60ae46438e 100644 --- a/src/common/ipc/index.ts +++ b/src/common/ipc/index.ts @@ -3,13 +3,7 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -export const dialogShowOpenDialogHandler = "dialog:show-open-dialog"; -export const catalogEntityRunListener = "catalog-entity:run"; - export * from "./ipc"; export * from "./invalid-kubeconfig"; -export * from "./update-available.ipc"; -export * from "./cluster.ipc"; +export * from "./update-available"; export * from "./type-enforced-ipc"; -export * from "./hotbar"; -export * from "./extension-loader.ipc"; diff --git a/src/common/ipc/ipc.ts b/src/common/ipc/ipc.ts index 78ef1f3a16..b875a7d30d 100644 --- a/src/common/ipc/ipc.ts +++ b/src/common/ipc/ipc.ts @@ -12,25 +12,8 @@ import { toJS } from "../utils/toJS"; import logger from "../../main/logger"; import { ClusterFrameInfo, clusterFrameMap } from "../cluster-frames"; import type { Disposer } from "../utils"; -import type remote from "@electron/remote"; -const electronRemote = (() => { - if (ipcRenderer) { - try { - return require("@electron/remote"); - } catch { - // ignore temp - } - } - - return null; -})(); - -const subFramesChannel = "ipc:get-sub-frames"; - -export async function requestMain(channel: string, ...args: any[]) { - return ipcRenderer.invoke(channel, ...args.map(sanitizePayload)); -} +export const broadcastMainChannel = "ipc:broadcast-main"; export function ipcMainHandle(channel: string, listener: (event: Electron.IpcMainInvokeEvent, ...args: any[]) => any) { ipcMain.handle(channel, async (event, ...args) => { @@ -42,51 +25,55 @@ function getSubFrames(): ClusterFrameInfo[] { return Array.from(clusterFrameMap.values()); } -export function broadcastMessage(channel: string, ...args: any[]) { - const subFramesP = ipcRenderer - ? requestMain(subFramesChannel) - : Promise.resolve(getSubFrames()); +export async function broadcastMessage(channel: string, ...args: any[]): Promise { + if (ipcRenderer) { + return ipcRenderer.invoke(broadcastMainChannel, channel, ...args.map(sanitizePayload)); + } - subFramesP - .then(subFrames => { - const views: undefined | ReturnType | ReturnType = (webContents || electronRemote?.webContents)?.getAllWebContents(); + if (!webContents) { + return; + } - if (!views || !Array.isArray(views) || views.length === 0) return; - args = args.map(sanitizePayload); + ipcMain.listeners(channel).forEach((func) => func({ + processId: undefined, frameId: undefined, sender: undefined, senderFrame: undefined, + }, ...args)); - ipcRenderer?.send(channel, ...args); - ipcMain?.emit(channel, ...args); + const subFrames = getSubFrames(); + const views = webContents.getAllWebContents(); - for (const view of views) { - let viewType = "unknown"; + if (!views || !Array.isArray(views) || views.length === 0) return; - // There will be a uncaught exception if the view is destroyed. - try { - viewType = view.getType(); - } catch { - // We can ignore the view destroyed exception as viewType is only used for logging. - } + args = args.map(sanitizePayload); - // Send message to views. - try { - logger.silly(`[IPC]: broadcasting "${channel}" to ${viewType}=${view.id}`, { args }); - view.send(channel, ...args); - } catch (error) { - logger.error(`[IPC]: failed to send IPC message "${channel}" to view "${viewType}=${view.id}"`, { error: String(error) }); - } + for (const view of views) { + let viewType = "unknown"; - // Send message to subFrames of views. - for (const frameInfo of subFrames) { - logger.silly(`[IPC]: broadcasting "${channel}" to subframe "frameInfo.processId"=${frameInfo.processId} "frameInfo.frameId"=${frameInfo.frameId}`, { args }); + // There will be a uncaught exception if the view is destroyed. + try { + viewType = view.getType(); + } catch { + // We can ignore the view destroyed exception as viewType is only used for logging. + } - try { - view.sendToFrame([frameInfo.processId, frameInfo.frameId], channel, ...args); - } catch (error) { - logger.error(`[IPC]: failed to send IPC message "${channel}" to view "${viewType}=${view.id}"'s subframe "frameInfo.processId"=${frameInfo.processId} "frameInfo.frameId"=${frameInfo.frameId}`, { error: String(error) }); - } - } + // Send message to views. + try { + logger.silly(`[IPC]: broadcasting "${channel}" to ${viewType}=${view.id}`, { args }); + view.send(channel, ...args); + } catch (error) { + logger.error(`[IPC]: failed to send IPC message "${channel}" to view "${viewType}=${view.id}"`, { error }); + } + + // Send message to subFrames of views. + for (const frameInfo of subFrames) { + logger.silly(`[IPC]: broadcasting "${channel}" to subframe "frameInfo.processId"=${frameInfo.processId} "frameInfo.frameId"=${frameInfo.frameId}`, { args }); + + try { + view.sendToFrame([frameInfo.processId, frameInfo.frameId], channel, ...args); + } catch (error) { + logger.error(`[IPC]: failed to send IPC message "${channel}" to view "${viewType}=${view.id}"'s subframe "frameInfo.processId"=${frameInfo.processId} "frameInfo.frameId"=${frameInfo.frameId}`, { error: String(error) }); } - }); + } + } } export function ipcMainOn(channel: string, listener: (event: Electron.IpcMainEvent, ...args: any[]) => any): Disposer { @@ -101,10 +88,6 @@ export function ipcRendererOn(channel: string, listener: (event: Electron.IpcRen return () => ipcRenderer.off(channel, listener); } -export function bindBroadcastHandlers() { - ipcMainHandle(subFramesChannel, () => getSubFrames()); -} - /** * Sanitizing data for IPC-messaging before send. * Removes possible observable values to avoid exceptions like "can't clone object". diff --git a/src/common/ipc/update-available.ipc.ts b/src/common/ipc/update-available.ts similarity index 100% rename from src/common/ipc/update-available.ipc.ts rename to src/common/ipc/update-available.ts diff --git a/src/common/ipc/window.ts b/src/common/ipc/window.ts new file mode 100644 index 0000000000..23a83177c6 --- /dev/null +++ b/src/common/ipc/window.ts @@ -0,0 +1,39 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +export const windowActionHandleChannel = "window:window-action"; +export const windowOpenAppMenuAsContextMenuChannel = "window:open-app-context-menu"; +export const windowLocationChangedChannel = "window:location-changed"; + +/** + * The supported actions on the current window. The argument for `windowActionHandleChannel` + */ +export enum WindowAction { + /** + * Request that the current window goes back one step of browser history + */ + GO_BACK = "back", + + /** + * Request that the current window goes forward one step of browser history + */ + GO_FORWARD = "forward", + + /** + * Request that the current window is minimized + */ + MINIMIZE = "minimize", + + /** + * Request that the current window is maximized if it isn't, or unmaximized + * if it is + */ + TOGGLE_MAXIMIZE = "toggle-maximize", + + /** + * Request that the current window is closed + */ + CLOSE = "close", +} diff --git a/src/common/k8s/resource-stack.ts b/src/common/k8s/resource-stack.ts index 08aae15d0b..29ade4b84a 100644 --- a/src/common/k8s/resource-stack.ts +++ b/src/common/k8s/resource-stack.ts @@ -9,11 +9,10 @@ import { ResourceApplier } from "../../main/resource-applier"; import type { KubernetesCluster } from "../catalog-entities"; import logger from "../../main/logger"; import { app } from "electron"; -import { requestMain } from "../ipc"; -import { clusterKubectlApplyAllHandler, clusterKubectlDeleteAllHandler } from "../cluster-ipc"; import { ClusterStore } from "../cluster-store/cluster-store"; import yaml from "js-yaml"; import { productName } from "../vars"; +import { requestKubectlApplyAll, requestKubectlDeleteAll } from "../../renderer/ipc"; export class ResourceStack { constructor(protected cluster: KubernetesCluster, protected name: string) {} @@ -41,7 +40,7 @@ export class ResourceStack { } protected async applyResources(resources: string[], extraArgs?: string[]): Promise { - const clusterModel = ClusterStore.getInstance().getById(this.cluster.metadata.uid); + const clusterModel = ClusterStore.getInstance().getById(this.cluster.getId()); if (!clusterModel) { throw new Error(`cluster not found`); @@ -54,7 +53,7 @@ export class ResourceStack { if (app) { return await new ResourceApplier(clusterModel).kubectlApplyAll(resources, kubectlArgs); } else { - const response = await requestMain(clusterKubectlApplyAllHandler, this.cluster.metadata.uid, resources, kubectlArgs); + const response = await requestKubectlApplyAll(this.cluster.getId(), resources, kubectlArgs); if (response.stderr) { throw new Error(response.stderr); @@ -65,7 +64,7 @@ export class ResourceStack { } protected async deleteResources(resources: string[], extraArgs?: string[]): Promise { - const clusterModel = ClusterStore.getInstance().getById(this.cluster.metadata.uid); + const clusterModel = ClusterStore.getInstance().getById(this.cluster.getId()); if (!clusterModel) { throw new Error(`cluster not found`); @@ -78,7 +77,7 @@ export class ResourceStack { if (app) { return await new ResourceApplier(clusterModel).kubectlDeleteAll(resources, kubectlArgs); } else { - const response = await requestMain(clusterKubectlDeleteAllHandler, this.cluster.metadata.uid, resources, kubectlArgs); + const response = await requestKubectlDeleteAll(this.cluster.getId(), resources, kubectlArgs); if (response.stderr) { throw new Error(response.stderr); diff --git a/src/common/utils/collection-functions.ts b/src/common/utils/collection-functions.ts index a58209f9ab..0d18a41e8a 100644 --- a/src/common/utils/collection-functions.ts +++ b/src/common/utils/collection-functions.ts @@ -25,3 +25,14 @@ export function getOrInsert(map: Map, key: K, value: V): V { export function getOrInsertMap(map: Map>, key: K): Map { return getOrInsert(map, key, new Map()); } + +/** + * Like `getOrInsert` but with delayed creation of the item + */ +export function getOrInsertWith(map: Map, key: K, value: () => V): V { + if (!map.has(key)) { + map.set(key, value()); + } + + return map.get(key); +} diff --git a/src/extensions/__tests__/extension-loader.test.ts b/src/extensions/__tests__/extension-loader.test.ts index fe72bea585..c9e145f8f9 100644 --- a/src/extensions/__tests__/extension-loader.test.ts +++ b/src/extensions/__tests__/extension-loader.test.ts @@ -8,8 +8,7 @@ import { Console } from "console"; import { stdout, stderr } from "process"; import extensionLoaderInjectable from "../extension-loader/extension-loader.injectable"; import { runInAction } from "mobx"; -import updateExtensionsStateInjectable - from "../extension-loader/update-extensions-state/update-extensions-state.injectable"; +import updateExtensionsStateInjectable from "../extension-loader/update-extensions-state/update-extensions-state.injectable"; import { getDisForUnitTesting } from "../../test-utils/get-dis-for-unit-testing"; import mockFs from "mock-fs"; @@ -24,7 +23,7 @@ jest.mock( () => ({ ipcRenderer: { invoke: jest.fn(async (channel: string) => { - if (channel === "extensions:main") { + if (channel === "extension-loader:main:state") { return [ [ manifestPath, @@ -61,7 +60,7 @@ jest.mock( }), on: jest.fn( (channel: string, listener: (event: any, ...args: any[]) => void) => { - if (channel === "extensions:main") { + if (channel === "extension-loader:main:state") { // First initialize with extensions 1 and 2 // and then broadcast event to remove extension 2 and add extension number 3 setTimeout(() => { diff --git a/src/extensions/extension-discovery/extension-discovery.ts b/src/extensions/extension-discovery/extension-discovery.ts index d563fcbdb5..eb957a4f0c 100644 --- a/src/extensions/extension-discovery/extension-discovery.ts +++ b/src/extensions/extension-discovery/extension-discovery.ts @@ -10,12 +10,7 @@ import fse from "fs-extra"; import { makeObservable, observable, reaction, when } from "mobx"; import os from "os"; import path from "path"; -import { - broadcastMessage, - ipcMainHandle, - ipcRendererOn, - requestMain, -} from "../../common/ipc"; +import { broadcastMessage, ipcMainHandle, ipcRendererOn } from "../../common/ipc"; import { toJS } from "../../common/utils"; import logger from "../../main/logger"; import type { ExtensionsStore } from "../extensions-store/extensions-store"; @@ -24,6 +19,8 @@ import type { LensExtensionId, LensExtensionManifest } from "../lens-extension"; import { isProduction } from "../../common/vars"; import type { ExtensionInstallationStateStore } from "../extension-installation-state-store/extension-installation-state-store"; import type { PackageJson } from "type-fest"; +import { extensionDiscoveryStateChannel } from "../../common/ipc/extension-handling"; +import { requestInitialExtensionDiscovery } from "../../renderer/ipc"; interface Dependencies { extensionLoader: ExtensionLoader; @@ -96,9 +93,6 @@ export class ExtensionDiscovery { return when(() => this.isLoaded); } - // IPC channel to broadcast changes to extension-discovery from main - protected static readonly extensionDiscoveryChannel = "extension-discovery:main"; - public events = new EventEmitter(); constructor(protected dependencies : Dependencies) { @@ -141,14 +135,14 @@ export class ExtensionDiscovery { this.isLoaded = isLoaded; }; - requestMain(ExtensionDiscovery.extensionDiscoveryChannel).then(onMessage); - ipcRendererOn(ExtensionDiscovery.extensionDiscoveryChannel, (_event, message: ExtensionDiscoveryChannelMessage) => { + requestInitialExtensionDiscovery().then(onMessage); + ipcRendererOn(extensionDiscoveryStateChannel, (_event, message: ExtensionDiscoveryChannelMessage) => { onMessage(message); }); } async initMain(): Promise { - ipcMainHandle(ExtensionDiscovery.extensionDiscoveryChannel, () => this.toJSON()); + ipcMainHandle(extensionDiscoveryStateChannel, () => this.toJSON()); reaction(() => this.toJSON(), () => { this.broadcast(); @@ -492,6 +486,6 @@ export class ExtensionDiscovery { } broadcast(): void { - broadcastMessage(ExtensionDiscovery.extensionDiscoveryChannel, this.toJSON()); + broadcastMessage(extensionDiscoveryStateChannel, this.toJSON()); } } diff --git a/src/extensions/extension-loader/extension-loader.ts b/src/extensions/extension-loader/extension-loader.ts index afe09cce19..04b552884c 100644 --- a/src/extensions/extension-loader/extension-loader.ts +++ b/src/extensions/extension-loader/extension-loader.ts @@ -8,7 +8,7 @@ import { EventEmitter } from "events"; import { isEqual } from "lodash"; import { action, computed, makeObservable, observable, observe, reaction, when } from "mobx"; import path from "path"; -import { broadcastMessage, ipcMainOn, ipcRendererOn, requestMain, ipcMainHandle } from "../../common/ipc"; +import { broadcastMessage, ipcMainOn, ipcRendererOn, ipcMainHandle } from "../../common/ipc"; import { Disposer, toJS } from "../../common/utils"; import logger from "../../main/logger"; import type { KubernetesCluster } from "../common-api/catalog"; @@ -17,6 +17,8 @@ import type { LensExtension, LensExtensionConstructor, LensExtensionId } from ". import type { LensRendererExtension } from "../lens-renderer-extension"; import * as registries from "../registries"; import type { LensExtensionState } from "../extensions-store/extensions-store"; +import { extensionLoaderFromMainChannel, extensionLoaderFromRendererChannel } from "../../common/ipc/extension-handling"; +import { requestExtensionLoaderInitialState } from "../../renderer/ipc"; const logModule = "[EXTENSIONS-LOADER]"; @@ -49,12 +51,6 @@ export class ExtensionLoader { */ protected instancesByName = observable.map(); - // IPC channel to broadcast changes to extensions from main - protected static readonly extensionsMainChannel = "extensions:main"; - - // IPC channel to broadcast changes to extensions from renderer - protected static readonly extensionsRendererChannel = "extensions:renderer"; - // emits event "remove" of type LensExtension when the extension is removed private events = new EventEmitter(); @@ -196,11 +192,11 @@ export class ExtensionLoader { this.isLoaded = true; this.loadOnMain(); - ipcMainHandle(ExtensionLoader.extensionsMainChannel, () => { + ipcMainHandle(extensionLoaderFromMainChannel, () => { return Array.from(this.toJSON()); }); - ipcMainOn(ExtensionLoader.extensionsRendererChannel, (event, extensions: [LensExtensionId, InstalledExtension][]) => { + ipcMainOn(extensionLoaderFromRendererChannel, (event, extensions: [LensExtensionId, InstalledExtension][]) => { this.syncExtensions(extensions); }); } @@ -220,16 +216,16 @@ export class ExtensionLoader { }); }; - requestMain(ExtensionLoader.extensionsMainChannel).then(extensionListHandler); - ipcRendererOn(ExtensionLoader.extensionsMainChannel, (event, extensions: [LensExtensionId, InstalledExtension][]) => { + requestExtensionLoaderInitialState().then(extensionListHandler); + ipcRendererOn(extensionLoaderFromMainChannel, (event, extensions: [LensExtensionId, InstalledExtension][]) => { extensionListHandler(extensions); }); } broadcastExtensions() { const channel = ipcRenderer - ? ExtensionLoader.extensionsRendererChannel - : ExtensionLoader.extensionsMainChannel; + ? extensionLoaderFromRendererChannel + : extensionLoaderFromMainChannel; broadcastMessage(channel, Array.from(this.extensions)); } diff --git a/src/extensions/main-api/k8s-api.ts b/src/extensions/main-api/k8s-api.ts index 31fd2ea14d..cc54ab0134 100644 --- a/src/extensions/main-api/k8s-api.ts +++ b/src/extensions/main-api/k8s-api.ts @@ -43,3 +43,5 @@ export { CustomResourceDefinition, crdApi } from "../../common/k8s-api/endpoints export type { ILocalKubeApiConfig, IRemoteKubeApiConfig, IKubeApiCluster } from "../../common/k8s-api/kube-api"; export type { IPodContainer, IPodContainerStatus } from "../../common/k8s-api/endpoints/pods.api"; export type { ISecretRef } from "../../common/k8s-api/endpoints/secret.api"; +export type { KubeObjectMetadata, KubeStatusData } from "../../common/k8s-api/kube-object"; +export type { KubeObjectStoreLoadAllParams, KubeObjectStoreLoadingParams, KubeObjectStoreSubscribeParams } from "../../common/k8s-api/kube-object.store"; diff --git a/src/extensions/renderer-api/components.ts b/src/extensions/renderer-api/components.ts index 79372c71dd..54a0f2d2d1 100644 --- a/src/extensions/renderer-api/components.ts +++ b/src/extensions/renderer-api/components.ts @@ -6,7 +6,7 @@ import { asLegacyGlobalFunctionForExtensionApi } from "../as-legacy-globals-for- import createTerminalTabInjectable from "../../renderer/components/dock/create-terminal-tab/create-terminal-tab.injectable"; import terminalStoreInjectable from "../../renderer/components/dock/terminal-store/terminal-store.injectable"; import { asLegacyGlobalObjectForExtensionApi } from "../as-legacy-globals-for-extension-api/as-legacy-global-object-for-extension-api"; -import logTabStoreInjectable from "../../renderer/components/dock/log-tab-store/log-tab-store.injectable"; +import 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"; diff --git a/src/extensions/renderer-api/k8s-api.ts b/src/extensions/renderer-api/k8s-api.ts index 266d4759f5..47a17e4427 100644 --- a/src/extensions/renderer-api/k8s-api.ts +++ b/src/extensions/renderer-api/k8s-api.ts @@ -46,6 +46,8 @@ export type { ILocalKubeApiConfig, IRemoteKubeApiConfig, IKubeApiCluster } from export type { IPodContainer, IPodContainerStatus } from "../../common/k8s-api/endpoints"; export type { ISecretRef } from "../../common/k8s-api/endpoints"; export type { KubeObjectStatus } from "./kube-object-status"; +export type { KubeObjectMetadata, KubeStatusData } from "../../common/k8s-api/kube-object"; +export type { KubeObjectStoreLoadAllParams, KubeObjectStoreLoadingParams, KubeObjectStoreSubscribeParams } from "../../common/k8s-api/kube-object.store"; // stores export type { EventStore } from "../../renderer/components/+events/event.store"; diff --git a/src/main/__test__/cluster.test.ts b/src/main/__test__/cluster.test.ts index 5e90a767cf..c1b094669f 100644 --- a/src/main/__test__/cluster.test.ts +++ b/src/main/__test__/cluster.test.ts @@ -70,8 +70,7 @@ describe("create clusters", () => { const di = getDiForUnitTesting({ doGeneralOverrides: true }); - - const mockOpts = { + mockFs({ "minikube-config.yml": JSON.stringify({ apiVersion: "v1", clusters: [{ @@ -93,9 +92,7 @@ describe("create clusters", () => { kind: "Config", preferences: {}, }), - }; - - mockFs(mockOpts); + }); await di.runSetups(); diff --git a/src/main/catalog-pusher.ts b/src/main/catalog-pusher.ts index 7a79c9f50c..14cf8cc5e9 100644 --- a/src/main/catalog-pusher.ts +++ b/src/main/catalog-pusher.ts @@ -10,15 +10,15 @@ import "../common/catalog-entities/kubernetes-cluster"; import { disposer, toJS } from "../common/utils"; import { debounce } from "lodash"; import type { CatalogEntity } from "../common/catalog"; -import { CatalogIpcEvents } from "../common/ipc/catalog"; +import { catalogInitChannel, catalogItemsChannel } from "../common/ipc/catalog"; const broadcaster = debounce((items: CatalogEntity[]) => { - broadcastMessage(CatalogIpcEvents.ITEMS, items); + broadcastMessage(catalogItemsChannel, items); }, 1_000, { leading: true, trailing: true }); export function pushCatalogToRenderer(catalog: CatalogEntityRegistry) { return disposer( - ipcMainOn(CatalogIpcEvents.INIT, () => broadcaster(toJS(catalog.items))), + ipcMainOn(catalogInitChannel, () => broadcaster(toJS(catalog.items))), reaction(() => toJS(catalog.items), (items) => { broadcaster(items); }, { diff --git a/src/main/cluster-manager.ts b/src/main/cluster-manager.ts index b7e17ff762..12924a4b18 100644 --- a/src/main/cluster-manager.ts +++ b/src/main/cluster-manager.ts @@ -3,7 +3,7 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import "../common/cluster-ipc"; +import "../common/ipc/cluster"; import type http from "http"; import { action, makeObservable, observable, observe, reaction, toJS } from "mobx"; import { Cluster } from "../common/cluster/cluster"; diff --git a/src/main/index.ts b/src/main/index.ts index 663e7d8f53..473a92c370 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -6,7 +6,6 @@ // Main process import { injectSystemCAs } from "../common/system-ca"; -import { initialize as initializeRemote } from "@electron/remote/main"; import * as Mobx from "mobx"; import * as LensExtensionsCommonApi from "../extensions/common-api"; import * as LensExtensionsMainApi from "../extensions/main-api"; @@ -24,7 +23,7 @@ import type { InstalledExtension } from "../extensions/extension-discovery/exten import type { LensExtensionId } from "../extensions/lens-extension"; import { installDeveloperTools } from "./developer-tools"; import { disposer, getAppVersion, getAppVersionFromProxyServer } from "../common/utils"; -import { bindBroadcastHandlers, ipcMainOn } from "../common/ipc"; +import { ipcMainOn } from "../common/ipc"; import { startUpdateChecking } from "./app-updater"; import { IpcRendererNavigationEvents } from "../renderer/navigation/events"; import { pushCatalogToRenderer } from "./catalog-pusher"; @@ -81,9 +80,6 @@ di.runSetups().then(() => { app.disableHardwareAcceleration(); } - logger.debug("[APP-MAIN] initializing remote"); - initializeRemote(); - logger.debug("[APP-MAIN] configuring packages"); configurePackages(); @@ -131,8 +127,6 @@ di.runSetups().then(() => { logger.info("🐚 Syncing shell environment"); await shellSync(); - bindBroadcastHandlers(); - powerMonitor.on("shutdown", () => app.exit()); registerFileProtocol("static", __static); diff --git a/src/main/initializers/init-ipc-main-handlers/init-ipc-main-handlers.ts b/src/main/initializers/init-ipc-main-handlers/init-ipc-main-handlers.ts index 53d2ed9567..2986d3319e 100644 --- a/src/main/initializers/init-ipc-main-handlers/init-ipc-main-handlers.ts +++ b/src/main/initializers/init-ipc-main-handlers/init-ipc-main-handlers.ts @@ -3,23 +3,27 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import { BrowserWindow, dialog, IpcMainInvokeEvent, Menu } from "electron"; +import { BrowserWindow, IpcMainInvokeEvent, Menu } from "electron"; import { clusterFrameMap } from "../../../common/cluster-frames"; -import { clusterActivateHandler, clusterSetFrameIdHandler, clusterVisibilityHandler, clusterRefreshHandler, clusterDisconnectHandler, clusterKubectlApplyAllHandler, clusterKubectlDeleteAllHandler, clusterDeleteHandler, clusterSetDeletingHandler, clusterClearDeletingHandler } from "../../../common/cluster-ipc"; +import { clusterActivateHandler, clusterSetFrameIdHandler, clusterVisibilityHandler, clusterRefreshHandler, clusterDisconnectHandler, clusterKubectlApplyAllHandler, clusterKubectlDeleteAllHandler, clusterDeleteHandler, clusterSetDeletingHandler, clusterClearDeletingHandler } from "../../../common/ipc/cluster"; import type { ClusterId } from "../../../common/cluster-types"; import { ClusterStore } from "../../../common/cluster-store/cluster-store"; import { appEventBus } from "../../../common/app-event-bus/event-bus"; -import { dialogShowOpenDialogHandler, ipcMainHandle, ipcMainOn } from "../../../common/ipc"; +import { broadcastMainChannel, broadcastMessage, ipcMainHandle, ipcMainOn } from "../../../common/ipc"; import { catalogEntityRegistry } from "../../catalog"; import { pushCatalogToRenderer } from "../../catalog-pusher"; import { ClusterManager } from "../../cluster-manager"; import { ResourceApplier } from "../../resource-applier"; -import { IpcMainWindowEvents, WindowManager } from "../../window-manager"; +import { WindowManager } from "../../window-manager"; import path from "path"; import { remove } from "fs-extra"; import { getAppMenu } from "../../menu/menu"; import type { MenuRegistration } from "../../menu/menu-registration"; import type { IComputedValue } from "mobx"; +import { onLocationChange, handleWindowAction } from "../../ipc/window"; +import { openFilePickingDialogChannel } from "../../../common/ipc/dialog"; +import { showOpenDialog } from "../../ipc/dialog"; +import { windowActionHandleChannel, windowLocationChangedChannel, windowOpenAppMenuAsContextMenuChannel } from "../../../common/ipc/window"; interface Dependencies { electronMenuItems: IComputedValue, @@ -136,21 +140,22 @@ export const initIpcMainHandlers = ({ electronMenuItems, directoryForLensLocalSt } }); - ipcMainHandle(dialogShowOpenDialogHandler, async (event, dialogOpts: Electron.OpenDialogOptions) => { - await WindowManager.getInstance().ensureMainWindow(); + ipcMainHandle(windowActionHandleChannel, (event, action) => handleWindowAction(action)); - return dialog.showOpenDialog(BrowserWindow.getFocusedWindow(), dialogOpts); - }); + ipcMainOn(windowLocationChangedChannel, () => onLocationChange()); - ipcMainOn(IpcMainWindowEvents.OPEN_CONTEXT_MENU, async (event) => { + ipcMainHandle(openFilePickingDialogChannel, (event, opts) => showOpenDialog(opts)); + + ipcMainHandle(broadcastMainChannel, (event, channel, ...args) => broadcastMessage(channel, ...args)); + + ipcMainOn(windowOpenAppMenuAsContextMenuChannel, async (event) => { const menu = Menu.buildFromTemplate(getAppMenu(WindowManager.getInstance(), electronMenuItems.get())); - const options = { + + menu.popup({ ...BrowserWindow.fromWebContents(event.sender), // Center of the topbar menu icon x: 20, y: 20, - } as Electron.PopupOptions; - - menu.popup(options); + }); }); }; diff --git a/src/main/ipc/dialog.ts b/src/main/ipc/dialog.ts new file mode 100644 index 0000000000..d192869ebd --- /dev/null +++ b/src/main/ipc/dialog.ts @@ -0,0 +1,12 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { BrowserWindow, dialog, OpenDialogOptions } from "electron"; + +export async function showOpenDialog(dialogOptions: OpenDialogOptions): Promise<{ canceled: boolean; filePaths: string[]; }> { + const { canceled, filePaths } = await dialog.showOpenDialog(BrowserWindow.getFocusedWindow(), dialogOptions); + + return { canceled, filePaths }; +} diff --git a/src/main/ipc/window.ts b/src/main/ipc/window.ts new file mode 100644 index 0000000000..b111eab243 --- /dev/null +++ b/src/main/ipc/window.ts @@ -0,0 +1,71 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { BrowserWindow, webContents } from "electron"; +import { broadcastMessage } from "../../common/ipc"; +import { WindowAction } from "../../common/ipc/window"; + +export function handleWindowAction(action: WindowAction) { + const window = BrowserWindow.getFocusedWindow(); + + if (!window) return; + + switch (action) { + case WindowAction.GO_BACK: { + window.webContents.goBack(); + break; + } + + case WindowAction.GO_FORWARD: { + window.webContents.goForward(); + break; + } + + case WindowAction.MINIMIZE: { + window.minimize(); + break; + } + + case WindowAction.TOGGLE_MAXIMIZE: { + if (window.isMaximized()) { + window.unmaximize(); + } else { + window.maximize(); + } + break; + } + + case WindowAction.CLOSE: { + window.close(); + break; + } + + default: + throw new Error(`Attemped window action ${action} is unknown`); + } +} + +export function onLocationChange(): void { + const getAllWebContents = webContents.getAllWebContents(); + + const canGoBack = getAllWebContents.some((webContent) => { + if (webContent.getType() === "window") { + return webContent.canGoBack(); + } + + return false; + }); + + const canGoForward = getAllWebContents.some((webContent) => { + if (webContent.getType() === "window") { + return webContent.canGoForward(); + } + + return false; + }); + + broadcastMessage("history:can-go-back", canGoBack); + broadcastMessage("history:can-go-forward", canGoForward); +} diff --git a/src/main/window-manager.ts b/src/main/window-manager.ts index 116f02aefe..903c9d8525 100644 --- a/src/main/window-manager.ts +++ b/src/main/window-manager.ts @@ -8,17 +8,14 @@ import { makeObservable, observable } from "mobx"; import { app, BrowserWindow, dialog, ipcMain, shell, webContents } from "electron"; import windowStateKeeper from "electron-window-state"; import { appEventBus } from "../common/app-event-bus/event-bus"; -import { BundledExtensionsLoaded, ipcMainOn } from "../common/ipc"; +import { ipcMainOn } from "../common/ipc"; import { delay, iter, Singleton } from "../common/utils"; import { ClusterFrameInfo, clusterFrameMap } from "../common/cluster-frames"; import { IpcRendererNavigationEvents } from "../renderer/navigation/events"; import logger from "./logger"; import { isMac, productName } from "../common/vars"; import { LensProxy } from "./lens-proxy"; - -export const enum IpcMainWindowEvents { - OPEN_CONTEXT_MENU = "window:open-context-menu", -} +import { bundledExtensionsLoaded } from "../common/ipc/extension-handling"; function isHideable(window: BrowserWindow | null): boolean { return Boolean(window && !window.isDestroyed()); @@ -75,9 +72,9 @@ export class WindowManager extends Singleton { webPreferences: { nodeIntegration: true, nodeIntegrationInSubFrames: true, - enableRemoteModule: true, webviewTag: true, contextIsolation: false, + nativeWindowOpen: true, }, }); this.windowState.manage(this.mainWindow); @@ -135,7 +132,8 @@ export class WindowManager extends Singleton { // Always disable Node.js integration for all webviews webPreferences.nodeIntegration = false; - }).setWindowOpenHandler((details) => { + }) + .setWindowOpenHandler((details) => { shell.openExternal(details.url); return { action: "deny" }; @@ -165,7 +163,7 @@ export class WindowManager extends Singleton { if (!this.mainWindow) { viewHasLoaded = new Promise(resolve => { - ipcMain.once(BundledExtensionsLoaded, () => resolve()); + ipcMain.once(bundledExtensionsLoaded, () => resolve()); }); await this.initMainWindow(showSplash); } @@ -249,9 +247,9 @@ export class WindowManager extends Singleton { show: false, webPreferences: { nodeIntegration: true, - enableRemoteModule: true, contextIsolation: false, nodeIntegrationInSubFrames: true, + nativeWindowOpen: true, }, }); await this.splashWindow.loadURL("static://splash.html"); diff --git a/src/renderer/api/catalog-entity-registry.ts b/src/renderer/api/catalog-entity-registry.ts index 85477245a4..b7c97aa968 100644 --- a/src/renderer/api/catalog-entity-registry.ts +++ b/src/renderer/api/catalog-entity-registry.ts @@ -4,7 +4,7 @@ */ import { computed, observable, makeObservable, action } from "mobx"; -import { catalogEntityRunListener, ipcRendererOn } from "../../common/ipc"; +import { ipcRendererOn } from "../../common/ipc"; import { CatalogCategory, CatalogEntity, CatalogEntityData, catalogCategoryRegistry, CatalogCategoryRegistry, CatalogEntityKindData } from "../../common/catalog"; import "../../common/catalog-entities"; import type { Cluster } from "../../common/cluster/cluster"; @@ -14,7 +14,7 @@ import { once } from "lodash"; import logger from "../../common/logger"; import { CatalogRunEvent } from "../../common/catalog/catalog-run-event"; import { ipcRenderer } from "electron"; -import { CatalogIpcEvents } from "../../common/ipc/catalog"; +import { catalogInitChannel, catalogItemsChannel, catalogEntityRunListener } from "../../common/ipc/catalog"; import { navigate } from "../navigation"; import { isMainFrame } from "process"; @@ -79,12 +79,12 @@ export class CatalogEntityRegistry { } init() { - ipcRendererOn(CatalogIpcEvents.ITEMS, (event, items: (CatalogEntityData & CatalogEntityKindData)[]) => { + ipcRendererOn(catalogItemsChannel, (event, items: (CatalogEntityData & CatalogEntityKindData)[]) => { this.updateItems(items); }); // Make sure that we get items ASAP and not the next time one of them changes - ipcRenderer.send(CatalogIpcEvents.INIT); + ipcRenderer.send(catalogInitChannel); if (isMainFrame) { ipcRendererOn(catalogEntityRunListener, (event, id: string) => { diff --git a/src/renderer/components/+catalog/catalog.module.scss b/src/renderer/components/+catalog/catalog.module.scss index 14f68babed..25d564eba8 100644 --- a/src/renderer/components/+catalog/catalog.module.scss +++ b/src/renderer/components/+catalog/catalog.module.scss @@ -4,14 +4,11 @@ */ .Catalog { - :global(.TableHead) .entityName { - /* offset for icon to align texts in the column */ - padding-left: calc(21px + var(--padding) * 2.5); - } - :global(.TableRow):hover .pinIcon { opacity: 1; } + + flex-grow: 1; } .entityName { @@ -128,3 +125,10 @@ .catalogAvatar { font-size: 1.2ch; } + +.views { + padding: calc(var(--padding) * 2); + display: flex; + flex-direction: column; + height: 100%; +} \ No newline at end of file diff --git a/src/renderer/components/+catalog/catalog.tsx b/src/renderer/components/+catalog/catalog.tsx index 194f35f481..3edca9ea92 100644 --- a/src/renderer/components/+catalog/catalog.tsx +++ b/src/renderer/components/+catalog/catalog.tsx @@ -282,7 +282,7 @@ class NonInjectedCatalog extends React.Component { return ( -
+
{this.renderViews()}
{ diff --git a/src/renderer/components/+extensions/extensions.tsx b/src/renderer/components/+extensions/extensions.tsx index 264118da3b..e9be90e067 100644 --- a/src/renderer/components/+extensions/extensions.tsx +++ b/src/renderer/components/+extensions/extensions.tsx @@ -27,12 +27,11 @@ import enableExtensionInjectable from "./enable-extension/enable-extension.injec import disableExtensionInjectable from "./disable-extension/disable-extension.injectable"; import confirmUninstallExtensionInjectable from "./confirm-uninstall-extension/confirm-uninstall-extension.injectable"; import installFromInputInjectable from "./install-from-input/install-from-input.injectable"; -import installFromSelectFileDialogInjectable from "./install-from-select-file-dialog/install-from-select-file-dialog.injectable"; +import installFromSelectFileDialogInjectable from "./install-from-select-file-dialog.injectable"; import type { LensExtensionId } from "../../../extensions/lens-extension"; import installOnDropInjectable from "./install-on-drop/install-on-drop.injectable"; import { supportedExtensionFormats } from "./supported-extension-formats"; -import extensionInstallationStateStoreInjectable - from "../../../extensions/extension-installation-state-store/extension-installation-state-store.injectable"; +import extensionInstallationStateStoreInjectable from "../../../extensions/extension-installation-state-store/extension-installation-state-store.injectable"; import type { ExtensionInstallationStateStore } from "../../../extensions/extension-installation-state-store/extension-installation-state-store"; interface Dependencies { @@ -107,22 +106,15 @@ class NonInjectedExtensions extends React.Component { } } -export const Extensions = withInjectables( - NonInjectedExtensions, - { - getProps: (di) => ({ - userExtensions: di.inject(userExtensionsInjectable), - enableExtension: di.inject(enableExtensionInjectable), - disableExtension: di.inject(disableExtensionInjectable), - confirmUninstallExtension: di.inject(confirmUninstallExtensionInjectable), - installFromInput: di.inject(installFromInputInjectable), - installOnDrop: di.inject(installOnDropInjectable), - - installFromSelectFileDialog: di.inject( - installFromSelectFileDialogInjectable, - ), - - extensionInstallationStateStore: di.inject(extensionInstallationStateStoreInjectable), - }), - }, -); +export const Extensions = withInjectables(NonInjectedExtensions, { + getProps: (di) => ({ + userExtensions: di.inject(userExtensionsInjectable), + enableExtension: di.inject(enableExtensionInjectable), + disableExtension: di.inject(disableExtensionInjectable), + confirmUninstallExtension: di.inject(confirmUninstallExtensionInjectable), + installFromInput: di.inject(installFromInputInjectable), + installOnDrop: di.inject(installOnDropInjectable), + installFromSelectFileDialog: di.inject(installFromSelectFileDialogInjectable), + extensionInstallationStateStore: di.inject(extensionInstallationStateStoreInjectable), + }), +}); 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 new file mode 100644 index 0000000000..5cf51733d1 --- /dev/null +++ b/src/renderer/components/+extensions/install-from-select-file-dialog.injectable.ts @@ -0,0 +1,39 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +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 { canceled, filePaths } = await requestOpenFilePickingDialog({ + defaultPath: directoryForDownloads, + properties: ["openFile", "multiSelections"], + message: `Select extensions to install (formats: ${supportedExtensionFormats.join(", ")}), `, + buttonLabel: "Use configuration", + filters: [{ name: "tarball", extensions: supportedExtensionFormats }], + }); + + if (!canceled) { + await attemptInstalls(filePaths); + } +} + +const installFromSelectFileDialogInjectable = getInjectable({ + instantiate: (di) => bind(installFromSelectFileDialog, null, { + attemptInstalls: di.inject(attemptInstallsInjectable), + directoryForDownloads: di.inject(directoryForDownloadsInjectable), + }), + lifecycle: lifecycleEnum.singleton, +}); + +export default installFromSelectFileDialogInjectable; diff --git a/src/renderer/components/+extensions/install-from-select-file-dialog/install-from-select-file-dialog.injectable.ts b/src/renderer/components/+extensions/install-from-select-file-dialog/install-from-select-file-dialog.injectable.ts deleted file mode 100644 index 8ea2d9b1a4..0000000000 --- a/src/renderer/components/+extensions/install-from-select-file-dialog/install-from-select-file-dialog.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 { installFromSelectFileDialog } from "./install-from-select-file-dialog"; -import attemptInstallsInjectable from "../attempt-installs/attempt-installs.injectable"; -import directoryForDownloadsInjectable from "../../../../common/app-paths/directory-for-downloads/directory-for-downloads.injectable"; - -const installFromSelectFileDialogInjectable = getInjectable({ - instantiate: (di) => - installFromSelectFileDialog({ - attemptInstalls: di.inject(attemptInstallsInjectable), - directoryForDownloads: di.inject(directoryForDownloadsInjectable), - }), - - lifecycle: lifecycleEnum.singleton, -}); - -export default installFromSelectFileDialogInjectable; diff --git a/src/renderer/components/+extensions/install-from-select-file-dialog/install-from-select-file-dialog.ts b/src/renderer/components/+extensions/install-from-select-file-dialog/install-from-select-file-dialog.ts deleted file mode 100644 index de98b5a7ec..0000000000 --- a/src/renderer/components/+extensions/install-from-select-file-dialog/install-from-select-file-dialog.ts +++ /dev/null @@ -1,29 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ -import { dialog } from "../../../remote-helpers"; -import { supportedExtensionFormats } from "../supported-extension-formats"; - -interface Dependencies { - attemptInstalls: (filePaths: string[]) => Promise - directoryForDownloads: string -} - -export const installFromSelectFileDialog = - ({ attemptInstalls, directoryForDownloads }: Dependencies) => - async () => { - const { canceled, filePaths } = await dialog.showOpenDialog({ - defaultPath: directoryForDownloads, - properties: ["openFile", "multiSelections"], - message: `Select extensions to install (formats: ${supportedExtensionFormats.join( - ", ", - )}), `, - buttonLabel: "Use configuration", - filters: [{ name: "tarball", extensions: supportedExtensionFormats }], - }); - - if (!canceled) { - await attemptInstalls(filePaths); - } - }; diff --git a/src/renderer/components/+preferences/add-helm-repo-dialog.tsx b/src/renderer/components/+preferences/add-helm-repo-dialog.tsx index bd25940c84..eb3d255987 100644 --- a/src/renderer/components/+preferences/add-helm-repo-dialog.tsx +++ b/src/renderer/components/+preferences/add-helm-repo-dialog.tsx @@ -19,7 +19,7 @@ import { SubTitle } from "../layout/sub-title"; import { Icon } from "../icon"; import { Notifications } from "../notifications"; import { HelmRepo, HelmRepoManager } from "../../../main/helm/helm-repo-manager"; -import { dialog } from "../../remote-helpers"; +import { requestOpenFilePickingDialog } from "../../ipc"; interface Props extends Partial { onAddRepo: Function @@ -73,7 +73,7 @@ export class AddHelmRepoDialog extends React.Component { } async selectFileDialog(type: FileType, fileFilter: FileFilter) { - const { canceled, filePaths } = await dialog.showOpenDialog({ + const { canceled, filePaths } = await requestOpenFilePickingDialog({ defaultPath: this.getFilePath(type), properties: ["openFile", "showHiddenFiles"], message: `Select file`, diff --git a/src/renderer/components/+workloads-pods/__tests__/pod-tolerations.test.tsx b/src/renderer/components/+workloads-pods/__tests__/pod-tolerations.test.tsx index 79deef7dcb..69102ca38b 100644 --- a/src/renderer/components/+workloads-pods/__tests__/pod-tolerations.test.tsx +++ b/src/renderer/components/+workloads-pods/__tests__/pod-tolerations.test.tsx @@ -10,8 +10,7 @@ import type { IToleration } from "../../../../common/k8s-api/workload-kube-objec import { PodTolerations } from "../pod-tolerations"; import { getDiForUnitTesting } from "../../../getDiForUnitTesting"; import { DiRender, renderFor } from "../../test-utils/renderFor"; -import directoryForLensLocalStorageInjectable - from "../../../../common/directory-for-lens-local-storage/directory-for-lens-local-storage.injectable"; +import directoryForLensLocalStorageInjectable from "../../../../common/directory-for-lens-local-storage/directory-for-lens-local-storage.injectable"; jest.mock("electron", () => ({ app: { diff --git a/src/renderer/components/activate-entity-command/activate-entity-command.tsx b/src/renderer/components/activate-entity-command/activate-entity-command.tsx index 9a7964dee6..939d750da4 100644 --- a/src/renderer/components/activate-entity-command/activate-entity-command.tsx +++ b/src/renderer/components/activate-entity-command/activate-entity-command.tsx @@ -7,7 +7,8 @@ import { withInjectables } from "@ogre-tools/injectable-react"; import { computed, IComputedValue } from "mobx"; import { observer } from "mobx-react"; import React from "react"; -import { broadcastMessage, catalogEntityRunListener } from "../../../common/ipc"; +import { broadcastMessage } from "../../../common/ipc"; +import { catalogEntityRunListener } from "../../../common/ipc/catalog"; import type { CatalogEntity } from "../../api/catalog-entity"; import { catalogEntityRegistry } from "../../api/catalog-entity-registry"; import commandOverlayInjectable from "../command-palette/command-overlay.injectable"; diff --git a/src/renderer/components/cluster-manager/cluster-status.tsx b/src/renderer/components/cluster-manager/cluster-status.tsx index 11a16f2689..408f45b1bb 100644 --- a/src/renderer/components/cluster-manager/cluster-status.tsx +++ b/src/renderer/components/cluster-manager/cluster-status.tsx @@ -8,8 +8,7 @@ import styles from "./cluster-status.module.scss"; import { computed, observable, makeObservable } from "mobx"; import { disposeOnUnmount, observer } from "mobx-react"; import React from "react"; -import { clusterActivateHandler } from "../../../common/cluster-ipc"; -import { ipcRendererOn, requestMain } from "../../../common/ipc"; +import { ipcRendererOn } from "../../../common/ipc"; import type { Cluster } from "../../../common/cluster/cluster"; import { cssNames, IClassName } from "../../utils"; import { Button } from "../button"; @@ -19,6 +18,7 @@ import { navigate } from "../../navigation"; import { entitySettingsURL } from "../../../common/routes"; import type { KubeAuthUpdate } from "../../../common/cluster-types"; import { catalogEntityRegistry } from "../../api/catalog-entity-registry"; +import { requestClusterActivation } from "../../ipc"; interface Props { className?: IClassName; @@ -60,7 +60,7 @@ export class ClusterStatus extends React.Component { this.isReconnecting = true; try { - await requestMain(clusterActivateHandler, this.cluster.id, true); + await requestClusterActivation(this.cluster.id, true); } catch (error) { this.authOutput.push({ message: error.toString(), diff --git a/src/renderer/components/cluster-manager/cluster-view.tsx b/src/renderer/components/cluster-manager/cluster-view.tsx index 29b0f4c739..5187866ef3 100644 --- a/src/renderer/components/cluster-manager/cluster-view.tsx +++ b/src/renderer/components/cluster-manager/cluster-view.tsx @@ -12,11 +12,10 @@ import { ClusterStatus } from "./cluster-status"; import { ClusterFrameHandler } from "./lens-views"; import type { Cluster } from "../../../common/cluster/cluster"; import { ClusterStore } from "../../../common/cluster-store/cluster-store"; -import { requestMain } from "../../../common/ipc"; -import { clusterActivateHandler } from "../../../common/cluster-ipc"; import { catalogEntityRegistry } from "../../api/catalog-entity-registry"; import { navigate } from "../../navigation"; import { catalogURL, ClusterViewRouteParams } from "../../../common/routes"; +import { requestClusterActivation } from "../../ipc"; interface Props extends RouteComponentProps { } @@ -58,7 +57,7 @@ export class ClusterView extends React.Component { reaction(() => this.clusterId, async (clusterId) => { ClusterFrameHandler.getInstance().setVisibleCluster(clusterId); ClusterFrameHandler.getInstance().initView(clusterId); - requestMain(clusterActivateHandler, clusterId, false); // activate and fetch cluster's state from main + requestClusterActivation(clusterId, false); // activate and fetch cluster's state from main catalogEntityRegistry.activeEntity = clusterId; }, { fireImmediately: true, diff --git a/src/renderer/components/cluster-manager/lens-views.ts b/src/renderer/components/cluster-manager/lens-views.ts index 787c2bbf15..62b740b1e1 100644 --- a/src/renderer/components/cluster-manager/lens-views.ts +++ b/src/renderer/components/cluster-manager/lens-views.ts @@ -6,7 +6,7 @@ import { action, IReactionDisposer, makeObservable, observable, when } from "mobx"; import logger from "../../../main/logger"; import { broadcastMessage } from "../../../common/ipc"; -import { clusterVisibilityHandler } from "../../../common/cluster-ipc"; +import { clusterVisibilityHandler } from "../../../common/ipc/cluster"; import { ClusterStore } from "../../../common/cluster-store/cluster-store"; import type { ClusterId } from "../../../common/cluster-types"; import { getClusterFrameUrl, Singleton } from "../../utils"; diff --git a/src/renderer/components/delete-cluster-dialog/delete-cluster-dialog.tsx b/src/renderer/components/delete-cluster-dialog/delete-cluster-dialog.tsx index 9b30e686fe..f28222a2a2 100644 --- a/src/renderer/components/delete-cluster-dialog/delete-cluster-dialog.tsx +++ b/src/renderer/components/delete-cluster-dialog/delete-cluster-dialog.tsx @@ -12,8 +12,6 @@ import { Button } from "../button"; import type { KubeConfig } from "@kubernetes/client-node"; import type { Cluster } from "../../../common/cluster/cluster"; import { saveKubeconfig } from "./save-config"; -import { requestMain } from "../../../common/ipc"; -import { clusterClearDeletingHandler, clusterDeleteHandler, clusterSetDeletingHandler } from "../../../common/cluster-ipc"; import { Notifications } from "../notifications"; import { HotbarStore } from "../../../common/hotbar-store"; import { boundMethod } from "autobind-decorator"; @@ -21,6 +19,7 @@ import { Dialog } from "../dialog"; import { Icon } from "../icon"; import { Select } from "../select"; import { Checkbox } from "../checkbox"; +import { requestClearClusterAsDeleting, requestDeleteCluster, requestSetClusterAsDeleting } from "../../ipc"; type DialogState = { isOpen: boolean, @@ -87,18 +86,18 @@ export class DeleteClusterDialog extends React.Component { async onDelete() { const { cluster, config } = dialogState; - await requestMain(clusterSetDeletingHandler, cluster.id); + await requestSetClusterAsDeleting(cluster.id); this.removeContext(); this.changeCurrentContext(); try { await saveKubeconfig(config, cluster.kubeConfigPath); HotbarStore.getInstance().removeAllHotbarItems(cluster.id); - await requestMain(clusterDeleteHandler, cluster.id); + await requestDeleteCluster(cluster.id); } catch(error) { Notifications.error(`Cannot remove cluster, failed to process config file. ${error}`); } finally { - await requestMain(clusterClearDeletingHandler, cluster.id); + await requestClearClusterAsDeleting(cluster.id); } this.onClose(); diff --git a/src/renderer/components/dock/__test__/log-resource-selector.test.tsx b/src/renderer/components/dock/__test__/log-resource-selector.test.tsx deleted file mode 100644 index 3521ef4ad5..0000000000 --- a/src/renderer/components/dock/__test__/log-resource-selector.test.tsx +++ /dev/null @@ -1,151 +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 "@testing-library/jest-dom/extend-expect"; -import * as selectEvent from "react-select-event"; -import { Pod } from "../../../../common/k8s-api/endpoints"; -import { LogResourceSelector } from "../log-resource-selector"; -import type { LogTabData } from "../log-tab-store/log-tab.store"; -import { dockerPod, deploymentPod1 } from "./pod.mock"; -import { ThemeStore } from "../../../theme.store"; -import { UserStore } from "../../../../common/user-store"; -import mockFs from "mock-fs"; -import { getDiForUnitTesting } from "../../../getDiForUnitTesting"; -import type { DiRender } from "../../test-utils/renderFor"; -import { renderFor } from "../../test-utils/renderFor"; -import directoryForUserDataInjectable from "../../../../common/app-paths/directory-for-user-data/directory-for-user-data.injectable"; -import callForLogsInjectable from "../log-store/call-for-logs/call-for-logs.injectable"; - -jest.mock("electron", () => ({ - app: { - getVersion: () => "99.99.99", - getName: () => "lens", - setName: jest.fn(), - setPath: jest.fn(), - getPath: () => "tmp", - getLocale: () => "en", - setLoginItemSettings: jest.fn(), - }, - ipcMain: { - on: jest.fn(), - handle: jest.fn(), - }, -})); - -const getComponent = (tabData: LogTabData) => { - return ( - - ); -}; - -const getOnePodTabData = (): LogTabData => { - const selectedPod = new Pod(dockerPod); - - return { - pods: [] as Pod[], - selectedPod, - selectedContainer: selectedPod.getContainers()[0], - }; -}; - -const getFewPodsTabData = (): LogTabData => { - const selectedPod = new Pod(deploymentPod1); - const anotherPod = new Pod(dockerPod); - - return { - pods: [anotherPod], - selectedPod, - selectedContainer: selectedPod.getContainers()[0], - }; -}; - -describe("", () => { - let render: DiRender; - - beforeEach(async () => { - const di = getDiForUnitTesting({ doGeneralOverrides: true }); - - di.override(directoryForUserDataInjectable, () => "some-directory-for-user-data"); - di.override(callForLogsInjectable, () => () => Promise.resolve("some-logs")); - - render = renderFor(di); - - await di.runSetups(); - - mockFs({ - "tmp": {}, - }); - - UserStore.createInstance(); - ThemeStore.createInstance(); - }); - - afterEach(() => { - UserStore.resetInstance(); - ThemeStore.resetInstance(); - mockFs.restore(); - }); - - it("renders w/o errors", () => { - const tabData = getOnePodTabData(); - const { container } = render(getComponent(tabData)); - - expect(container).toBeInstanceOf(HTMLElement); - }); - - it("renders proper namespace", () => { - const tabData = getOnePodTabData(); - const { getByTestId } = render(getComponent(tabData)); - const ns = getByTestId("namespace-badge"); - - expect(ns).toHaveTextContent("default"); - }); - - it("renders proper selected items within dropdowns", () => { - const tabData = getOnePodTabData(); - const { getByText } = render(getComponent(tabData)); - - expect(getByText("dockerExporter")).toBeInTheDocument(); - expect(getByText("docker-exporter")).toBeInTheDocument(); - }); - - it("renders sibling pods in dropdown", () => { - const tabData = getFewPodsTabData(); - const { container, getByText } = render(getComponent(tabData)); - const podSelector: HTMLElement = container.querySelector(".pod-selector"); - - selectEvent.openMenu(podSelector); - - expect(getByText("dockerExporter")).toBeInTheDocument(); - expect(getByText("deploymentPod1")).toBeInTheDocument(); - }); - - it("renders sibling containers in dropdown", () => { - const tabData = getFewPodsTabData(); - const { getByText, container } = render(getComponent(tabData)); - const containerSelector: HTMLElement = container.querySelector(".container-selector"); - - selectEvent.openMenu(containerSelector); - - expect(getByText("node-exporter-1")).toBeInTheDocument(); - expect(getByText("init-node-exporter")).toBeInTheDocument(); - expect(getByText("init-node-exporter-1")).toBeInTheDocument(); - }); - - it("renders pod owner as dropdown title", () => { - const tabData = getFewPodsTabData(); - const { getByText, container } = render(getComponent(tabData)); - const podSelector: HTMLElement = container.querySelector(".pod-selector"); - - selectEvent.openMenu(podSelector); - - expect(getByText("super-deployment")).toBeInTheDocument(); - }); -}); diff --git a/src/renderer/components/dock/dock.tsx b/src/renderer/components/dock/dock.tsx index 5a9f1b26e1..dcc655c60d 100644 --- a/src/renderer/components/dock/dock.tsx +++ b/src/renderer/components/dock/dock.tsx @@ -18,7 +18,7 @@ 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 { Logs } from "./logs"; +import { LogsDockTab } from "./logs/dock-tab"; import { TerminalWindow } from "./terminal-window"; import { UpgradeChart } from "./upgrade-chart"; import { withInjectables } from "@ogre-tools/injectable-react"; @@ -85,7 +85,7 @@ class NonInjectedDock extends React.Component { case TabKind.UPGRADE_CHART: return ; case TabKind.POD_LOGS: - return ; + return ; case TabKind.TERMINAL: return ; } diff --git a/src/renderer/components/dock/log-store/reloaded-log-store.injectable.ts b/src/renderer/components/dock/log-store/reloaded-log-store.injectable.ts deleted file mode 100644 index 277579dc15..0000000000 --- a/src/renderer/components/dock/log-store/reloaded-log-store.injectable.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ -import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; -import logStoreInjectable from "./log-store.injectable"; - -const reloadedLogStoreInjectable = getInjectable({ - instantiate: async (di) => { - const nonReloadedStore = di.inject(logStoreInjectable); - - await nonReloadedStore.reload(); - - return nonReloadedStore; - }, - - lifecycle: lifecycleEnum.transient, -}); - -export default reloadedLogStoreInjectable; diff --git a/src/renderer/components/dock/logs.tsx b/src/renderer/components/dock/logs.tsx deleted file mode 100644 index 00425cbfd4..0000000000 --- a/src/renderer/components/dock/logs.tsx +++ /dev/null @@ -1,136 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import React from "react"; -import { observer } from "mobx-react"; -import { boundMethod } from "../../utils"; -import { InfoPanel } from "./info-panel"; -import { LogResourceSelector } from "./log-resource-selector"; -import { LogList, NonInjectedLogList } from "./log-list"; -import { LogSearch } from "./log-search"; -import { LogControls } from "./log-controls"; -import { withInjectables } from "@ogre-tools/injectable-react"; -import type { SearchStore } from "../../search-store/search-store"; -import searchStoreInjectable from "../../search-store/search-store.injectable"; -import { Spinner } from "../spinner"; -import logsViewModelInjectable from "./logs/logs-view-model/logs-view-model.injectable"; -import type { LogsViewModel } from "./logs/logs-view-model/logs-view-model"; - -interface Props { - className?: string; -} - -interface Dependencies { - searchStore: SearchStore - model: LogsViewModel -} - -@observer -class NonInjectedLogs extends React.Component { - private logListElement = React.createRef(); // A reference for VirtualList component - - get model() { - return this.props.model; - } - - /** - * Scrolling to active overlay (search word highlight) - */ - @boundMethod - scrollToOverlay() { - const { activeOverlayLine } = this.props.searchStore; - - if (!this.logListElement.current || activeOverlayLine === undefined) return; - // Scroll vertically - this.logListElement.current.scrollToItem(activeOverlayLine, "center"); - // Scroll horizontally in timeout since virtual list need some time to prepare its contents - setTimeout(() => { - const overlay = document.querySelector(".PodLogs .list span.active"); - - if (!overlay) return; - overlay.scrollIntoViewIfNeeded(); - }, 100); - } - - renderResourceSelector() { - const { tabs, logs, logsWithoutTimestamps, saveTab, tabId } = this.model; - - if (!tabs) { - return null; - } - - const searchLogs = tabs.showTimestamps ? logs : logsWithoutTimestamps; - - const controls = ( -
- - - -
- ); - - return ( - - ); - } - - render() { - const { logs, tabs, tabId, saveTab } = this.model; - - return ( -
- {this.renderResourceSelector()} - - - - -
- ); - } -} - - - -export const Logs = withInjectables( - NonInjectedLogs, - - { - - getPlaceholder: () => ( -
- -
- ), - - getProps: async (di, props) => ({ - searchStore: di.inject(searchStoreInjectable), - model: await di.inject(logsViewModelInjectable), - ...props, - }), - }, -); diff --git a/src/renderer/components/dock/logs/__test__/log-resource-selector.test.tsx b/src/renderer/components/dock/logs/__test__/log-resource-selector.test.tsx new file mode 100644 index 0000000000..9858827f86 --- /dev/null +++ b/src/renderer/components/dock/logs/__test__/log-resource-selector.test.tsx @@ -0,0 +1,164 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import React from "react"; +import "@testing-library/jest-dom/extend-expect"; +import * as selectEvent from "react-select-event"; +import { Pod } from "../../../../../common/k8s-api/endpoints"; +import { LogResourceSelector } from "../resource-selector"; +import { dockerPod, deploymentPod1 } from "./pod.mock"; +import { ThemeStore } from "../../../../theme.store"; +import { UserStore } from "../../../../../common/user-store"; +import mockFs from "mock-fs"; +import { getDiForUnitTesting } from "../../../../getDiForUnitTesting"; +import type { DiRender } from "../../../test-utils/renderFor"; +import { renderFor } from "../../../test-utils/renderFor"; +import directoryForUserDataInjectable from "../../../../../common/app-paths/directory-for-user-data/directory-for-user-data.injectable"; +import callForLogsInjectable from "../call-for-logs.injectable"; +import { LogTabViewModel, LogTabViewModelDependencies } from "../logs-view-model"; +import type { TabId } from "../../dock-store/dock.store"; + +jest.mock("electron", () => ({ + app: { + getVersion: () => "99.99.99", + getName: () => "lens", + setName: jest.fn(), + setPath: jest.fn(), + getPath: () => "tmp", + getLocale: () => "en", + setLoginItemSettings: jest.fn(), + }, + ipcMain: { + on: jest.fn(), + handle: jest.fn(), + }, +})); + +const getComponent = (model: LogTabViewModel) => ( + +); + +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(), + updateTabName: jest.fn(), + stopLoadingLogs: jest.fn(), + ...deps, + }); +} + +const getOnePodViewModel = (tabId: TabId): LogTabViewModel => { + const selectedPod = new Pod(dockerPod); + + return mockLogTabViewModel(tabId, { + getLogTabData: () => ({ + pods: [selectedPod], + selectedPod, + selectedContainer: selectedPod.getContainers()[0], + }), + }); +}; + +const getFewPodsTabData = (tabId: TabId): LogTabViewModel => { + const selectedPod = new Pod(deploymentPod1); + const anotherPod = new Pod(dockerPod); + + return mockLogTabViewModel(tabId, { + getLogTabData: () => ({ + pods: [selectedPod, anotherPod], + selectedPod, + selectedContainer: selectedPod.getContainers()[0], + }), + }); +}; + +describe("", () => { + let render: DiRender; + + beforeEach(async () => { + const di = getDiForUnitTesting({ doGeneralOverrides: true }); + + di.override(directoryForUserDataInjectable, () => "some-directory-for-user-data"); + di.override(callForLogsInjectable, () => () => Promise.resolve("some-logs")); + + render = renderFor(di); + + await di.runSetups(); + + mockFs({ + "tmp": {}, + }); + + UserStore.createInstance(); + ThemeStore.createInstance(); + }); + + afterEach(() => { + UserStore.resetInstance(); + ThemeStore.resetInstance(); + mockFs.restore(); + }); + + it("renders w/o errors", () => { + const model = getOnePodViewModel("foobar"); + const { container } = render(getComponent(model)); + + expect(container).toBeInstanceOf(HTMLElement); + }); + + it("renders proper namespace", async () => { + const model = getOnePodViewModel("foobar"); + const { findByTestId } = render(getComponent(model)); + const ns = await findByTestId("namespace-badge"); + + expect(ns).toHaveTextContent("default"); + }); + + it("renders proper selected items within dropdowns", async () => { + const model = getOnePodViewModel("foobar"); + const { findByText } = render(getComponent(model)); + + expect(await findByText("dockerExporter")).toBeInTheDocument(); + expect(await findByText("docker-exporter")).toBeInTheDocument(); + }); + + it("renders sibling pods in dropdown", async () => { + const model = getFewPodsTabData("foobar"); + const { container, findByText } = render(getComponent(model)); + + selectEvent.openMenu(container.querySelector(".pod-selector")); + + expect(await findByText("dockerExporter", { 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); + + 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 () => { + const model = getFewPodsTabData("foobar"); + const { findByText, container } = render(getComponent(model)); + const podSelector: HTMLElement = container.querySelector(".pod-selector"); + + selectEvent.openMenu(podSelector); + + expect(await findByText("super-deployment")).toBeInTheDocument(); + }); +}); diff --git a/src/renderer/components/dock/__test__/log-tab.store.test.ts b/src/renderer/components/dock/logs/__test__/log-tab.store.test.ts similarity index 83% rename from src/renderer/components/dock/__test__/log-tab.store.test.ts rename to src/renderer/components/dock/logs/__test__/log-tab.store.test.ts index 7d9aab1e01..b98b46daf9 100644 --- a/src/renderer/components/dock/__test__/log-tab.store.test.ts +++ b/src/renderer/components/dock/logs/__test__/log-tab.store.test.ts @@ -3,19 +3,19 @@ * 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 { 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 "../log-tab-store/log-tab-store.injectable"; -import type { LogTabStore } from "../log-tab-store/log-tab.store"; -import dockStoreInjectable from "../dock-store/dock-store.injectable"; -import type { DockStore } from "../dock-store/dock.store"; +import { 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"; + from "../../../../../common/app-paths/directory-for-user-data/directory-for-user-data.injectable"; import mockFs from "mock-fs"; mockWindow(); diff --git a/src/renderer/components/dock/__test__/pod.mock.ts b/src/renderer/components/dock/logs/__test__/pod.mock.ts similarity index 100% rename from src/renderer/components/dock/__test__/pod.mock.ts rename to src/renderer/components/dock/logs/__test__/pod.mock.ts diff --git a/src/renderer/components/dock/__test__/to-bottom.test.tsx b/src/renderer/components/dock/logs/__test__/to-bottom.test.tsx similarity index 96% rename from src/renderer/components/dock/__test__/to-bottom.test.tsx rename to src/renderer/components/dock/logs/__test__/to-bottom.test.tsx index fd5858a3c3..2040dada89 100644 --- a/src/renderer/components/dock/__test__/to-bottom.test.tsx +++ b/src/renderer/components/dock/logs/__test__/to-bottom.test.tsx @@ -6,7 +6,7 @@ import React from "react"; import "@testing-library/jest-dom/extend-expect"; import { fireEvent, render } from "@testing-library/react"; import { ToBottom } from "../to-bottom"; -import { noop } from "../../../utils"; +import { noop } from "../../../../utils"; describe("", () => { it("renders w/o errors", () => { diff --git a/src/renderer/components/dock/log-store/call-for-logs/call-for-logs.injectable.ts b/src/renderer/components/dock/logs/call-for-logs.injectable.ts similarity index 85% rename from src/renderer/components/dock/log-store/call-for-logs/call-for-logs.injectable.ts rename to src/renderer/components/dock/logs/call-for-logs.injectable.ts index 5657786f9f..4b918f351f 100644 --- a/src/renderer/components/dock/log-store/call-for-logs/call-for-logs.injectable.ts +++ b/src/renderer/components/dock/logs/call-for-logs.injectable.ts @@ -3,7 +3,7 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; -import { podsApi } from "../../../../../common/k8s-api/endpoints"; +import { podsApi } from "../../../../common/k8s-api/endpoints"; const callForLogsInjectable = getInjectable({ instantiate: () => podsApi.getLogs, diff --git a/src/renderer/components/dock/log-controls.scss b/src/renderer/components/dock/logs/controls.scss similarity index 100% rename from src/renderer/components/dock/log-controls.scss rename to src/renderer/components/dock/logs/controls.scss diff --git a/src/renderer/components/dock/log-controls.tsx b/src/renderer/components/dock/logs/controls.tsx similarity index 55% rename from src/renderer/components/dock/log-controls.tsx rename to src/renderer/components/dock/logs/controls.tsx index a197944bb4..954ccb8478 100644 --- a/src/renderer/components/dock/log-controls.tsx +++ b/src/renderer/components/dock/logs/controls.tsx @@ -3,53 +3,47 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import "./log-controls.scss"; +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 { Checkbox } from "../checkbox"; -import { Icon } from "../icon"; -import type { LogTabData } from "./log-tab-store/log-tab.store"; -import type { LogStore } from "./log-store/log.store"; -import { withInjectables } from "@ogre-tools/injectable-react"; -import logStoreInjectable from "./log-store/log-store.injectable"; +import { Pod } from "../../../../common/k8s-api/endpoints"; +import { cssNames, saveFileDialog } from "../../../utils"; +import { Checkbox } from "../../checkbox"; +import { Icon } from "../../icon"; +import type { LogTabViewModel } from "./logs-view-model"; -interface Props { - tabData?: LogTabData - logs: string[] - save: (data: Partial) => void +export interface LogControlsProps { + model: LogTabViewModel; } -interface Dependencies { - logStore: LogStore -} - -const NonInjectedLogControls = observer((props: Props & Dependencies) => { - const { tabData, save, logs, logStore } = props; +export const LogControls = observer(({ model }: LogControlsProps) => { + const tabData = model.logTabData.get(); if (!tabData) { return null; } + const logs = model.timestampSplitLogs.get(); const { showTimestamps, previous } = tabData; - const since = logs.length ? logStore.getTimestamps(logs[0]) : null; + const since = logs.length ? logs[0][0] : null; const pod = new Pod(tabData.selectedPod); const toggleTimestamps = () => { - save({ showTimestamps: !showTimestamps }); + model.updateLogTabData({ showTimestamps: !showTimestamps }); }; const togglePrevious = () => { - save({ previous: !previous }); - logStore.reload(); + model.updateLogTabData({ previous: !previous }); + model.reloadLogs(); }; const downloadLogs = () => { const fileName = pod.getName(); - const logsToDownload = showTimestamps ? logs : logStore.logsWithoutTimestamps; + const logsToDownload: string[] = showTimestamps + ? model.logs.get() + : model.logsWithoutTimestamps.get(); saveFileDialog(`${fileName}.log`, logsToDownload.join("\n"), "text/plain"); }; @@ -87,15 +81,3 @@ const NonInjectedLogControls = observer((props: Props & Dependencies) => {
); }); - -export const LogControls = withInjectables( - NonInjectedLogControls, - - { - getProps: (di, props) => ({ - logStore: di.inject(logStoreInjectable), - ...props, - }), - }, -); - diff --git a/src/renderer/components/dock/logs/dock-tab.tsx b/src/renderer/components/dock/logs/dock-tab.tsx new file mode 100644 index 0000000000..1ae62b4fe6 --- /dev/null +++ b/src/renderer/components/dock/logs/dock-tab.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 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 new file mode 100644 index 0000000000..e706fdaafb --- /dev/null +++ b/src/renderer/components/dock/logs/get-log-tab-data.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 logTabStoreInjectable from "./tab-store.injectable"; + +const getLogTabDataInjectable = getInjectable({ + instantiate: (di) => di.inject(logTabStoreInjectable).getData, + lifecycle: lifecycleEnum.singleton, +}); + +export default getLogTabDataInjectable; diff --git a/src/renderer/components/dock/logs/get-logs-without-timestamps.injectable.ts b/src/renderer/components/dock/logs/get-logs-without-timestamps.injectable.ts new file mode 100644 index 0000000000..1be01ff443 --- /dev/null +++ b/src/renderer/components/dock/logs/get-logs-without-timestamps.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 logStoreInjectable from "./store.injectable"; + +const getLogsWithoutTimestampsInjectable = getInjectable({ + instantiate: (di) => di.inject(logStoreInjectable).getLogsWithoutTimestampsByTabId, + lifecycle: lifecycleEnum.singleton, +}); + +export default getLogsWithoutTimestampsInjectable; diff --git a/src/renderer/components/dock/logs/get-logs.injectable.ts b/src/renderer/components/dock/logs/get-logs.injectable.ts new file mode 100644 index 0000000000..659a87c46f --- /dev/null +++ b/src/renderer/components/dock/logs/get-logs.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 logStoreInjectable from "./store.injectable"; + +const getLogsInjectable = getInjectable({ + instantiate: (di) => di.inject(logStoreInjectable).getLogsByTabId, + lifecycle: lifecycleEnum.singleton, +}); + +export default getLogsInjectable; diff --git a/src/renderer/components/dock/logs/get-timestamp-split-logs.injectable.ts b/src/renderer/components/dock/logs/get-timestamp-split-logs.injectable.ts new file mode 100644 index 0000000000..7cdac68327 --- /dev/null +++ b/src/renderer/components/dock/logs/get-timestamp-split-logs.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 logStoreInjectable from "./store.injectable"; + +const getTimestampSplitLogsInjectable = getInjectable({ + instantiate: (di) => di.inject(logStoreInjectable).getTimestampSplitLogsByTabId, + lifecycle: lifecycleEnum.singleton, +}); + +export default getTimestampSplitLogsInjectable; diff --git a/src/renderer/components/dock/log-list.scss b/src/renderer/components/dock/logs/list.scss similarity index 100% rename from src/renderer/components/dock/log-list.scss rename to src/renderer/components/dock/logs/list.scss diff --git a/src/renderer/components/dock/log-list.tsx b/src/renderer/components/dock/logs/list.tsx similarity index 78% rename from src/renderer/components/dock/log-list.tsx rename to src/renderer/components/dock/logs/list.tsx index fc31311e5a..358889f2f3 100644 --- a/src/renderer/components/dock/log-list.tsx +++ b/src/renderer/components/dock/logs/list.tsx @@ -3,7 +3,7 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import "./log-list.scss"; +import "./list.scss"; import React from "react"; import AnsiUp from "ansi_up"; @@ -13,33 +13,21 @@ import { action, computed, observable, makeObservable, reaction } from "mobx"; import { disposeOnUnmount, observer } from "mobx-react"; import moment from "moment-timezone"; import type { Align, ListOnScrollProps } from "react-window"; -import { SearchStore } from "../../search-store/search-store"; -import { UserStore } from "../../../common/user-store"; -import { array, boundMethod, cssNames } from "../../utils"; -import { VirtualList } from "../virtual-list"; -import type { LogStore } from "./log-store/log.store"; -import type { LogTabStore } from "./log-tab-store/log-tab.store"; +import { SearchStore } from "../../../search-store/search-store"; +import { UserStore } from "../../../../common/user-store"; +import { array, boundMethod, cssNames } from "../../../utils"; +import { VirtualList } from "../../virtual-list"; import { ToBottom } from "./to-bottom"; -import { withInjectables } from "@ogre-tools/injectable-react"; -import logTabStoreInjectable from "./log-tab-store/log-tab-store.injectable"; -import logStoreInjectable from "./log-store/log-store.injectable"; -import searchStoreInjectable from "../../search-store/search-store.injectable"; +import type { LogTabViewModel } from "../logs/logs-view-model"; -interface Props { - logs: string[] - id: string +export interface LogListProps { + model: LogTabViewModel; } const colorConverter = new AnsiUp(); -interface Dependencies { - logTabStore: LogTabStore - logStore: LogStore - searchStore: SearchStore -} - @observer -export class NonInjectedLogList extends React.Component { +export class LogList extends React.Component { @observable isJumpButtonVisible = false; @observable isLastLineVisible = true; @@ -47,16 +35,18 @@ export class NonInjectedLogList extends React.Component { private virtualListRef = React.createRef(); // A reference for VirtualList component private lineHeight = 18; // Height of a log line. Should correlate with styles in pod-log-list.scss - constructor(props: Props & Dependencies) { + constructor(props: LogListProps) { super(props); makeObservable(this); } componentDidMount() { disposeOnUnmount(this, [ - reaction(() => this.props.logs, this.onLogsInitialLoad), - reaction(() => this.props.logs, this.onLogsUpdate), - reaction(() => this.props.logs, this.onUserScrolledUp), + reaction(() => this.props.model.logs.get(), (logs, prevLogs) => { + this.onLogsInitialLoad(logs, prevLogs); + this.onLogsUpdate(); + this.onUserScrolledUp(logs, prevLogs); + }), ]); } @@ -85,7 +75,7 @@ export class NonInjectedLogList extends React.Component { if (newLogsAdded && scrolledToBeginning) { const firstLineContents = prevLogs[0]; - const lineToScroll = this.props.logs.findIndex((value) => value == firstLineContents); + const lineToScroll = logs.findIndex((value) => value == firstLineContents); if (lineToScroll !== -1) { this.scrollToItem(lineToScroll, "start"); @@ -97,15 +87,15 @@ export class NonInjectedLogList extends React.Component { * Returns logs with or without timestamps regarding to showTimestamps prop */ @computed - get logs() { - const showTimestamps = this.props.logTabStore.getData(this.props.id)?.showTimestamps; + get logs(): string[] { + const { showTimestamps } = this.props.model.logTabData.get(); if (!showTimestamps) { - return this.props.logStore.logsWithoutTimestamps; + return this.props.model.logsWithoutTimestamps.get(); } - return this.props.logs - .map(log => this.props.logStore.splitOutTimestamp(log)) + return this.props.model.timestampSplitLogs + .get() .map(([logTimestamp, log]) => (`${logTimestamp && moment.tz(logTimestamp, UserStore.getInstance().localeTimezone).format()}${log}`)); } @@ -146,7 +136,7 @@ export class NonInjectedLogList extends React.Component { const { scrollOffset } = props; if (scrollOffset === 0) { - this.props.logStore.load(); + this.props.model.loadLogs(); } }; @@ -177,7 +167,7 @@ export class NonInjectedLogList extends React.Component { * @returns A react element with a row itself */ getLogRow = (rowIndex: number) => { - const { searchQuery, isActiveOverlay } = this.props.searchStore; + const { searchQuery, isActiveOverlay } = this.props.model.searchStore; const item = this.logs[rowIndex]; const contents: React.ReactElement[] = []; const ansiToHtml = (ansi: string) => DOMPurify.sanitize(colorConverter.ansi_to_html(ansi)); @@ -250,17 +240,3 @@ export class NonInjectedLogList extends React.Component { ); } } - -export const LogList = withInjectables( - NonInjectedLogList, - - { - getProps: (di, props) => ({ - logTabStore: di.inject(logTabStoreInjectable), - logStore: di.inject(logStoreInjectable), - searchStore: di.inject(searchStoreInjectable), - ...props, - }), - }, -); - diff --git a/src/renderer/components/dock/logs/load-logs.injectable.ts b/src/renderer/components/dock/logs/load-logs.injectable.ts new file mode 100644 index 0000000000..de92239454 --- /dev/null +++ b/src/renderer/components/dock/logs/load-logs.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 logStoreInjectable from "./store.injectable"; + +const loadLogsInjectable = getInjectable({ + instantiate: (di) => di.inject(logStoreInjectable).load, + lifecycle: lifecycleEnum.singleton, +}); + +export default loadLogsInjectable; diff --git a/src/renderer/components/dock/logs/logs-view-model.injectable.ts b/src/renderer/components/dock/logs/logs-view-model.injectable.ts new file mode 100644 index 0000000000..7516e47124 --- /dev/null +++ b/src/renderer/components/dock/logs/logs-view-model.injectable.ts @@ -0,0 +1,37 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { LogTabViewModel } from "./logs-view-model"; +import type { TabId } from "../dock-store/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 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"; + +export interface InstantiateArgs { + tabId: TabId; +} + +const logsViewModelInjectable = getInjectable({ + instantiate: (di, { tabId }: InstantiateArgs) => new LogTabViewModel(tabId, { + getLogs: di.inject(getLogsInjectable), + getLogsWithoutTimestamps: di.inject(getLogsWithoutTimestampsInjectable), + getTimestampSplitLogs: di.inject(getTimestampSplitLogsInjectable), + reloadLogs: di.inject(reloadLoadsInjectable), + getLogTabData: di.inject(getLogTabDataInjectable), + setLogTabData: di.inject(setLogTabDataInjectable), + loadLogs: di.inject(loadLogsInjectable), + updateTabName: di.inject(updateTabNameInjectable), + stopLoadingLogs: di.inject(stopLoadingLogsInjectable), + }), + lifecycle: lifecycleEnum.transient, +}); + +export default logsViewModelInjectable; diff --git a/src/renderer/components/dock/logs/logs-view-model.ts b/src/renderer/components/dock/logs/logs-view-model.ts new file mode 100644 index 0000000000..6c2f182355 --- /dev/null +++ b/src/renderer/components/dock/logs/logs-view-model.ts @@ -0,0 +1,39 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import 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"; + +export interface LogTabViewModelDependencies { + getLogs: (tabId: TabId) => string[]; + getLogsWithoutTimestamps: (tabId: TabId) => string[]; + getTimestampSplitLogs: (tabId: TabId) => [string, string][]; + getLogTabData: (tabId: TabId) => LogTabData; + setLogTabData: (tabId: TabId, data: LogTabData) => void; + loadLogs: (tabId: TabId, logTabData: IComputedValue) => Promise; + reloadLogs: (tabId: TabId, logTabData: IComputedValue) => Promise; + updateTabName: (tabId: TabId) => void; + stopLoadingLogs: (tabId: TabId) => void; +} + +export class LogTabViewModel { + constructor(protected readonly tabId: TabId, private readonly dependencies: LogTabViewModelDependencies) {} + + 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(); + + 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); + stopLoadingLogs = () => this.dependencies.stopLoadingLogs(this.tabId); +} diff --git a/src/renderer/components/dock/logs/logs-view-model/logs-view-model.injectable.ts b/src/renderer/components/dock/logs/logs-view-model/logs-view-model.injectable.ts deleted file mode 100644 index c3a11048fe..0000000000 --- a/src/renderer/components/dock/logs/logs-view-model/logs-view-model.injectable.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ -import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; -import dockStoreInjectable from "../../dock-store/dock-store.injectable"; -import logTabStoreInjectable from "../../log-tab-store/log-tab-store.injectable"; -import reloadedLogStoreInjectable from "../../log-store/reloaded-log-store.injectable"; -import { LogsViewModel } from "./logs-view-model"; - -const logsViewModelInjectable = getInjectable({ - instantiate: async (di) => new LogsViewModel({ - dockStore: di.inject(dockStoreInjectable), - logTabStore: di.inject(logTabStoreInjectable), - logStore: await di.inject(reloadedLogStoreInjectable), - }), - - lifecycle: lifecycleEnum.singleton, -}); - -export default logsViewModelInjectable; diff --git a/src/renderer/components/dock/logs/logs-view-model/logs-view-model.ts b/src/renderer/components/dock/logs/logs-view-model/logs-view-model.ts deleted file mode 100644 index 1bb54f65dd..0000000000 --- a/src/renderer/components/dock/logs/logs-view-model/logs-view-model.ts +++ /dev/null @@ -1,44 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ -import type { LogTabData, LogTabStore } from "../../log-tab-store/log-tab.store"; -import type { LogStore } from "../../log-store/log.store"; -import { computed, makeObservable } from "mobx"; - -interface Dependencies { - dockStore: { selectedTabId: string }, - logTabStore: LogTabStore - logStore: LogStore -} - -export class LogsViewModel { - constructor(private dependencies: Dependencies) { - makeObservable(this, { - logs: computed, - logsWithoutTimestamps: computed, - tabs: computed, - tabId: computed, - }); - } - - get logs() { - return this.dependencies.logStore.logs; - } - - get logsWithoutTimestamps() { - return this.dependencies.logStore.logsWithoutTimestamps; - } - - get tabs() { - return this.dependencies.logTabStore.tabs; - } - - get tabId() { - return this.dependencies.dockStore.selectedTabId; - } - - saveTab = (newTabs: LogTabData) => { - this.dependencies.logTabStore.setData(this.tabId, { ...this.tabs, ...newTabs }); - }; -} diff --git a/src/renderer/components/dock/logs/reload-logs.injectable.ts b/src/renderer/components/dock/logs/reload-logs.injectable.ts new file mode 100644 index 0000000000..9fa917bf4b --- /dev/null +++ b/src/renderer/components/dock/logs/reload-logs.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 logStoreInjectable from "./store.injectable"; + +const reloadLoadsInjectable = getInjectable({ + instantiate: (di) => di.inject(logStoreInjectable).reload, + lifecycle: lifecycleEnum.singleton, +}); + +export default reloadLoadsInjectable; diff --git a/src/renderer/components/dock/log-resource-selector.scss b/src/renderer/components/dock/logs/resource-selector.scss similarity index 100% rename from src/renderer/components/dock/log-resource-selector.scss rename to src/renderer/components/dock/logs/resource-selector.scss diff --git a/src/renderer/components/dock/log-resource-selector.tsx b/src/renderer/components/dock/logs/resource-selector.tsx similarity index 61% rename from src/renderer/components/dock/log-resource-selector.tsx rename to src/renderer/components/dock/logs/resource-selector.tsx index c6d646038a..bca2c30f45 100644 --- a/src/renderer/components/dock/log-resource-selector.tsx +++ b/src/renderer/components/dock/logs/resource-selector.tsx @@ -3,55 +3,48 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import "./log-resource-selector.scss"; +import "./resource-selector.scss"; import React, { useEffect } from "react"; import { observer } from "mobx-react"; -import { Pod } from "../../../common/k8s-api/endpoints"; -import { Badge } from "../badge"; -import { Select, SelectOption } from "../select"; -import type { LogTabData, LogTabStore } from "./log-tab-store/log-tab.store"; -import { podsStore } from "../+workloads-pods/pods.store"; -import type { TabId } from "./dock-store/dock.store"; -import logTabStoreInjectable from "./log-tab-store/log-tab-store.injectable"; -import { withInjectables } from "@ogre-tools/injectable-react"; -import logStoreInjectable from "./log-store/log-store.injectable"; +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"; -interface Props { - tabId: TabId - tabData: LogTabData - save: (data: Partial) => void +export interface LogResourceSelectorProps { + model: LogTabViewModel; } -interface Dependencies { - logTabStore: LogTabStore - reloadLogs: () => Promise -} +export const LogResourceSelector = observer(({ model }: LogResourceSelectorProps) => { + const tabData = model.logTabData.get(); + + if (!tabData) { + return null; + } -const NonInjectedLogResourceSelector = observer((props: Props & Dependencies) => { - const { tabData, save, tabId, logTabStore, reloadLogs } = props; const { selectedPod, selectedContainer, pods } = tabData; const pod = new Pod(selectedPod); const containers = pod.getContainers(); const initContainers = pod.getInitContainers(); const onContainerChange = (option: SelectOption) => { - save({ + model.updateLogTabData({ selectedContainer: containers .concat(initContainers) .find(container => container.name === option.value), }); - reloadLogs(); + model.reloadLogs(); }; const onPodChange = (option: SelectOption) => { const selectedPod = podsStore.getByName(option.value, pod.getNs()); - save({ selectedPod }); - - logTabStore.renameTab(tabId); + model.updateLogTabData({ selectedPod }); + model.updateTabName(); }; const getSelectOptions = (items: string[]) => { @@ -82,7 +75,7 @@ const NonInjectedLogResourceSelector = observer((props: Props & Dependencies) => ]; useEffect(() => { - reloadLogs(); + model.reloadLogs(); }, [selectedPod]); return ( @@ -95,6 +88,7 @@ const NonInjectedLogResourceSelector = observer((props: Props & Dependencies) => onChange={onPodChange} autoConvertOptions={false} className="pod-selector" + menuClass="pod-selector-menu" /> Container