mirror of
https://github.com/lensapp/lens.git
synced 2025-05-20 05:10:56 +00:00
Merge a38fdc7c78 into 7c5faaaf1d
This commit is contained in:
commit
30e5676278
@ -105,7 +105,7 @@ describeIf(minikubeReady(TEST_NAMESPACE))("Minikube based tests", () => {
|
||||
|
||||
// Check if controls are available
|
||||
await frame.waitForSelector(".Dock.isOpen");
|
||||
await frame.waitForSelector(".LogList .VirtualList");
|
||||
await frame.waitForSelector("[data-testid=pod-log-list]");
|
||||
await frame.waitForSelector(".LogResourceSelector");
|
||||
|
||||
const logSearchInput = await frame.waitForSelector(
|
||||
@ -113,19 +113,25 @@ describeIf(minikubeReady(TEST_NAMESPACE))("Minikube based tests", () => {
|
||||
);
|
||||
|
||||
await logSearchInput.type(":");
|
||||
await frame.waitForSelector(".LogList .list span.active");
|
||||
await frame.waitForSelector("[data-testid=search-overlay-active]");
|
||||
|
||||
const showTimestampsButton = await frame.waitForSelector(
|
||||
const showTimestampsCheckbox = await frame.waitForSelector(
|
||||
"[data-testid='log-controls'] .show-timestamps",
|
||||
);
|
||||
|
||||
await showTimestampsButton.click();
|
||||
await showTimestampsCheckbox.click();
|
||||
|
||||
const showPreviousButton = await frame.waitForSelector(
|
||||
const showPreviousCheckbox = await frame.waitForSelector(
|
||||
"[data-testid='log-controls'] .show-previous",
|
||||
);
|
||||
|
||||
await showPreviousButton.click();
|
||||
await showPreviousCheckbox.click();
|
||||
|
||||
const showWrapLogsCheckbox = await frame.waitForSelector(
|
||||
"[data-testid='log-controls'] .wrap-logs",
|
||||
);
|
||||
|
||||
await showWrapLogsCheckbox.click();
|
||||
},
|
||||
10 * 60 * 1000,
|
||||
);
|
||||
|
||||
@ -210,6 +210,7 @@
|
||||
"@sentry/electron": "^3.0.8",
|
||||
"@sentry/integrations": "^6.19.3",
|
||||
"@side/jest-runtime": "^1.0.1",
|
||||
"@tanstack/react-virtual": "3.0.0-beta.23",
|
||||
"@types/circular-dependency-plugin": "5.0.5",
|
||||
"abort-controller": "^3.0.0",
|
||||
"auto-bind": "^4.0.0",
|
||||
|
||||
@ -748,31 +748,37 @@ exports[`download logs options in logs dock tab opening pod logs when logs avail
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="LogList flex"
|
||||
class="LogList"
|
||||
data-testid="pod-log-list"
|
||||
>
|
||||
<div
|
||||
class="VirtualList box grow"
|
||||
class="virtualizer"
|
||||
style="height: 0px;"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
class="list"
|
||||
style="position: relative; height: 420000px; width: 100%; overflow: auto; will-change: transform; direction: ltr;"
|
||||
>
|
||||
<div
|
||||
class="anchorLine"
|
||||
style="top: 0px;"
|
||||
/>
|
||||
<div
|
||||
class="rowWrapper"
|
||||
data-index="0"
|
||||
style="transform: translateY(0px);"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
style="height: 18px; width: 100%;"
|
||||
class="LogRow"
|
||||
>
|
||||
<div
|
||||
class="LogRow"
|
||||
style="position: absolute; left: 0px; top: 0px; height: 18px; width: 100%;"
|
||||
>
|
||||
<span>
|
||||
some-logs
|
||||
</span>
|
||||
<br />
|
||||
</div>
|
||||
<span>
|
||||
some-logs
|
||||
</span>
|
||||
<br />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="anchorLine"
|
||||
style="bottom: 0px;"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
@ -791,6 +797,21 @@ exports[`download logs options in logs dock tab opening pod logs when logs avail
|
||||
<div
|
||||
class="flex gaps align-center"
|
||||
>
|
||||
<label
|
||||
class="Checkbox flex align-center wrap-logs"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
/>
|
||||
<i
|
||||
class="box flex align-center"
|
||||
/>
|
||||
<span
|
||||
class="label"
|
||||
>
|
||||
Wrap logs
|
||||
</span>
|
||||
</label>
|
||||
<label
|
||||
class="Checkbox flex align-center show-timestamps"
|
||||
>
|
||||
@ -1603,11 +1624,22 @@ exports[`download logs options in logs dock tab opening pod logs when logs not a
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="LogList flex box grow align-center justify-center"
|
||||
class="LogList"
|
||||
data-testid="pod-log-list"
|
||||
>
|
||||
There are no logs available for container
|
||||
|
||||
docker-exporter
|
||||
<div
|
||||
class="virtualizer"
|
||||
style="height: 0px;"
|
||||
>
|
||||
<div
|
||||
class="anchorLine"
|
||||
style="top: 0px;"
|
||||
/>
|
||||
<div
|
||||
class="anchorLine"
|
||||
style="bottom: 0px;"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="controls"
|
||||
@ -1617,6 +1649,21 @@ exports[`download logs options in logs dock tab opening pod logs when logs not a
|
||||
<div
|
||||
class="flex gaps align-center"
|
||||
>
|
||||
<label
|
||||
class="Checkbox flex align-center wrap-logs"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
/>
|
||||
<i
|
||||
class="box flex align-center"
|
||||
/>
|
||||
<span
|
||||
class="label"
|
||||
>
|
||||
Wrap logs
|
||||
</span>
|
||||
</label>
|
||||
<label
|
||||
class="Checkbox flex align-center show-timestamps"
|
||||
>
|
||||
|
||||
@ -29,6 +29,17 @@ import showErrorNotificationInjectable from "../../renderer/components/notificat
|
||||
import type { DiContainer } from "@ogre-tools/injectable";
|
||||
import type { Container } from "../../common/k8s-api/endpoints";
|
||||
|
||||
const observe = jest.fn();
|
||||
|
||||
Object.defineProperty(window, "IntersectionObserver", {
|
||||
writable: true,
|
||||
value: jest.fn().mockImplementation(() => ({
|
||||
observe,
|
||||
disconnect: jest.fn(),
|
||||
unobserve: jest.fn(),
|
||||
})),
|
||||
});
|
||||
|
||||
describe("download logs options in logs dock tab", () => {
|
||||
let windowDi: DiContainer;
|
||||
let rendered: RenderResult;
|
||||
@ -71,6 +82,7 @@ describe("download logs options in logs dock tab", () => {
|
||||
namespace: "default",
|
||||
showPrevious: true,
|
||||
showTimestamps: false,
|
||||
wrap: false,
|
||||
}));
|
||||
windowDi.override(setLogTabDataInjectable, () => jest.fn());
|
||||
windowDi.override(loadLogsInjectable, () => jest.fn());
|
||||
|
||||
@ -0,0 +1,111 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`<LogList /> renders logs 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="LogList"
|
||||
data-testid="pod-log-list"
|
||||
>
|
||||
<div
|
||||
class="virtualizer"
|
||||
style="height: 0px;"
|
||||
>
|
||||
<div
|
||||
class="anchorLine"
|
||||
style="top: 0px;"
|
||||
/>
|
||||
<div
|
||||
class="rowWrapper"
|
||||
data-index="0"
|
||||
style="transform: translateY(0px);"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
class="LogRow"
|
||||
>
|
||||
<span>
|
||||
hello
|
||||
</span>
|
||||
<br />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="rowWrapper"
|
||||
data-index="1"
|
||||
style="transform: translateY(0px);"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
class="LogRow"
|
||||
>
|
||||
<span>
|
||||
world
|
||||
</span>
|
||||
<br />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="anchorLine"
|
||||
style="bottom: 0px;"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`<LogList /> when user selected to wrap log lines renders logs with wrapping 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="LogList"
|
||||
data-testid="pod-log-list"
|
||||
>
|
||||
<div
|
||||
class="virtualizer"
|
||||
style="height: 0px;"
|
||||
>
|
||||
<div
|
||||
class="anchorLine"
|
||||
style="top: 0px;"
|
||||
/>
|
||||
<div
|
||||
class="rowWrapper wrap"
|
||||
data-index="0"
|
||||
style="transform: translateY(0px);"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
class="LogRow"
|
||||
>
|
||||
<span>
|
||||
hello
|
||||
</span>
|
||||
<br />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="rowWrapper wrap"
|
||||
data-index="1"
|
||||
style="transform: translateY(0px);"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
class="LogRow"
|
||||
>
|
||||
<span>
|
||||
world
|
||||
</span>
|
||||
<br />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="anchorLine"
|
||||
style="bottom: 0px;"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
126
src/renderer/components/dock/logs/__test__/log-list.test.tsx
Normal file
126
src/renderer/components/dock/logs/__test__/log-list.test.tsx
Normal file
@ -0,0 +1,126 @@
|
||||
/**
|
||||
* Copyright (c) OpenLens Authors. All rights reserved.
|
||||
* Licensed under MIT License. See LICENSE in root directory for more information.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { getDiForUnitTesting } from "../../../../getDiForUnitTesting";
|
||||
import { SearchStore } from "../../../../search-store/search-store";
|
||||
import type { DiRender } from "../../../test-utils/renderFor";
|
||||
import { renderFor } from "../../../test-utils/renderFor";
|
||||
import type { TabId } from "../../dock/store";
|
||||
import { LogList } from "../log-list";
|
||||
import type { LogTabViewModelDependencies } from "../logs-view-model";
|
||||
import { LogTabViewModel } from "../logs-view-model";
|
||||
import type { LogTabData } from "../tab-store";
|
||||
import { dockerPod } from "./pod.mock";
|
||||
|
||||
Object.defineProperty(window, "IntersectionObserver", {
|
||||
writable: true,
|
||||
value: jest.fn().mockImplementation(() => ({
|
||||
observe: jest.fn(),
|
||||
disconnect: jest.fn(),
|
||||
unobserve: jest.fn(),
|
||||
})),
|
||||
});
|
||||
|
||||
function mockLogTabViewModel(tabId: TabId, deps: Partial<LogTabViewModelDependencies>): LogTabViewModel {
|
||||
return new LogTabViewModel(tabId, {
|
||||
getLogs: jest.fn(),
|
||||
getLogsWithoutTimestamps: jest.fn(),
|
||||
getVisibleLogs: jest.fn(),
|
||||
getTimestampSplitLogs: jest.fn(),
|
||||
getLogTabData: jest.fn(),
|
||||
setLogTabData: jest.fn(),
|
||||
loadLogs: jest.fn(),
|
||||
reloadLogs: jest.fn(),
|
||||
renameTab: jest.fn(),
|
||||
stopLoadingLogs: jest.fn(),
|
||||
getPodById: jest.fn(),
|
||||
getPodsByOwnerId: jest.fn(),
|
||||
areLogsPresent: jest.fn(),
|
||||
searchStore: new SearchStore(),
|
||||
downloadLogs: jest.fn(),
|
||||
downloadAllLogs: jest.fn(),
|
||||
...deps,
|
||||
});
|
||||
}
|
||||
|
||||
const getOnePodViewModel = (tabId: TabId, deps: Partial<LogTabViewModelDependencies> = {}, logTabData?: Partial<LogTabData>): LogTabViewModel => {
|
||||
const selectedPod = dockerPod;
|
||||
|
||||
return mockLogTabViewModel(tabId, {
|
||||
getLogTabData: () => ({
|
||||
selectedPodId: selectedPod.getId(),
|
||||
selectedContainer: selectedPod.getContainers()[0].name,
|
||||
namespace: selectedPod.getNs(),
|
||||
showPrevious: false,
|
||||
showTimestamps: false,
|
||||
wrap: false,
|
||||
...logTabData,
|
||||
}),
|
||||
getPodById: (id) => {
|
||||
if (id === selectedPod.getId()) {
|
||||
return selectedPod;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
},
|
||||
...deps,
|
||||
});
|
||||
};
|
||||
|
||||
describe("<LogList />", () => {
|
||||
let render: DiRender;
|
||||
|
||||
beforeEach(() => {
|
||||
const di = getDiForUnitTesting({ doGeneralOverrides: true });
|
||||
|
||||
render = renderFor(di);
|
||||
});
|
||||
|
||||
it("renders empty list", () => {
|
||||
const { container } = render(<LogList
|
||||
model={getOnePodViewModel("tabId", {
|
||||
getVisibleLogs: () => [],
|
||||
})} />);
|
||||
|
||||
expect(container.getElementsByClassName(".LogRow")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("renders logs", () => {
|
||||
const model = getOnePodViewModel("foobar", {
|
||||
getVisibleLogs: () => [
|
||||
"hello",
|
||||
"world",
|
||||
],
|
||||
});
|
||||
|
||||
const list = render(<LogList model={model} />);
|
||||
|
||||
expect(list.container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
describe("when user selected to wrap log lines", () => {
|
||||
const model = getOnePodViewModel("foobar", {
|
||||
getVisibleLogs: () => [
|
||||
"hello",
|
||||
"world",
|
||||
],
|
||||
}, {
|
||||
wrap: true,
|
||||
});
|
||||
|
||||
it("renders logs with wrapping", () => {
|
||||
const list = render(<LogList model={model} />);
|
||||
|
||||
expect(list.container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("has specific class applied for log row wrappers", () => {
|
||||
const list = render(<LogList model={model} />);
|
||||
|
||||
expect(list.container.getElementsByClassName("rowWrapper wrap")).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -46,6 +46,7 @@ function mockLogTabViewModel(tabId: TabId, deps: Partial<LogTabViewModelDependen
|
||||
return new LogTabViewModel(tabId, {
|
||||
getLogs: jest.fn(),
|
||||
getLogsWithoutTimestamps: jest.fn(),
|
||||
getVisibleLogs: jest.fn(),
|
||||
getTimestampSplitLogs: jest.fn(),
|
||||
getLogTabData: jest.fn(),
|
||||
setLogTabData: jest.fn(),
|
||||
@ -73,6 +74,7 @@ function getOnePodViewModel(tabId: TabId, deps: Partial<LogTabViewModelDependenc
|
||||
namespace: selectedPod.getNs(),
|
||||
showPrevious: false,
|
||||
showTimestamps: false,
|
||||
wrap: false,
|
||||
}),
|
||||
getPodById: (id) => {
|
||||
if (id === selectedPod.getId()) {
|
||||
@ -101,6 +103,7 @@ const getFewPodsTabData = (tabId: TabId, deps: Partial<LogTabViewModelDependenci
|
||||
namespace: selectedPod.getNs(),
|
||||
showPrevious: false,
|
||||
showTimestamps: false,
|
||||
wrap: false,
|
||||
}),
|
||||
getPodById: (id) => {
|
||||
if (id === selectedPod.getId()) {
|
||||
|
||||
@ -20,6 +20,7 @@ function mockLogTabViewModel(tabId: TabId, deps: Partial<LogTabViewModelDependen
|
||||
return new LogTabViewModel(tabId, {
|
||||
getLogs: jest.fn(),
|
||||
getLogsWithoutTimestamps: jest.fn(),
|
||||
getVisibleLogs: jest.fn(),
|
||||
getTimestampSplitLogs: jest.fn(),
|
||||
getLogTabData: jest.fn(),
|
||||
setLogTabData: jest.fn(),
|
||||
@ -47,6 +48,7 @@ const getOnePodViewModel = (tabId: TabId, deps: Partial<LogTabViewModelDependenc
|
||||
namespace: selectedPod.getNs(),
|
||||
showPrevious: false,
|
||||
showTimestamps: false,
|
||||
wrap: false,
|
||||
}),
|
||||
getPodById: (id) => {
|
||||
if (id === selectedPod.getId()) {
|
||||
@ -61,27 +63,38 @@ const getOnePodViewModel = (tabId: TabId, deps: Partial<LogTabViewModelDependenc
|
||||
|
||||
describe("LogSearch tests", () => {
|
||||
let render: DiRender;
|
||||
let setPrevOverlayActiveMock: jest.SpyInstance<void, []>;
|
||||
let setNextOverlayActiveMock: jest.SpyInstance<void, []>;
|
||||
|
||||
beforeEach(() => {
|
||||
const di = getDiForUnitTesting({ doGeneralOverrides: true });
|
||||
|
||||
setPrevOverlayActiveMock = jest
|
||||
.spyOn(SearchStore.prototype, "setPrevOverlayActive")
|
||||
.mockImplementation(() => jest.fn());
|
||||
|
||||
setNextOverlayActiveMock = jest
|
||||
.spyOn(SearchStore.prototype, "setNextOverlayActive")
|
||||
.mockImplementation(() => jest.fn());
|
||||
|
||||
render = renderFor(di);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
setNextOverlayActiveMock.mockClear();
|
||||
setPrevOverlayActiveMock.mockClear();
|
||||
});
|
||||
|
||||
it("renders w/o errors", () => {
|
||||
const model = getOnePodViewModel("foobar");
|
||||
const { container } = render(
|
||||
<LogSearch
|
||||
model={model}
|
||||
scrollToOverlay={jest.fn()}
|
||||
/>,
|
||||
<LogSearch model={model}/>,
|
||||
);
|
||||
|
||||
expect(container).toBeInstanceOf(HTMLElement);
|
||||
});
|
||||
|
||||
it("should scroll to new active overlay when clicking the previous button", async () => {
|
||||
const scrollToOverlay = jest.fn();
|
||||
const model = getOnePodViewModel("foobar", {
|
||||
getLogsWithoutTimestamps: () => [
|
||||
"hello",
|
||||
@ -90,20 +103,16 @@ describe("LogSearch tests", () => {
|
||||
});
|
||||
|
||||
render(
|
||||
<LogSearch
|
||||
model={model}
|
||||
scrollToOverlay={scrollToOverlay}
|
||||
/>,
|
||||
<LogSearch model={model}/>,
|
||||
);
|
||||
|
||||
|
||||
userEvent.click(await screen.findByPlaceholderText("Search..."));
|
||||
userEvent.keyboard("o");
|
||||
userEvent.click(await screen.findByText("keyboard_arrow_up"));
|
||||
expect(scrollToOverlay).toBeCalled();
|
||||
expect(setPrevOverlayActiveMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should scroll to new active overlay when clicking the next button", async () => {
|
||||
const scrollToOverlay = jest.fn();
|
||||
const model = getOnePodViewModel("foobar", {
|
||||
getLogsWithoutTimestamps: () => [
|
||||
"hello",
|
||||
@ -112,20 +121,16 @@ describe("LogSearch tests", () => {
|
||||
});
|
||||
|
||||
render(
|
||||
<LogSearch
|
||||
model={model}
|
||||
scrollToOverlay={scrollToOverlay}
|
||||
/>,
|
||||
<LogSearch model={model}/>,
|
||||
);
|
||||
|
||||
userEvent.click(await screen.findByPlaceholderText("Search..."));
|
||||
userEvent.keyboard("o");
|
||||
userEvent.click(await screen.findByText("keyboard_arrow_down"));
|
||||
expect(scrollToOverlay).toBeCalled();
|
||||
expect(setNextOverlayActiveMock).toBeCalled();
|
||||
});
|
||||
|
||||
it("next and previous should be disabled initially", async () => {
|
||||
const scrollToOverlay = jest.fn();
|
||||
const model = getOnePodViewModel("foobar", {
|
||||
getLogsWithoutTimestamps: () => [
|
||||
"hello",
|
||||
@ -134,14 +139,12 @@ describe("LogSearch tests", () => {
|
||||
});
|
||||
|
||||
render(
|
||||
<LogSearch
|
||||
model={model}
|
||||
scrollToOverlay={scrollToOverlay}
|
||||
/>,
|
||||
<LogSearch model={model}/>,
|
||||
);
|
||||
|
||||
userEvent.click(await screen.findByText("keyboard_arrow_down"));
|
||||
userEvent.click(await screen.findByText("keyboard_arrow_up"));
|
||||
expect(scrollToOverlay).not.toBeCalled();
|
||||
expect(setNextOverlayActiveMock).not.toBeCalled();
|
||||
expect(setPrevOverlayActiveMock).not.toBeCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@ -26,12 +26,6 @@ describe("<ToBottom/>", () => {
|
||||
expect(container).toBeInstanceOf(HTMLElement);
|
||||
});
|
||||
|
||||
it("has 'To bottom' label", () => {
|
||||
const { getByText } = render(<ToBottom onClick={noop}/>);
|
||||
|
||||
expect(getByText("To bottom")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("has a arrow down icon", () => {
|
||||
const { getByText } = render(<ToBottom onClick={noop}/>);
|
||||
|
||||
@ -42,7 +36,7 @@ describe("<ToBottom/>", () => {
|
||||
const callback = jest.fn();
|
||||
const { getByText } = render(<ToBottom onClick={callback}/>);
|
||||
|
||||
fireEvent.click(getByText("To bottom"));
|
||||
fireEvent.click(getByText("expand_more"));
|
||||
expect(callback).toBeCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,6 +1,4 @@
|
||||
.controls {
|
||||
@include hidden-scrollbar;
|
||||
|
||||
display: flex;
|
||||
gap: var(--padding);
|
||||
align-items: center;
|
||||
|
||||
@ -25,13 +25,17 @@ export const LogControls = observer(({ model }: LogControlsProps) => {
|
||||
}
|
||||
|
||||
const logs = model.timestampSplitLogs.get();
|
||||
const { showTimestamps, showPrevious: previous } = tabData;
|
||||
const { showTimestamps, showPrevious: previous, wrap } = tabData;
|
||||
const since = logs.length ? logs[0][0] : null;
|
||||
|
||||
const toggleTimestamps = () => {
|
||||
model.updateLogTabData({ showTimestamps: !showTimestamps });
|
||||
};
|
||||
|
||||
const toggleWrap = () => {
|
||||
model.updateLogTabData({ wrap: !wrap });
|
||||
};
|
||||
|
||||
const togglePrevious = () => {
|
||||
model.updateLogTabData({ showPrevious: !previous });
|
||||
model.reloadLogs();
|
||||
@ -49,6 +53,12 @@ export const LogControls = observer(({ model }: LogControlsProps) => {
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gaps align-center">
|
||||
<Checkbox
|
||||
label="Wrap logs"
|
||||
value={wrap}
|
||||
onChange={toggleWrap}
|
||||
className="wrap-logs"
|
||||
/>
|
||||
<Checkbox
|
||||
label="Show timestamps"
|
||||
value={showTimestamps}
|
||||
|
||||
@ -31,6 +31,7 @@ const createLogsTab = ({ createDockTab, setLogTabData, getRandomId }: Dependenci
|
||||
setLogTabData(id, {
|
||||
showTimestamps: false,
|
||||
showPrevious: false,
|
||||
wrap: false,
|
||||
...data,
|
||||
});
|
||||
});
|
||||
|
||||
@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Copyright (c) OpenLens Authors. All rights reserved.
|
||||
* Licensed under MIT License. See LICENSE in root directory for more information.
|
||||
*/
|
||||
import { getInjectable } from "@ogre-tools/injectable";
|
||||
import moment from "moment";
|
||||
import userStoreInjectable from "../../../../common/user-store/user-store.injectable";
|
||||
import type { TabId } from "../dock/store";
|
||||
import getLogTabDataInjectable from "./get-log-tab-data.injectable";
|
||||
import getLogsWithoutTimestampsInjectable from "./get-logs-without-timestamps.injectable";
|
||||
import getTimestampSplitLogsInjectable from "./get-timestamp-split-logs.injectable";
|
||||
|
||||
const getVisibleLogsInjectable = getInjectable({
|
||||
id: "get-visible-logs",
|
||||
|
||||
instantiate: (di) => {
|
||||
return (tabId: TabId) => {
|
||||
const getLogTabData = di.inject(getLogTabDataInjectable);
|
||||
const getTimestampSplitLogs = di.inject(getTimestampSplitLogsInjectable);
|
||||
const userStore = di.inject(userStoreInjectable);
|
||||
const logTabData = getLogTabData(tabId);
|
||||
|
||||
if (!logTabData) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const { showTimestamps } = logTabData;
|
||||
|
||||
if (!showTimestamps) {
|
||||
const getLogsWithoutTimestamps = di.inject(getLogsWithoutTimestampsInjectable);
|
||||
|
||||
return getLogsWithoutTimestamps(tabId);
|
||||
}
|
||||
|
||||
return getTimestampSplitLogs(tabId).map(([logTimestamp, log]) => (
|
||||
`${logTimestamp && moment.tz(logTimestamp, userStore.localeTimezone).format()}${log}`
|
||||
));
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export default getVisibleLogsInjectable;
|
||||
@ -1,71 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) OpenLens Authors. All rights reserved.
|
||||
* Licensed under MIT License. See LICENSE in root directory for more information.
|
||||
*/
|
||||
|
||||
.LogList {
|
||||
--overlay-bg: #8cc474b8;
|
||||
--overlay-active-bg: orange;
|
||||
|
||||
// fix for `this.logsElement.scrollTop = this.logsElement.scrollHeight`
|
||||
// `overflow: overlay` don't allow scroll to the last line
|
||||
overflow: auto;
|
||||
|
||||
position: relative;
|
||||
color: var(--logsForeground);
|
||||
background: var(--logsBackground);
|
||||
flex-grow: 1;
|
||||
|
||||
.VirtualList {
|
||||
height: 100%;
|
||||
|
||||
.list {
|
||||
overflow-x: scroll!important;
|
||||
|
||||
.LogRow {
|
||||
padding: 2px 16px;
|
||||
height: 18px; // Must be equal to lineHeight variable in pod-log-list.tsx
|
||||
font-family: var(--font-monospace);
|
||||
font-size: smaller;
|
||||
white-space: nowrap;
|
||||
|
||||
&:hover {
|
||||
background: var(--logRowHoverBackground);
|
||||
}
|
||||
|
||||
span {
|
||||
-webkit-font-smoothing: auto; // Better readability on non-retina screens
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
span.overlay {
|
||||
border-radius: 2px;
|
||||
-webkit-font-smoothing: auto;
|
||||
background-color: var(--overlay-bg);
|
||||
|
||||
span {
|
||||
background-color: var(--overlay-bg)!important; // Rewriting inline styles from AnsiUp library
|
||||
}
|
||||
|
||||
&.active {
|
||||
background-color: var(--overlay-active-bg);
|
||||
|
||||
span {
|
||||
background-color: var(--overlay-active-bg)!important; // Rewriting inline styles from AnsiUp library
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.isLoading {
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
&.isScrollHidden {
|
||||
.VirtualList .list {
|
||||
overflow-x: hidden!important; // fixing scroll to bottom issues in PodLogs
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,278 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) OpenLens Authors. All rights reserved.
|
||||
* Licensed under MIT License. See LICENSE in root directory for more information.
|
||||
*/
|
||||
|
||||
import "./list.scss";
|
||||
|
||||
import type { ForwardedRef } from "react";
|
||||
import React from "react";
|
||||
import AnsiUp from "ansi_up";
|
||||
import DOMPurify from "dompurify";
|
||||
import debounce from "lodash/debounce";
|
||||
import { action, computed, observable, makeObservable, reaction } from "mobx";
|
||||
import { disposeOnUnmount, observer } from "mobx-react";
|
||||
import moment from "moment-timezone";
|
||||
import type { Align, ListOnScrollProps } from "react-window";
|
||||
import { SearchStore } from "../../../search-store/search-store";
|
||||
import { UserStore } from "../../../../common/user-store";
|
||||
import { array, autoBind, cssNames } from "../../../utils";
|
||||
import type { VirtualListRef } from "../../virtual-list";
|
||||
import { VirtualList } from "../../virtual-list";
|
||||
import { ToBottom } from "./to-bottom";
|
||||
import type { LogTabViewModel } from "../logs/logs-view-model";
|
||||
import { Spinner } from "../../spinner";
|
||||
|
||||
export interface LogListProps {
|
||||
model: LogTabViewModel;
|
||||
}
|
||||
|
||||
const colorConverter = new AnsiUp();
|
||||
|
||||
export interface LogListRef {
|
||||
scrollToItem: (index: number, align: Align) => void;
|
||||
}
|
||||
|
||||
@observer
|
||||
class NonForwardedLogList extends React.Component<LogListProps & { innerRef: ForwardedRef<LogListRef> }> {
|
||||
@observable isJumpButtonVisible = false;
|
||||
@observable isLastLineVisible = true;
|
||||
|
||||
private virtualListDiv = React.createRef<HTMLDivElement>(); // A reference for outer container in VirtualList
|
||||
private virtualListRef = React.createRef<VirtualListRef>(); // A reference for VirtualList component
|
||||
private lineHeight = 18; // Height of a log line. Should correlate with styles in pod-log-list.scss
|
||||
|
||||
constructor(props: any) {
|
||||
super(props);
|
||||
makeObservable(this);
|
||||
autoBind(this);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
disposeOnUnmount(this, [
|
||||
reaction(() => this.props.model.logs.get(), (logs, prevLogs) => {
|
||||
this.onLogsInitialLoad(logs, prevLogs);
|
||||
this.onLogsUpdate();
|
||||
this.onUserScrolledUp(logs, prevLogs);
|
||||
}),
|
||||
]);
|
||||
this.bindInnerRef({
|
||||
scrollToItem: this.scrollToItem,
|
||||
});
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
this.bindInnerRef({
|
||||
scrollToItem: this.scrollToItem,
|
||||
});
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.bindInnerRef(null);
|
||||
}
|
||||
|
||||
private bindInnerRef(value: LogListRef | null) {
|
||||
if (typeof this.props.innerRef === "function") {
|
||||
this.props.innerRef(value);
|
||||
} else if (this.props.innerRef && typeof this.props.innerRef === "object") {
|
||||
this.props.innerRef.current = value;
|
||||
}
|
||||
}
|
||||
|
||||
onLogsInitialLoad(logs: string[], prevLogs: string[]) {
|
||||
if (!prevLogs.length && logs.length) {
|
||||
this.isLastLineVisible = true;
|
||||
}
|
||||
}
|
||||
|
||||
onLogsUpdate() {
|
||||
if (this.isLastLineVisible) {
|
||||
setTimeout(() => {
|
||||
this.scrollToBottom();
|
||||
}, 500); // Giving some time to VirtualList to prepare its outerRef (this.virtualListDiv) element
|
||||
}
|
||||
}
|
||||
|
||||
onUserScrolledUp(logs: string[], prevLogs: string[]) {
|
||||
if (!this.virtualListDiv.current) return;
|
||||
|
||||
const newLogsAdded = prevLogs.length < logs.length;
|
||||
const scrolledToBeginning = this.virtualListDiv.current.scrollTop === 0;
|
||||
|
||||
if (newLogsAdded && scrolledToBeginning) {
|
||||
const firstLineContents = prevLogs[0];
|
||||
const lineToScroll = logs.findIndex((value) => value == firstLineContents);
|
||||
|
||||
if (lineToScroll !== -1) {
|
||||
this.scrollToItem(lineToScroll, "start");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns logs with or without timestamps regarding to showTimestamps prop
|
||||
*/
|
||||
@computed
|
||||
get logs(): string[] {
|
||||
const { showTimestamps } = this.props.model.logTabData.get() ?? {};
|
||||
|
||||
if (!showTimestamps) {
|
||||
return this.props.model.logsWithoutTimestamps.get();
|
||||
}
|
||||
|
||||
return this.props.model.timestampSplitLogs
|
||||
.get()
|
||||
.map(([logTimestamp, log]) => (`${logTimestamp && moment.tz(logTimestamp, UserStore.getInstance().localeTimezone).format()}${log}`));
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if JumpToBottom button should be visible and sets its observable
|
||||
* @param props Scrolling props from virtual list core
|
||||
*/
|
||||
setButtonVisibility = action(({ scrollOffset }: ListOnScrollProps, { scrollHeight }: HTMLDivElement) => {
|
||||
const offset = 100 * this.lineHeight;
|
||||
|
||||
if (scrollHeight - scrollOffset < offset) {
|
||||
this.isJumpButtonVisible = false;
|
||||
} else {
|
||||
this.isJumpButtonVisible = true;
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Checks if last log line considered visible to user, setting its observable
|
||||
* @param props Scrolling props from virtual list core
|
||||
*/
|
||||
setLastLineVisibility = action(({ scrollOffset }: ListOnScrollProps, { scrollHeight, clientHeight }: HTMLDivElement) => {
|
||||
this.isLastLineVisible = (clientHeight + scrollOffset) === scrollHeight;
|
||||
});
|
||||
|
||||
/**
|
||||
* Check if user scrolled to top and new logs should be loaded
|
||||
* @param props Scrolling props from virtual list core
|
||||
*/
|
||||
checkLoadIntent = (props: ListOnScrollProps) => {
|
||||
const { scrollOffset } = props;
|
||||
|
||||
if (scrollOffset === 0) {
|
||||
this.props.model.loadLogs();
|
||||
}
|
||||
};
|
||||
|
||||
scrollToBottom = () => {
|
||||
if (!this.virtualListDiv.current) return;
|
||||
this.virtualListDiv.current.scrollTop = this.virtualListDiv.current.scrollHeight;
|
||||
};
|
||||
|
||||
scrollToItem = (index: number, align: Align) => {
|
||||
this.virtualListRef.current?.scrollToItem(index, align);
|
||||
};
|
||||
|
||||
onScroll = (props: ListOnScrollProps) => {
|
||||
this.isLastLineVisible = false;
|
||||
this.onScrollDebounced(props);
|
||||
};
|
||||
|
||||
onScrollDebounced = debounce((props: ListOnScrollProps) => {
|
||||
const virtualList = this.virtualListDiv.current;
|
||||
|
||||
if (virtualList) {
|
||||
this.setButtonVisibility(props, virtualList);
|
||||
this.setLastLineVisibility(props, virtualList);
|
||||
this.checkLoadIntent(props);
|
||||
}
|
||||
}, 700); // Increasing performance and giving some time for virtual list to settle down
|
||||
|
||||
/**
|
||||
* A function is called by VirtualList for rendering each of the row
|
||||
* @param rowIndex index of the log element in logs array
|
||||
* @returns A react element with a row itself
|
||||
*/
|
||||
getLogRow = (rowIndex: number) => {
|
||||
const { searchQuery, isActiveOverlay } = this.props.model.searchStore;
|
||||
const item = this.logs[rowIndex];
|
||||
const contents: React.ReactElement[] = [];
|
||||
const ansiToHtml = (ansi: string) => DOMPurify.sanitize(colorConverter.ansi_to_html(ansi));
|
||||
|
||||
if (searchQuery) { // If search is enabled, replace keyword with backgrounded <span>
|
||||
// Case-insensitive search (lowercasing query and keywords in line)
|
||||
const regex = new RegExp(SearchStore.escapeRegex(searchQuery), "gi");
|
||||
const matches = item.matchAll(regex);
|
||||
const modified = item.replace(regex, match => match.toLowerCase());
|
||||
// Splitting text line by keyword
|
||||
const pieces = modified.split(searchQuery.toLowerCase());
|
||||
|
||||
pieces.forEach((piece, index) => {
|
||||
const active = isActiveOverlay(rowIndex, index);
|
||||
const lastItem = index === pieces.length - 1;
|
||||
const overlayValue = matches.next().value;
|
||||
const overlay = !lastItem
|
||||
? (
|
||||
<span
|
||||
className={cssNames("overlay", { active })}
|
||||
dangerouslySetInnerHTML={{ __html: ansiToHtml(overlayValue) }}
|
||||
/>
|
||||
)
|
||||
: null;
|
||||
|
||||
contents.push(
|
||||
<React.Fragment key={piece + index}>
|
||||
<span dangerouslySetInnerHTML={{ __html: ansiToHtml(piece) }} />
|
||||
{overlay}
|
||||
</React.Fragment>,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cssNames("LogRow")}>
|
||||
{contents.length > 1 ? contents : (
|
||||
<span dangerouslySetInnerHTML={{ __html: ansiToHtml(item) }} />
|
||||
)}
|
||||
{/* For preserving copy-paste experience and keeping line breaks */}
|
||||
<br />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
render() {
|
||||
if (this.props.model.isLoading.get()) {
|
||||
return (
|
||||
<div className="LogList flex box grow align-center justify-center">
|
||||
<Spinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!this.logs.length) {
|
||||
return (
|
||||
<div className="LogList flex box grow align-center justify-center">
|
||||
There are no logs available for container
|
||||
{" "}
|
||||
{this.props.model.logTabData.get()?.selectedContainer}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cssNames("LogList flex" )}>
|
||||
<VirtualList
|
||||
items={this.logs}
|
||||
rowHeights={array.filled(this.logs.length, this.lineHeight)}
|
||||
getRow={this.getLogRow}
|
||||
onScroll={this.onScroll}
|
||||
outerRef={this.virtualListDiv}
|
||||
ref={this.virtualListRef}
|
||||
className="box grow"
|
||||
/>
|
||||
{this.isJumpButtonVisible && (
|
||||
<ToBottom onClick={this.scrollToBottom} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const LogList = React.forwardRef<LogListRef, LogListProps>((props, ref) => (
|
||||
<NonForwardedLogList {...props} innerRef={ref} />
|
||||
));
|
||||
37
src/renderer/components/dock/logs/log-list.module.scss
Normal file
37
src/renderer/components/dock/logs/log-list.module.scss
Normal file
@ -0,0 +1,37 @@
|
||||
.LogList {
|
||||
flex-grow: 1;
|
||||
overflow: auto;
|
||||
color: var(--logsForeground);
|
||||
background: var(--logsBackground);
|
||||
}
|
||||
|
||||
.virtualizer {
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.rowWrapper {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
font-family: var(--font-monospace);
|
||||
font-size: smaller;
|
||||
line-height: 120%;
|
||||
white-space: nowrap;
|
||||
|
||||
> * {
|
||||
-webkit-font-smoothing: auto; // Better readability on non-retina screens
|
||||
}
|
||||
|
||||
&.wrap {
|
||||
white-space: normal;
|
||||
}
|
||||
}
|
||||
|
||||
.anchorLine {
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
background-color: var(--logsBackground);
|
||||
position: absolute;
|
||||
}
|
||||
106
src/renderer/components/dock/logs/log-list.tsx
Normal file
106
src/renderer/components/dock/logs/log-list.tsx
Normal file
@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Copyright (c) OpenLens Authors. All rights reserved.
|
||||
* Licensed under MIT License. See LICENSE in root directory for more information.
|
||||
*/
|
||||
|
||||
import styles from "./log-list.module.scss";
|
||||
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import { observer } from "mobx-react";
|
||||
import React, { useRef } from "react";
|
||||
import { cssNames } from "../../../utils";
|
||||
import { LogRow } from "./log-row";
|
||||
import type { LogTabViewModel } from "./logs-view-model";
|
||||
import { ToBottom } from "./to-bottom";
|
||||
import { useInitialScrollToBottom } from "./use-initial-scroll-to-bottom";
|
||||
import { useOnScrollTop } from "./use-on-scroll-top";
|
||||
import { useRefreshListOnDataChange } from "./use-refresh-list-on-data-change";
|
||||
import { useScrollOnSearch } from "./use-scroll-on-search";
|
||||
import { useJumpToBottomButton } from "./use-scroll-to-bottom";
|
||||
import { useStickToBottomOnLogsLoad } from "./use-stick-to-bottom-on-logs-load";
|
||||
|
||||
export interface LogListProps {
|
||||
model: LogTabViewModel;
|
||||
}
|
||||
|
||||
export const LogList = observer(({ model }: LogListProps) => {
|
||||
const { visibleLogs } = model;
|
||||
const parentRef = useRef<HTMLDivElement>(null);
|
||||
const topLineRef = useRef<HTMLDivElement>(null);
|
||||
const bottomLineRef = useRef<HTMLDivElement>(null);
|
||||
const [toBottomVisible, setButtonVisibility] = useJumpToBottomButton(parentRef.current);
|
||||
const uniqRowKey = useRefreshListOnDataChange(model.logTabData.get());
|
||||
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: visibleLogs.get().length,
|
||||
getScrollElement: () => parentRef.current,
|
||||
estimateSize: () => 38,
|
||||
overscan: 5,
|
||||
enableSmoothScroll: false,
|
||||
});
|
||||
|
||||
const scrollTo = (index: number) => {
|
||||
rowVirtualizer.scrollToIndex(index, { align: "start", smoothScroll: false });
|
||||
};
|
||||
|
||||
const scrollToBottom = () => {
|
||||
scrollTo(visibleLogs.get().length - 1);
|
||||
};
|
||||
|
||||
const onScroll = () => {
|
||||
if (!parentRef.current) return;
|
||||
|
||||
setButtonVisibility();
|
||||
};
|
||||
|
||||
useInitialScrollToBottom(model, scrollToBottom);
|
||||
useScrollOnSearch(model.searchStore, scrollTo);
|
||||
useStickToBottomOnLogsLoad({ bottomLineRef, model, scrollToBottom });
|
||||
useOnScrollTop({ topLineRef, model, scrollTo });
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={parentRef}
|
||||
className={styles.LogList}
|
||||
onScroll={onScroll}
|
||||
data-testid="pod-log-list"
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: `${rowVirtualizer.getTotalSize()}px`,
|
||||
}}
|
||||
className={styles.virtualizer}
|
||||
>
|
||||
<div
|
||||
className={styles.anchorLine}
|
||||
ref={topLineRef}
|
||||
style={{ top: 0 }}
|
||||
/>
|
||||
{rowVirtualizer.getVirtualItems().map((virtualRow) => (
|
||||
<div
|
||||
key={virtualRow.index + uniqRowKey}
|
||||
data-index={virtualRow.index}
|
||||
ref={rowVirtualizer.measureElement}
|
||||
style={{
|
||||
transform: `translateY(${virtualRow.start}px)`,
|
||||
}}
|
||||
className={cssNames(styles.rowWrapper, { [styles.wrap]: model.logTabData.get()?.wrap })}
|
||||
>
|
||||
<div>
|
||||
<LogRow rowIndex={virtualRow.index} model={model} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div
|
||||
className={styles.anchorLine}
|
||||
ref={bottomLineRef}
|
||||
style={{ bottom: 0 }}
|
||||
/>
|
||||
</div>
|
||||
{toBottomVisible && (
|
||||
<ToBottom onClick={scrollToBottom} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
29
src/renderer/components/dock/logs/log-row.module.scss
Normal file
29
src/renderer/components/dock/logs/log-row.module.scss
Normal file
@ -0,0 +1,29 @@
|
||||
.LogRow {
|
||||
padding: 2px calc(var(--padding) * 2);
|
||||
line-height: 150%;
|
||||
|
||||
&:hover {
|
||||
background: var(--logRowHoverBackground);
|
||||
}
|
||||
}
|
||||
|
||||
.overlay {
|
||||
--overlay-bg: #8cc474b8;
|
||||
--overlay-active-bg: orange;
|
||||
|
||||
border-radius: 2px;
|
||||
-webkit-font-smoothing: auto;
|
||||
background-color: var(--overlay-bg);
|
||||
|
||||
span {
|
||||
background-color: var(--overlay-bg)!important; // Rewriting inline styles from AnsiUp library
|
||||
}
|
||||
|
||||
&.active {
|
||||
background-color: var(--overlay-active-bg);
|
||||
|
||||
span {
|
||||
background-color: var(--overlay-active-bg)!important; // Rewriting inline styles from AnsiUp library
|
||||
}
|
||||
}
|
||||
}
|
||||
62
src/renderer/components/dock/logs/log-row.tsx
Normal file
62
src/renderer/components/dock/logs/log-row.tsx
Normal file
@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Copyright (c) OpenLens Authors. All rights reserved.
|
||||
* Licensed under MIT License. See LICENSE in root directory for more information.
|
||||
*/
|
||||
import styles from "./log-row.module.scss";
|
||||
|
||||
import AnsiUp from "ansi_up";
|
||||
import DOMPurify from "dompurify";
|
||||
import React from "react";
|
||||
import { SearchStore } from "../../../search-store/search-store";
|
||||
import { cssNames } from "../../../utils";
|
||||
import type { LogTabViewModel } from "./logs-view-model";
|
||||
|
||||
const colorConverter = new AnsiUp();
|
||||
|
||||
export function LogRow({ rowIndex, model }: { rowIndex: number; model: LogTabViewModel }) {
|
||||
const { searchQuery, isActiveOverlay } = model.searchStore;
|
||||
const log = model.visibleLogs.get()[rowIndex];
|
||||
const contents: React.ReactElement[] = [];
|
||||
const ansiToHtml = (ansi: string) => DOMPurify.sanitize(colorConverter.ansi_to_html(ansi));
|
||||
|
||||
if (searchQuery) { // If search is enabled, replace keyword with backgrounded <span>
|
||||
// Case-insensitive search (lowercasing query and keywords in line)
|
||||
const regex = new RegExp(SearchStore.escapeRegex(searchQuery), "gi");
|
||||
const matches = log.matchAll(regex);
|
||||
const modified = log.replace(regex, match => match.toLowerCase());
|
||||
// Splitting text line by keyword
|
||||
const pieces = modified.split(searchQuery.toLowerCase());
|
||||
|
||||
pieces.forEach((piece, index) => {
|
||||
const active = isActiveOverlay(rowIndex, index);
|
||||
const lastItem = index === pieces.length - 1;
|
||||
const overlayValue = matches.next().value;
|
||||
const overlay = !lastItem
|
||||
? (
|
||||
<span
|
||||
className={cssNames(styles.overlay, { [styles.active]: active })}
|
||||
data-testid={active ? "search-overlay-active" : "search-overlay"}
|
||||
dangerouslySetInnerHTML={{ __html: ansiToHtml(overlayValue) }}
|
||||
/>
|
||||
)
|
||||
: null;
|
||||
|
||||
contents.push(
|
||||
<React.Fragment key={piece + index}>
|
||||
<span dangerouslySetInnerHTML={{ __html: ansiToHtml(piece) }} />
|
||||
{overlay}
|
||||
</React.Fragment>,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.LogRow}>
|
||||
{contents.length > 1 ? contents : (
|
||||
<span dangerouslySetInnerHTML={{ __html: ansiToHtml(log) }} />
|
||||
)}
|
||||
{/* For preserving copy-paste experience and keeping line breaks */}
|
||||
<br />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -20,6 +20,7 @@ import getPodsByOwnerIdInjectable from "../../+workloads-pods/get-pods-by-owner-
|
||||
import getPodByIdInjectable from "../../+workloads-pods/get-pod-by-id.injectable";
|
||||
import downloadLogsInjectable from "./download-logs.injectable";
|
||||
import downloadAllLogsInjectable from "./download-all-logs.injectable";
|
||||
import getVisibleLogsInjectable from "./get-visible-logs.injectable";
|
||||
|
||||
export interface InstantiateArgs {
|
||||
tabId: TabId;
|
||||
@ -32,6 +33,7 @@ const logsViewModelInjectable = getInjectable({
|
||||
getLogs: di.inject(getLogsInjectable),
|
||||
getLogsWithoutTimestamps: di.inject(getLogsWithoutTimestampsInjectable),
|
||||
getTimestampSplitLogs: di.inject(getTimestampSplitLogsInjectable),
|
||||
getVisibleLogs: di.inject(getVisibleLogsInjectable),
|
||||
reloadLogs: di.inject(reloadLogsInjectable),
|
||||
getLogTabData: di.inject(getLogTabDataInjectable),
|
||||
setLogTabData: di.inject(setLogTabDataInjectable),
|
||||
|
||||
@ -19,6 +19,7 @@ export interface LogTabViewModelDependencies {
|
||||
getLogs: (tabId: TabId) => string[];
|
||||
getLogsWithoutTimestamps: (tabId: TabId) => string[];
|
||||
getTimestampSplitLogs: (tabId: TabId) => [string, string][];
|
||||
getVisibleLogs: (tabId: TabId) => string[];
|
||||
getLogTabData: (tabId: TabId) => LogTabData | undefined;
|
||||
setLogTabData: (tabId: TabId, data: LogTabData) => void;
|
||||
loadLogs: LoadLogs;
|
||||
@ -45,6 +46,7 @@ export class LogTabViewModel {
|
||||
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 visibleLogs = computed(() => this.dependencies.getVisibleLogs(this.tabId));
|
||||
readonly pods = computed(() => {
|
||||
const data = this.logTabData.get();
|
||||
|
||||
|
||||
@ -13,18 +13,13 @@ import type { LogTabViewModel } from "./logs-view-model";
|
||||
|
||||
export interface PodLogSearchProps {
|
||||
onSearch?: (query: string) => void;
|
||||
scrollToOverlay: (lineNumber: number | undefined) => void;
|
||||
model: LogTabViewModel;
|
||||
}
|
||||
|
||||
export const LogSearch = observer(({ onSearch, scrollToOverlay, model: { logTabData, searchStore, ...model }}: PodLogSearchProps) => {
|
||||
export const LogSearch = observer(({ onSearch, model: { logTabData, searchStore, ...model }}: PodLogSearchProps) => {
|
||||
const tabData = logTabData.get();
|
||||
|
||||
if (!tabData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const logs = tabData.showTimestamps
|
||||
const logs = tabData?.showTimestamps
|
||||
? model.logs.get()
|
||||
: model.logsWithoutTimestamps.get();
|
||||
const { setNextOverlayActive, setPrevOverlayActive, searchQuery, occurrences, activeFind, totalFinds } = searchStore;
|
||||
@ -33,17 +28,14 @@ export const LogSearch = observer(({ onSearch, scrollToOverlay, model: { logTabD
|
||||
const setSearch = (query: string) => {
|
||||
searchStore.onSearch(logs, query);
|
||||
onSearch?.(query);
|
||||
scrollToOverlay(searchStore.activeOverlayLine);
|
||||
};
|
||||
|
||||
const onPrevOverlay = () => {
|
||||
setPrevOverlayActive();
|
||||
scrollToOverlay(searchStore.activeOverlayLine);
|
||||
};
|
||||
|
||||
const onNextOverlay = () => {
|
||||
setNextOverlayActive();
|
||||
scrollToOverlay(searchStore.activeOverlayLine);
|
||||
};
|
||||
|
||||
const onClear = () => {
|
||||
|
||||
@ -54,6 +54,11 @@ export interface LogTabData {
|
||||
* Whether to show the logs of the previous container instance
|
||||
*/
|
||||
showPrevious: boolean;
|
||||
|
||||
/**
|
||||
* Whether to wrap logs lines to avoid horizontal scrolling.
|
||||
*/
|
||||
wrap: boolean;
|
||||
}
|
||||
|
||||
interface Dependencies {
|
||||
|
||||
8
src/renderer/components/dock/logs/to-bottom.module.scss
Normal file
8
src/renderer/components/dock/logs/to-bottom.module.scss
Normal file
@ -0,0 +1,8 @@
|
||||
.ToBottom {
|
||||
position: absolute;
|
||||
top: 64px;
|
||||
right: 16px;
|
||||
border-radius: 4px;
|
||||
padding: 4px 6px;
|
||||
background-color: var(--colorInfo);
|
||||
}
|
||||
@ -2,20 +2,20 @@
|
||||
* Copyright (c) OpenLens Authors. All rights reserved.
|
||||
* Licensed under MIT License. See LICENSE in root directory for more information.
|
||||
*/
|
||||
import styles from "./to-bottom.module.scss";
|
||||
|
||||
import React from "react";
|
||||
import { Icon } from "../../icon";
|
||||
|
||||
export function ToBottom({ onClick }: { onClick: () => void }) {
|
||||
return (
|
||||
<button
|
||||
className="absolute top-3 right-3 z-10 rounded-md flex align-center px-1 py-1 pl-3"
|
||||
style={{ backgroundColor: "var(--blue)" }}
|
||||
className={styles.ToBottom}
|
||||
onClick={evt => {
|
||||
evt.currentTarget.blur();
|
||||
onClick();
|
||||
}}
|
||||
>
|
||||
To bottom
|
||||
<Icon small material="expand_more" />
|
||||
</button>
|
||||
);
|
||||
|
||||
@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Copyright (c) OpenLens Authors. All rights reserved.
|
||||
* Licensed under MIT License. See LICENSE in root directory for more information.
|
||||
*/
|
||||
import { useEffect } from "react";
|
||||
import type { LogTabViewModel } from "./logs-view-model";
|
||||
|
||||
export function useInitialScrollToBottom(model: LogTabViewModel, callback: () => void) {
|
||||
useEffect(() => {
|
||||
// TODO: Consider more precise way to check when list ready to scroll
|
||||
setTimeout(() => {
|
||||
callback();
|
||||
}, 500); // Giving some time virtual library to render its rows
|
||||
}, [model.logTabData.get()?.selectedPodId]);
|
||||
}
|
||||
37
src/renderer/components/dock/logs/use-on-scroll-top.ts
Normal file
37
src/renderer/components/dock/logs/use-on-scroll-top.ts
Normal file
@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Copyright (c) OpenLens Authors. All rights reserved.
|
||||
* Licensed under MIT License. See LICENSE in root directory for more information.
|
||||
*/
|
||||
|
||||
import type { RefObject } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { useIntersectionObserver } from "../../../hooks";
|
||||
import type { LogTabViewModel } from "./logs-view-model";
|
||||
|
||||
interface UseStickToBottomProps {
|
||||
topLineRef: RefObject<HTMLDivElement>;
|
||||
model: LogTabViewModel;
|
||||
scrollTo: (index: number) => void;
|
||||
}
|
||||
|
||||
export function useOnScrollTop({ topLineRef, model, scrollTo }: UseStickToBottomProps) {
|
||||
const topLineEntry = useIntersectionObserver(topLineRef.current, {});
|
||||
|
||||
function getPreviouslyFirstLogIndex(firstLog: string) {
|
||||
return model.logs.get().findIndex(log => log === firstLog);
|
||||
}
|
||||
|
||||
async function onScrolledTop() {
|
||||
const firstLog = model.logs.get()[0];
|
||||
const scrollIndex = () => getPreviouslyFirstLogIndex(firstLog);
|
||||
|
||||
await model.loadLogs();
|
||||
scrollTo(scrollIndex());
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (topLineEntry?.isIntersecting) {
|
||||
onScrolledTop();
|
||||
}
|
||||
}, [topLineEntry?.isIntersecting]);
|
||||
}
|
||||
@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Copyright (c) OpenLens Authors. All rights reserved.
|
||||
* Licensed under MIT License. See LICENSE in root directory for more information.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import type { LogTabData } from "./tab-store";
|
||||
import { v4 as getRandomId } from "uuid";
|
||||
|
||||
export function useRefreshListOnDataChange(data: LogTabData | undefined) {
|
||||
const [rowKeySuffix, setRowKeySuffix] = useState(getRandomId());
|
||||
|
||||
useEffect(() => {
|
||||
// Refresh virtualizer list rows by changing their keys
|
||||
setRowKeySuffix(getRandomId());
|
||||
}, [data]);
|
||||
|
||||
return rowKeySuffix;
|
||||
}
|
||||
17
src/renderer/components/dock/logs/use-scroll-on-search.ts
Normal file
17
src/renderer/components/dock/logs/use-scroll-on-search.ts
Normal file
@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Copyright (c) OpenLens Authors. All rights reserved.
|
||||
* Licensed under MIT License. See LICENSE in root directory for more information.
|
||||
*/
|
||||
|
||||
import { useEffect } from "react";
|
||||
import type { SearchStore } from "../../../search-store/search-store";
|
||||
|
||||
export function useScrollOnSearch(store: SearchStore, scrollTo: (index: number) => void) {
|
||||
const { occurrences, searchQuery, activeOverlayIndex } = store;
|
||||
|
||||
useEffect(() => {
|
||||
if (!occurrences.length) return;
|
||||
|
||||
scrollTo(occurrences[activeOverlayIndex]);
|
||||
}, [searchQuery, activeOverlayIndex]);
|
||||
}
|
||||
23
src/renderer/components/dock/logs/use-scroll-to-bottom.ts
Normal file
23
src/renderer/components/dock/logs/use-scroll-to-bottom.ts
Normal file
@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Copyright (c) OpenLens Authors. All rights reserved.
|
||||
* Licensed under MIT License. See LICENSE in root directory for more information.
|
||||
*/
|
||||
import { useState } from "react";
|
||||
|
||||
export function useJumpToBottomButton(scrolledParent: HTMLDivElement | null): [isVisible: boolean, setVisibility: () => void] {
|
||||
const [isVisible, setToBottomVisible] = useState(false);
|
||||
|
||||
const setVisibility = () => {
|
||||
if (!scrolledParent) return;
|
||||
|
||||
const { scrollTop, scrollHeight } = scrolledParent;
|
||||
|
||||
if (scrollHeight - scrollTop > 4000) {
|
||||
setToBottomVisible(true);
|
||||
} else {
|
||||
setToBottomVisible(false);
|
||||
}
|
||||
};
|
||||
|
||||
return [isVisible, setVisibility];
|
||||
}
|
||||
@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Copyright (c) OpenLens Authors. All rights reserved.
|
||||
* Licensed under MIT License. See LICENSE in root directory for more information.
|
||||
*/
|
||||
|
||||
import type { RefObject } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { useIntersectionObserver } from "../../../hooks";
|
||||
import type { LogTabViewModel } from "./logs-view-model";
|
||||
|
||||
interface UseStickToBottomProps {
|
||||
bottomLineRef: RefObject<HTMLDivElement>;
|
||||
model: LogTabViewModel;
|
||||
scrollToBottom: () => void;
|
||||
}
|
||||
|
||||
export function useStickToBottomOnLogsLoad({ bottomLineRef, model, scrollToBottom }: UseStickToBottomProps) {
|
||||
const bottomLineEntry = useIntersectionObserver(bottomLineRef.current, {});
|
||||
|
||||
useEffect(() => {
|
||||
if (bottomLineEntry?.isIntersecting) {
|
||||
scrollToBottom();
|
||||
}
|
||||
}, [model.visibleLogs.get().length]);
|
||||
}
|
||||
@ -3,12 +3,10 @@
|
||||
* Licensed under MIT License. See LICENSE in root directory for more information.
|
||||
*/
|
||||
|
||||
import React, { createRef, useEffect } from "react";
|
||||
import React, { useEffect } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { InfoPanel } from "../info-panel";
|
||||
import { LogResourceSelector } from "./resource-selector";
|
||||
import type { LogListRef } from "./list";
|
||||
import { LogList } from "./list";
|
||||
import { LogSearch } from "./search";
|
||||
import { LogControls } from "./controls";
|
||||
import { withInjectables } from "@ogre-tools/injectable-react";
|
||||
@ -20,6 +18,7 @@ import type { SubscribeStores } from "../../../kube-watch-api/kube-watch-api";
|
||||
import subscribeStoresInjectable from "../../../kube-watch-api/subscribe-stores.injectable";
|
||||
import type { PodStore } from "../../+workloads-pods/store";
|
||||
import podStoreInjectable from "../../+workloads-pods/store.injectable";
|
||||
import { LogList } from "./log-list";
|
||||
|
||||
export interface LogsDockTabProps {
|
||||
className?: string;
|
||||
@ -39,7 +38,6 @@ const NonInjectedLogsDockTab = observer(({
|
||||
subscribeStores,
|
||||
podStore,
|
||||
}: Dependencies & LogsDockTabProps) => {
|
||||
const logListElement = createRef<LogListRef>();
|
||||
const data = model.logTabData.get();
|
||||
|
||||
useEffect(() => {
|
||||
@ -53,27 +51,6 @@ const NonInjectedLogsDockTab = observer(({
|
||||
namespaces: data ? [data.namespace] : [],
|
||||
}), [data?.namespace]);
|
||||
|
||||
const scrollToOverlay = (overlayLine: number | undefined) => {
|
||||
if (!logListElement.current || overlayLine === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Scroll vertically
|
||||
logListElement.current.scrollToItem(overlayLine, "center");
|
||||
// Scroll horizontally in timeout since virtual list need some time to prepare its contents
|
||||
setTimeout(() => {
|
||||
const overlay = document.querySelector(".PodLogs .list span.active");
|
||||
|
||||
if (!overlay) return;
|
||||
// Note: .scrollIntoViewIfNeeded() is non-standard and thus not present in js-dom.
|
||||
overlay?.scrollIntoViewIfNeeded?.();
|
||||
}, 100);
|
||||
};
|
||||
|
||||
if (!data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cssNames("PodLogs flex column", className)}>
|
||||
<InfoPanel
|
||||
@ -81,17 +58,14 @@ const NonInjectedLogsDockTab = observer(({
|
||||
controls={(
|
||||
<div className="flex gaps">
|
||||
<LogResourceSelector model={model} />
|
||||
<LogSearch
|
||||
model={model}
|
||||
scrollToOverlay={scrollToOverlay}
|
||||
/>
|
||||
<LogSearch model={model}/>
|
||||
</div>
|
||||
)}
|
||||
showSubmitClose={false}
|
||||
showButtons={false}
|
||||
showStatusPanel={false}
|
||||
/>
|
||||
<LogList model={model} ref={logListElement} />
|
||||
<LogList model={model} />
|
||||
<LogControls model={model} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -10,3 +10,4 @@ export * from "./useInterval";
|
||||
export * from "./useMutationObserver";
|
||||
export * from "./useResizeObserver";
|
||||
export * from "./use-toggle";
|
||||
export * from "./useIntersectionObserver";
|
||||
|
||||
66
src/renderer/hooks/useIntersectionObserver.ts
Normal file
66
src/renderer/hooks/useIntersectionObserver.ts
Normal file
@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Copyright (c) OpenLens Authors. All rights reserved.
|
||||
* Licensed under MIT License. See LICENSE in root directory for more information.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
/**
|
||||
* Intersection Observer configuratiopn options.
|
||||
*/
|
||||
interface IntersectionObserverOptions {
|
||||
/**
|
||||
* If `true`, check for intersection only once. Will
|
||||
* disconnect the IntersectionObserver instance after
|
||||
* intersection.
|
||||
*/
|
||||
triggerOnce?: boolean;
|
||||
|
||||
/**
|
||||
* Number from 0 to 1 representing the percentage
|
||||
* of the element that needs to be visible to be
|
||||
* considered as visible. Can also be an array of
|
||||
* thresholds.
|
||||
*/
|
||||
threshold?: number | number[];
|
||||
|
||||
/**
|
||||
* Element that is used as the viewport for checking visibility
|
||||
* of the provided `ref` or `element`.
|
||||
*/
|
||||
root?: Element;
|
||||
|
||||
/**
|
||||
* Margin around the root. Can have values similar to
|
||||
* the CSS margin property.
|
||||
*/
|
||||
rootMargin?: string;
|
||||
}
|
||||
|
||||
export function useIntersectionObserver(
|
||||
element: Element | null,
|
||||
{
|
||||
threshold = 0,
|
||||
rootMargin = "0%",
|
||||
root,
|
||||
}: IntersectionObserverOptions,
|
||||
): IntersectionObserverEntry | undefined {
|
||||
const [entry, setEntry] = useState<IntersectionObserverEntry>();
|
||||
|
||||
const updateEntry = ([entry]: IntersectionObserverEntry[]): void => {
|
||||
setEntry(entry);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!element) return;
|
||||
|
||||
const observer = new IntersectionObserver(updateEntry, { threshold, root, rootMargin });
|
||||
|
||||
observer.observe(element);
|
||||
|
||||
return () => observer.disconnect();
|
||||
|
||||
}, [element, threshold, root, rootMargin]);
|
||||
|
||||
return entry;
|
||||
}
|
||||
@ -123,6 +123,8 @@ export class SearchStore {
|
||||
* @returns A line index within the text/logs array
|
||||
*/
|
||||
@computed get activeOverlayLine(): number {
|
||||
if (!this.occurrences.length) return -1;
|
||||
|
||||
return this.occurrences[this.activeOverlayIndex];
|
||||
}
|
||||
|
||||
|
||||
12
yarn.lock
12
yarn.lock
@ -1822,6 +1822,18 @@
|
||||
dependencies:
|
||||
defer-to-connect "^2.0.0"
|
||||
|
||||
"@tanstack/react-virtual@3.0.0-beta.23":
|
||||
version "3.0.0-beta.23"
|
||||
resolved "https://registry.yarnpkg.com/@tanstack/react-virtual/-/react-virtual-3.0.0-beta.23.tgz#f3d3e049d6b49e2b91c46ec3d35f48d9fdf7426a"
|
||||
integrity sha512-FurqZJD7oSXLtL5NP0YQKXNEY+2qczXjzxmxkD51ZO8ykyr2z62IRNfvpOyly2CPLg+M346mA/Ul2hlx4R3KPw==
|
||||
dependencies:
|
||||
"@tanstack/virtual-core" "3.0.0-beta.23"
|
||||
|
||||
"@tanstack/virtual-core@3.0.0-beta.23":
|
||||
version "3.0.0-beta.23"
|
||||
resolved "https://registry.yarnpkg.com/@tanstack/virtual-core/-/virtual-core-3.0.0-beta.23.tgz#2e586256bdb239e35c8d1ca4e8d66c1c55151419"
|
||||
integrity sha512-xQfJgz4mdaxifPjOqUBYAar60UAQTrED2SpS0VI5AZvxYI4NDTxx5cFrjaP4baKY3UnVc8IsGFbqr8Q/LZNMag==
|
||||
|
||||
"@testing-library/dom@>=7", "@testing-library/dom@^8.0.0":
|
||||
version "8.13.0"
|
||||
resolved "https://registry.yarnpkg.com/@testing-library/dom/-/dom-8.13.0.tgz#bc00bdd64c7d8b40841e27a70211399ad3af46f5"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user