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
|
// Check if controls are available
|
||||||
await frame.waitForSelector(".Dock.isOpen");
|
await frame.waitForSelector(".Dock.isOpen");
|
||||||
await frame.waitForSelector(".LogList .VirtualList");
|
await frame.waitForSelector("[data-testid=pod-log-list]");
|
||||||
await frame.waitForSelector(".LogResourceSelector");
|
await frame.waitForSelector(".LogResourceSelector");
|
||||||
|
|
||||||
const logSearchInput = await frame.waitForSelector(
|
const logSearchInput = await frame.waitForSelector(
|
||||||
@ -113,19 +113,25 @@ describeIf(minikubeReady(TEST_NAMESPACE))("Minikube based tests", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
await logSearchInput.type(":");
|
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",
|
"[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",
|
"[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,
|
10 * 60 * 1000,
|
||||||
);
|
);
|
||||||
|
|||||||
@ -210,6 +210,7 @@
|
|||||||
"@sentry/electron": "^3.0.8",
|
"@sentry/electron": "^3.0.8",
|
||||||
"@sentry/integrations": "^6.19.3",
|
"@sentry/integrations": "^6.19.3",
|
||||||
"@side/jest-runtime": "^1.0.1",
|
"@side/jest-runtime": "^1.0.1",
|
||||||
|
"@tanstack/react-virtual": "3.0.0-beta.23",
|
||||||
"@types/circular-dependency-plugin": "5.0.5",
|
"@types/circular-dependency-plugin": "5.0.5",
|
||||||
"abort-controller": "^3.0.0",
|
"abort-controller": "^3.0.0",
|
||||||
"auto-bind": "^4.0.0",
|
"auto-bind": "^4.0.0",
|
||||||
|
|||||||
@ -748,22 +748,25 @@ exports[`download logs options in logs dock tab opening pod logs when logs avail
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="LogList flex"
|
class="LogList"
|
||||||
|
data-testid="pod-log-list"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="VirtualList box grow"
|
class="virtualizer"
|
||||||
|
style="height: 0px;"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="anchorLine"
|
||||||
|
style="top: 0px;"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
class="rowWrapper"
|
||||||
|
data-index="0"
|
||||||
|
style="transform: translateY(0px);"
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<div
|
|
||||||
class="list"
|
|
||||||
style="position: relative; height: 420000px; width: 100%; overflow: auto; will-change: transform; direction: ltr;"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style="height: 18px; width: 100%;"
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
class="LogRow"
|
class="LogRow"
|
||||||
style="position: absolute; left: 0px; top: 0px; height: 18px; width: 100%;"
|
|
||||||
>
|
>
|
||||||
<span>
|
<span>
|
||||||
some-logs
|
some-logs
|
||||||
@ -772,7 +775,10 @@ exports[`download logs options in logs dock tab opening pod logs when logs avail
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div
|
||||||
|
class="anchorLine"
|
||||||
|
style="bottom: 0px;"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
@ -791,6 +797,21 @@ exports[`download logs options in logs dock tab opening pod logs when logs avail
|
|||||||
<div
|
<div
|
||||||
class="flex gaps align-center"
|
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
|
<label
|
||||||
class="Checkbox flex align-center show-timestamps"
|
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>
|
</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
|
<div
|
||||||
|
class="virtualizer"
|
||||||
docker-exporter
|
style="height: 0px;"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="anchorLine"
|
||||||
|
style="top: 0px;"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
class="anchorLine"
|
||||||
|
style="bottom: 0px;"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="controls"
|
class="controls"
|
||||||
@ -1617,6 +1649,21 @@ exports[`download logs options in logs dock tab opening pod logs when logs not a
|
|||||||
<div
|
<div
|
||||||
class="flex gaps align-center"
|
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
|
<label
|
||||||
class="Checkbox flex align-center show-timestamps"
|
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 { DiContainer } from "@ogre-tools/injectable";
|
||||||
import type { Container } from "../../common/k8s-api/endpoints";
|
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", () => {
|
describe("download logs options in logs dock tab", () => {
|
||||||
let windowDi: DiContainer;
|
let windowDi: DiContainer;
|
||||||
let rendered: RenderResult;
|
let rendered: RenderResult;
|
||||||
@ -71,6 +82,7 @@ describe("download logs options in logs dock tab", () => {
|
|||||||
namespace: "default",
|
namespace: "default",
|
||||||
showPrevious: true,
|
showPrevious: true,
|
||||||
showTimestamps: false,
|
showTimestamps: false,
|
||||||
|
wrap: false,
|
||||||
}));
|
}));
|
||||||
windowDi.override(setLogTabDataInjectable, () => jest.fn());
|
windowDi.override(setLogTabDataInjectable, () => jest.fn());
|
||||||
windowDi.override(loadLogsInjectable, () => 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, {
|
return new LogTabViewModel(tabId, {
|
||||||
getLogs: jest.fn(),
|
getLogs: jest.fn(),
|
||||||
getLogsWithoutTimestamps: jest.fn(),
|
getLogsWithoutTimestamps: jest.fn(),
|
||||||
|
getVisibleLogs: jest.fn(),
|
||||||
getTimestampSplitLogs: jest.fn(),
|
getTimestampSplitLogs: jest.fn(),
|
||||||
getLogTabData: jest.fn(),
|
getLogTabData: jest.fn(),
|
||||||
setLogTabData: jest.fn(),
|
setLogTabData: jest.fn(),
|
||||||
@ -73,6 +74,7 @@ function getOnePodViewModel(tabId: TabId, deps: Partial<LogTabViewModelDependenc
|
|||||||
namespace: selectedPod.getNs(),
|
namespace: selectedPod.getNs(),
|
||||||
showPrevious: false,
|
showPrevious: false,
|
||||||
showTimestamps: false,
|
showTimestamps: false,
|
||||||
|
wrap: false,
|
||||||
}),
|
}),
|
||||||
getPodById: (id) => {
|
getPodById: (id) => {
|
||||||
if (id === selectedPod.getId()) {
|
if (id === selectedPod.getId()) {
|
||||||
@ -101,6 +103,7 @@ const getFewPodsTabData = (tabId: TabId, deps: Partial<LogTabViewModelDependenci
|
|||||||
namespace: selectedPod.getNs(),
|
namespace: selectedPod.getNs(),
|
||||||
showPrevious: false,
|
showPrevious: false,
|
||||||
showTimestamps: false,
|
showTimestamps: false,
|
||||||
|
wrap: false,
|
||||||
}),
|
}),
|
||||||
getPodById: (id) => {
|
getPodById: (id) => {
|
||||||
if (id === selectedPod.getId()) {
|
if (id === selectedPod.getId()) {
|
||||||
|
|||||||
@ -20,6 +20,7 @@ function mockLogTabViewModel(tabId: TabId, deps: Partial<LogTabViewModelDependen
|
|||||||
return new LogTabViewModel(tabId, {
|
return new LogTabViewModel(tabId, {
|
||||||
getLogs: jest.fn(),
|
getLogs: jest.fn(),
|
||||||
getLogsWithoutTimestamps: jest.fn(),
|
getLogsWithoutTimestamps: jest.fn(),
|
||||||
|
getVisibleLogs: jest.fn(),
|
||||||
getTimestampSplitLogs: jest.fn(),
|
getTimestampSplitLogs: jest.fn(),
|
||||||
getLogTabData: jest.fn(),
|
getLogTabData: jest.fn(),
|
||||||
setLogTabData: jest.fn(),
|
setLogTabData: jest.fn(),
|
||||||
@ -47,6 +48,7 @@ const getOnePodViewModel = (tabId: TabId, deps: Partial<LogTabViewModelDependenc
|
|||||||
namespace: selectedPod.getNs(),
|
namespace: selectedPod.getNs(),
|
||||||
showPrevious: false,
|
showPrevious: false,
|
||||||
showTimestamps: false,
|
showTimestamps: false,
|
||||||
|
wrap: false,
|
||||||
}),
|
}),
|
||||||
getPodById: (id) => {
|
getPodById: (id) => {
|
||||||
if (id === selectedPod.getId()) {
|
if (id === selectedPod.getId()) {
|
||||||
@ -61,27 +63,38 @@ const getOnePodViewModel = (tabId: TabId, deps: Partial<LogTabViewModelDependenc
|
|||||||
|
|
||||||
describe("LogSearch tests", () => {
|
describe("LogSearch tests", () => {
|
||||||
let render: DiRender;
|
let render: DiRender;
|
||||||
|
let setPrevOverlayActiveMock: jest.SpyInstance<void, []>;
|
||||||
|
let setNextOverlayActiveMock: jest.SpyInstance<void, []>;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
const di = getDiForUnitTesting({ doGeneralOverrides: true });
|
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);
|
render = renderFor(di);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
setNextOverlayActiveMock.mockClear();
|
||||||
|
setPrevOverlayActiveMock.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
it("renders w/o errors", () => {
|
it("renders w/o errors", () => {
|
||||||
const model = getOnePodViewModel("foobar");
|
const model = getOnePodViewModel("foobar");
|
||||||
const { container } = render(
|
const { container } = render(
|
||||||
<LogSearch
|
<LogSearch model={model}/>,
|
||||||
model={model}
|
|
||||||
scrollToOverlay={jest.fn()}
|
|
||||||
/>,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(container).toBeInstanceOf(HTMLElement);
|
expect(container).toBeInstanceOf(HTMLElement);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should scroll to new active overlay when clicking the previous button", async () => {
|
it("should scroll to new active overlay when clicking the previous button", async () => {
|
||||||
const scrollToOverlay = jest.fn();
|
|
||||||
const model = getOnePodViewModel("foobar", {
|
const model = getOnePodViewModel("foobar", {
|
||||||
getLogsWithoutTimestamps: () => [
|
getLogsWithoutTimestamps: () => [
|
||||||
"hello",
|
"hello",
|
||||||
@ -90,20 +103,16 @@ describe("LogSearch tests", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<LogSearch
|
<LogSearch model={model}/>,
|
||||||
model={model}
|
|
||||||
scrollToOverlay={scrollToOverlay}
|
|
||||||
/>,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
userEvent.click(await screen.findByPlaceholderText("Search..."));
|
userEvent.click(await screen.findByPlaceholderText("Search..."));
|
||||||
userEvent.keyboard("o");
|
userEvent.keyboard("o");
|
||||||
userEvent.click(await screen.findByText("keyboard_arrow_up"));
|
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 () => {
|
it("should scroll to new active overlay when clicking the next button", async () => {
|
||||||
const scrollToOverlay = jest.fn();
|
|
||||||
const model = getOnePodViewModel("foobar", {
|
const model = getOnePodViewModel("foobar", {
|
||||||
getLogsWithoutTimestamps: () => [
|
getLogsWithoutTimestamps: () => [
|
||||||
"hello",
|
"hello",
|
||||||
@ -112,20 +121,16 @@ describe("LogSearch tests", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<LogSearch
|
<LogSearch model={model}/>,
|
||||||
model={model}
|
|
||||||
scrollToOverlay={scrollToOverlay}
|
|
||||||
/>,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
userEvent.click(await screen.findByPlaceholderText("Search..."));
|
userEvent.click(await screen.findByPlaceholderText("Search..."));
|
||||||
userEvent.keyboard("o");
|
userEvent.keyboard("o");
|
||||||
userEvent.click(await screen.findByText("keyboard_arrow_down"));
|
userEvent.click(await screen.findByText("keyboard_arrow_down"));
|
||||||
expect(scrollToOverlay).toBeCalled();
|
expect(setNextOverlayActiveMock).toBeCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("next and previous should be disabled initially", async () => {
|
it("next and previous should be disabled initially", async () => {
|
||||||
const scrollToOverlay = jest.fn();
|
|
||||||
const model = getOnePodViewModel("foobar", {
|
const model = getOnePodViewModel("foobar", {
|
||||||
getLogsWithoutTimestamps: () => [
|
getLogsWithoutTimestamps: () => [
|
||||||
"hello",
|
"hello",
|
||||||
@ -134,14 +139,12 @@ describe("LogSearch tests", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<LogSearch
|
<LogSearch model={model}/>,
|
||||||
model={model}
|
|
||||||
scrollToOverlay={scrollToOverlay}
|
|
||||||
/>,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
userEvent.click(await screen.findByText("keyboard_arrow_down"));
|
userEvent.click(await screen.findByText("keyboard_arrow_down"));
|
||||||
userEvent.click(await screen.findByText("keyboard_arrow_up"));
|
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);
|
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", () => {
|
it("has a arrow down icon", () => {
|
||||||
const { getByText } = render(<ToBottom onClick={noop}/>);
|
const { getByText } = render(<ToBottom onClick={noop}/>);
|
||||||
|
|
||||||
@ -42,7 +36,7 @@ describe("<ToBottom/>", () => {
|
|||||||
const callback = jest.fn();
|
const callback = jest.fn();
|
||||||
const { getByText } = render(<ToBottom onClick={callback}/>);
|
const { getByText } = render(<ToBottom onClick={callback}/>);
|
||||||
|
|
||||||
fireEvent.click(getByText("To bottom"));
|
fireEvent.click(getByText("expand_more"));
|
||||||
expect(callback).toBeCalled();
|
expect(callback).toBeCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,6 +1,4 @@
|
|||||||
.controls {
|
.controls {
|
||||||
@include hidden-scrollbar;
|
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: var(--padding);
|
gap: var(--padding);
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@ -25,13 +25,17 @@ export const LogControls = observer(({ model }: LogControlsProps) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const logs = model.timestampSplitLogs.get();
|
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 since = logs.length ? logs[0][0] : null;
|
||||||
|
|
||||||
const toggleTimestamps = () => {
|
const toggleTimestamps = () => {
|
||||||
model.updateLogTabData({ showTimestamps: !showTimestamps });
|
model.updateLogTabData({ showTimestamps: !showTimestamps });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const toggleWrap = () => {
|
||||||
|
model.updateLogTabData({ wrap: !wrap });
|
||||||
|
};
|
||||||
|
|
||||||
const togglePrevious = () => {
|
const togglePrevious = () => {
|
||||||
model.updateLogTabData({ showPrevious: !previous });
|
model.updateLogTabData({ showPrevious: !previous });
|
||||||
model.reloadLogs();
|
model.reloadLogs();
|
||||||
@ -49,6 +53,12 @@ export const LogControls = observer(({ model }: LogControlsProps) => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gaps align-center">
|
<div className="flex gaps align-center">
|
||||||
|
<Checkbox
|
||||||
|
label="Wrap logs"
|
||||||
|
value={wrap}
|
||||||
|
onChange={toggleWrap}
|
||||||
|
className="wrap-logs"
|
||||||
|
/>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
label="Show timestamps"
|
label="Show timestamps"
|
||||||
value={showTimestamps}
|
value={showTimestamps}
|
||||||
|
|||||||
@ -31,6 +31,7 @@ const createLogsTab = ({ createDockTab, setLogTabData, getRandomId }: Dependenci
|
|||||||
setLogTabData(id, {
|
setLogTabData(id, {
|
||||||
showTimestamps: false,
|
showTimestamps: false,
|
||||||
showPrevious: false,
|
showPrevious: false,
|
||||||
|
wrap: false,
|
||||||
...data,
|
...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 getPodByIdInjectable from "../../+workloads-pods/get-pod-by-id.injectable";
|
||||||
import downloadLogsInjectable from "./download-logs.injectable";
|
import downloadLogsInjectable from "./download-logs.injectable";
|
||||||
import downloadAllLogsInjectable from "./download-all-logs.injectable";
|
import downloadAllLogsInjectable from "./download-all-logs.injectable";
|
||||||
|
import getVisibleLogsInjectable from "./get-visible-logs.injectable";
|
||||||
|
|
||||||
export interface InstantiateArgs {
|
export interface InstantiateArgs {
|
||||||
tabId: TabId;
|
tabId: TabId;
|
||||||
@ -32,6 +33,7 @@ const logsViewModelInjectable = getInjectable({
|
|||||||
getLogs: di.inject(getLogsInjectable),
|
getLogs: di.inject(getLogsInjectable),
|
||||||
getLogsWithoutTimestamps: di.inject(getLogsWithoutTimestampsInjectable),
|
getLogsWithoutTimestamps: di.inject(getLogsWithoutTimestampsInjectable),
|
||||||
getTimestampSplitLogs: di.inject(getTimestampSplitLogsInjectable),
|
getTimestampSplitLogs: di.inject(getTimestampSplitLogsInjectable),
|
||||||
|
getVisibleLogs: di.inject(getVisibleLogsInjectable),
|
||||||
reloadLogs: di.inject(reloadLogsInjectable),
|
reloadLogs: di.inject(reloadLogsInjectable),
|
||||||
getLogTabData: di.inject(getLogTabDataInjectable),
|
getLogTabData: di.inject(getLogTabDataInjectable),
|
||||||
setLogTabData: di.inject(setLogTabDataInjectable),
|
setLogTabData: di.inject(setLogTabDataInjectable),
|
||||||
|
|||||||
@ -19,6 +19,7 @@ export interface LogTabViewModelDependencies {
|
|||||||
getLogs: (tabId: TabId) => string[];
|
getLogs: (tabId: TabId) => string[];
|
||||||
getLogsWithoutTimestamps: (tabId: TabId) => string[];
|
getLogsWithoutTimestamps: (tabId: TabId) => string[];
|
||||||
getTimestampSplitLogs: (tabId: TabId) => [string, string][];
|
getTimestampSplitLogs: (tabId: TabId) => [string, string][];
|
||||||
|
getVisibleLogs: (tabId: TabId) => string[];
|
||||||
getLogTabData: (tabId: TabId) => LogTabData | undefined;
|
getLogTabData: (tabId: TabId) => LogTabData | undefined;
|
||||||
setLogTabData: (tabId: TabId, data: LogTabData) => void;
|
setLogTabData: (tabId: TabId, data: LogTabData) => void;
|
||||||
loadLogs: LoadLogs;
|
loadLogs: LoadLogs;
|
||||||
@ -45,6 +46,7 @@ export class LogTabViewModel {
|
|||||||
readonly logsWithoutTimestamps = computed(() => this.dependencies.getLogsWithoutTimestamps(this.tabId));
|
readonly logsWithoutTimestamps = computed(() => this.dependencies.getLogsWithoutTimestamps(this.tabId));
|
||||||
readonly timestampSplitLogs = computed(() => this.dependencies.getTimestampSplitLogs(this.tabId));
|
readonly timestampSplitLogs = computed(() => this.dependencies.getTimestampSplitLogs(this.tabId));
|
||||||
readonly logTabData = computed(() => this.dependencies.getLogTabData(this.tabId));
|
readonly logTabData = computed(() => this.dependencies.getLogTabData(this.tabId));
|
||||||
|
readonly visibleLogs = computed(() => this.dependencies.getVisibleLogs(this.tabId));
|
||||||
readonly pods = computed(() => {
|
readonly pods = computed(() => {
|
||||||
const data = this.logTabData.get();
|
const data = this.logTabData.get();
|
||||||
|
|
||||||
|
|||||||
@ -13,18 +13,13 @@ import type { LogTabViewModel } from "./logs-view-model";
|
|||||||
|
|
||||||
export interface PodLogSearchProps {
|
export interface PodLogSearchProps {
|
||||||
onSearch?: (query: string) => void;
|
onSearch?: (query: string) => void;
|
||||||
scrollToOverlay: (lineNumber: number | undefined) => void;
|
|
||||||
model: LogTabViewModel;
|
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();
|
const tabData = logTabData.get();
|
||||||
|
|
||||||
if (!tabData) {
|
const logs = tabData?.showTimestamps
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const logs = tabData.showTimestamps
|
|
||||||
? model.logs.get()
|
? model.logs.get()
|
||||||
: model.logsWithoutTimestamps.get();
|
: model.logsWithoutTimestamps.get();
|
||||||
const { setNextOverlayActive, setPrevOverlayActive, searchQuery, occurrences, activeFind, totalFinds } = searchStore;
|
const { setNextOverlayActive, setPrevOverlayActive, searchQuery, occurrences, activeFind, totalFinds } = searchStore;
|
||||||
@ -33,17 +28,14 @@ export const LogSearch = observer(({ onSearch, scrollToOverlay, model: { logTabD
|
|||||||
const setSearch = (query: string) => {
|
const setSearch = (query: string) => {
|
||||||
searchStore.onSearch(logs, query);
|
searchStore.onSearch(logs, query);
|
||||||
onSearch?.(query);
|
onSearch?.(query);
|
||||||
scrollToOverlay(searchStore.activeOverlayLine);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const onPrevOverlay = () => {
|
const onPrevOverlay = () => {
|
||||||
setPrevOverlayActive();
|
setPrevOverlayActive();
|
||||||
scrollToOverlay(searchStore.activeOverlayLine);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const onNextOverlay = () => {
|
const onNextOverlay = () => {
|
||||||
setNextOverlayActive();
|
setNextOverlayActive();
|
||||||
scrollToOverlay(searchStore.activeOverlayLine);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const onClear = () => {
|
const onClear = () => {
|
||||||
|
|||||||
@ -54,6 +54,11 @@ export interface LogTabData {
|
|||||||
* Whether to show the logs of the previous container instance
|
* Whether to show the logs of the previous container instance
|
||||||
*/
|
*/
|
||||||
showPrevious: boolean;
|
showPrevious: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether to wrap logs lines to avoid horizontal scrolling.
|
||||||
|
*/
|
||||||
|
wrap: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Dependencies {
|
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.
|
* Copyright (c) OpenLens Authors. All rights reserved.
|
||||||
* Licensed under MIT License. See LICENSE in root directory for more information.
|
* Licensed under MIT License. See LICENSE in root directory for more information.
|
||||||
*/
|
*/
|
||||||
|
import styles from "./to-bottom.module.scss";
|
||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { Icon } from "../../icon";
|
import { Icon } from "../../icon";
|
||||||
|
|
||||||
export function ToBottom({ onClick }: { onClick: () => void }) {
|
export function ToBottom({ onClick }: { onClick: () => void }) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
className="absolute top-3 right-3 z-10 rounded-md flex align-center px-1 py-1 pl-3"
|
className={styles.ToBottom}
|
||||||
style={{ backgroundColor: "var(--blue)" }}
|
|
||||||
onClick={evt => {
|
onClick={evt => {
|
||||||
evt.currentTarget.blur();
|
evt.currentTarget.blur();
|
||||||
onClick();
|
onClick();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
To bottom
|
|
||||||
<Icon small material="expand_more" />
|
<Icon small material="expand_more" />
|
||||||
</button>
|
</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.
|
* 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 { observer } from "mobx-react";
|
||||||
import { InfoPanel } from "../info-panel";
|
import { InfoPanel } from "../info-panel";
|
||||||
import { LogResourceSelector } from "./resource-selector";
|
import { LogResourceSelector } from "./resource-selector";
|
||||||
import type { LogListRef } from "./list";
|
|
||||||
import { LogList } from "./list";
|
|
||||||
import { LogSearch } from "./search";
|
import { LogSearch } from "./search";
|
||||||
import { LogControls } from "./controls";
|
import { LogControls } from "./controls";
|
||||||
import { withInjectables } from "@ogre-tools/injectable-react";
|
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 subscribeStoresInjectable from "../../../kube-watch-api/subscribe-stores.injectable";
|
||||||
import type { PodStore } from "../../+workloads-pods/store";
|
import type { PodStore } from "../../+workloads-pods/store";
|
||||||
import podStoreInjectable from "../../+workloads-pods/store.injectable";
|
import podStoreInjectable from "../../+workloads-pods/store.injectable";
|
||||||
|
import { LogList } from "./log-list";
|
||||||
|
|
||||||
export interface LogsDockTabProps {
|
export interface LogsDockTabProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
@ -39,7 +38,6 @@ const NonInjectedLogsDockTab = observer(({
|
|||||||
subscribeStores,
|
subscribeStores,
|
||||||
podStore,
|
podStore,
|
||||||
}: Dependencies & LogsDockTabProps) => {
|
}: Dependencies & LogsDockTabProps) => {
|
||||||
const logListElement = createRef<LogListRef>();
|
|
||||||
const data = model.logTabData.get();
|
const data = model.logTabData.get();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -53,27 +51,6 @@ const NonInjectedLogsDockTab = observer(({
|
|||||||
namespaces: data ? [data.namespace] : [],
|
namespaces: data ? [data.namespace] : [],
|
||||||
}), [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 (
|
return (
|
||||||
<div className={cssNames("PodLogs flex column", className)}>
|
<div className={cssNames("PodLogs flex column", className)}>
|
||||||
<InfoPanel
|
<InfoPanel
|
||||||
@ -81,17 +58,14 @@ const NonInjectedLogsDockTab = observer(({
|
|||||||
controls={(
|
controls={(
|
||||||
<div className="flex gaps">
|
<div className="flex gaps">
|
||||||
<LogResourceSelector model={model} />
|
<LogResourceSelector model={model} />
|
||||||
<LogSearch
|
<LogSearch model={model}/>
|
||||||
model={model}
|
|
||||||
scrollToOverlay={scrollToOverlay}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
showSubmitClose={false}
|
showSubmitClose={false}
|
||||||
showButtons={false}
|
showButtons={false}
|
||||||
showStatusPanel={false}
|
showStatusPanel={false}
|
||||||
/>
|
/>
|
||||||
<LogList model={model} ref={logListElement} />
|
<LogList model={model} />
|
||||||
<LogControls model={model} />
|
<LogControls model={model} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -10,3 +10,4 @@ export * from "./useInterval";
|
|||||||
export * from "./useMutationObserver";
|
export * from "./useMutationObserver";
|
||||||
export * from "./useResizeObserver";
|
export * from "./useResizeObserver";
|
||||||
export * from "./use-toggle";
|
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
|
* @returns A line index within the text/logs array
|
||||||
*/
|
*/
|
||||||
@computed get activeOverlayLine(): number {
|
@computed get activeOverlayLine(): number {
|
||||||
|
if (!this.occurrences.length) return -1;
|
||||||
|
|
||||||
return this.occurrences[this.activeOverlayIndex];
|
return this.occurrences[this.activeOverlayIndex];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
12
yarn.lock
12
yarn.lock
@ -1822,6 +1822,18 @@
|
|||||||
dependencies:
|
dependencies:
|
||||||
defer-to-connect "^2.0.0"
|
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":
|
"@testing-library/dom@>=7", "@testing-library/dom@^8.0.0":
|
||||||
version "8.13.0"
|
version "8.13.0"
|
||||||
resolved "https://registry.yarnpkg.com/@testing-library/dom/-/dom-8.13.0.tgz#bc00bdd64c7d8b40841e27a70211399ad3af46f5"
|
resolved "https://registry.yarnpkg.com/@testing-library/dom/-/dom-8.13.0.tgz#bc00bdd64c7d8b40841e27a70211399ad3af46f5"
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user