1
0
mirror of https://github.com/lensapp/lens.git synced 2025-05-20 05:10:56 +00:00
This commit is contained in:
Alex Andreev 2022-12-15 14:43:40 +00:00 committed by GitHub
commit 30e5676278
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
36 changed files with 891 additions and 452 deletions

View File

@ -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,
);

View File

@ -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",

View File

@ -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"
>

View File

@ -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());

View File

@ -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>
`;

View 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);
});
});
});

View File

@ -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()) {

View File

@ -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();
});
});

View File

@ -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();
});
});

View File

@ -1,6 +1,4 @@
.controls {
@include hidden-scrollbar;
display: flex;
gap: var(--padding);
align-items: center;

View File

@ -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}

View File

@ -31,6 +31,7 @@ const createLogsTab = ({ createDockTab, setLogTabData, getRandomId }: Dependenci
setLogTabData(id, {
showTimestamps: false,
showPrevious: false,
wrap: false,
...data,
});
});

View File

@ -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;

View File

@ -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
}
}
}

View File

@ -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} />
));

View 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;
}

View 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>
);
});

View 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
}
}
}

View 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>
);
}

View File

@ -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),

View File

@ -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();

View File

@ -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 = () => {

View File

@ -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 {

View File

@ -0,0 +1,8 @@
.ToBottom {
position: absolute;
top: 64px;
right: 16px;
border-radius: 4px;
padding: 4px 6px;
background-color: var(--colorInfo);
}

View File

@ -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>
);

View File

@ -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]);
}

View 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]);
}

View File

@ -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;
}

View 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]);
}

View 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];
}

View File

@ -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]);
}

View File

@ -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>
);

View File

@ -10,3 +10,4 @@ export * from "./useInterval";
export * from "./useMutationObserver";
export * from "./useResizeObserver";
export * from "./use-toggle";
export * from "./useIntersectionObserver";

View 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;
}

View File

@ -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];
}

View File

@ -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"