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/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/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