1
0
mirror of https://github.com/lensapp/lens.git synced 2024-09-20 05:47:24 +03:00

Log search (#1114)

* Moving logs to virtual list

Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com>

* Introducing log search

Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com>

* Setting ref for VirtualList to access its methods

Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com>

* Introducing search store

Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com>

* Centering overlay when scroll to it

Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com>

* Using SearchInput in PodLogSearch

Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com>

* Using Prev/Next icons for search

Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com>

* No trigger logs load when scrolled by method

Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com>

* SearchInput refactoring

Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com>

* Adding find counters

Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com>

* Clean search query on dock tab change

Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com>

* Refresh search when logs get changed

Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com>

* Case-insensitive search

Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com>

* Improve logs scrolling experience

Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com>

* Catching empty logs in various places

Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com>

* Fixing downloading logs

Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com>

* Clean up some duplicated styles

Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com>

* Removing jump-to-bottom animation

Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com>

* Fixing since label

Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com>

* Reducing container selector size

Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com>

* Scroll down to bottom after each reload

Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com>

* Fix search within timestamps if they not provided

Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com>

* Use log row hover color from theme

Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com>

* Add search bindings for 'Esc' & 'Enter' hits

Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com>

* Focus input fields on CmdOrCtrl+F

Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com>

* Move search.store.ts in to /common folder

Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com>

* search.store.ts -> search-store.ts

Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com>

* Adding test for search store

Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com>

* Adding integration tests for logs

Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com>

* Fixing scroll jumping bug

Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com>

* Removing download icon check for testing purpose

Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com>

* Removing clicking on nginx-create-pod-test

Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com>

* Moving log tests before cluster operations

Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com>

* Build extensions before integration tests

Signed-off-by: Lauri Nevala <lauri.nevala@gmail.com>

* Build also npm before integration tests

Signed-off-by: Lauri Nevala <lauri.nevala@gmail.com>

* Move npm build and extension build into own build step

Signed-off-by: Lauri Nevala <lauri.nevala@gmail.com>

* Removing separator sketches

Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com>

* Horizontal scrolling to founded keyword

Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com>

* Delaying horizontal scrolling

Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com>

Co-authored-by: Lauri Nevala <lauri.nevala@gmail.com>
This commit is contained in:
Alex Andreev 2020-11-09 17:46:14 +03:00 committed by GitHub
parent f69f8c793f
commit 16fb35e3f9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
25 changed files with 697 additions and 144 deletions

View File

@ -37,6 +37,10 @@ jobs:
displayName: Cache Yarn packages
- script: make install-deps
displayName: Install dependencies
- script: make build-npm
displayName: Generate npm package
- script: make build-extensions
displayName: Build bundled extensions
- script: make integration-win
displayName: Run integration tests
- script: make test-extensions
@ -76,6 +80,10 @@ jobs:
condition: eq(variables.CACHE_RESTORED, 'true')
- script: make install-deps
displayName: Install dependencies
- script: make build-npm
displayName: Generate npm package
- script: make build-extensions
displayName: Build bundled extensions
- script: make test
displayName: Run tests
- script: make integration-mac
@ -127,6 +135,10 @@ jobs:
displayName: Run In-tree Extension tests
- script: make lint
displayName: Lint
- script: make build-npm
displayName: Generate npm package
- script: make build-extensions
displayName: Build bundled extensions
- script: make test
displayName: Run tests
- bash: |

View File

@ -410,6 +410,40 @@ describe("Lens integration tests", () => {
})
})
describe("viewing pod logs", () => {
beforeEach(appStartAddCluster, 40000)
afterEach(async () => {
if (app && app.isRunning()) {
return util.tearDown(app)
}
})
it(`shows a logs for a pod`, async () => {
expect(clusterAdded).toBe(true)
// Go to Pods page
await app.client.click(".sidebar-nav #workloads span.link-text")
await app.client.waitUntilTextExists('a[href^="/pods"]', "Pods")
await app.client.click('a[href^="/pods"]')
await app.client.waitUntilTextExists("div.TableCell", "kube-apiserver")
// Open logs tab in dock
await app.client.click(".list .TableRow:first-child")
await app.client.waitForVisible(".Drawer")
await app.client.click(".drawer-title .Menu li:nth-child(2)")
// Check if controls are available
await app.client.waitForVisible(".PodLogs .VirtualList")
await app.client.waitForVisible(".PodLogControls")
await app.client.waitForVisible(".PodLogControls .SearchInput")
await app.client.waitForVisible(".PodLogControls .SearchInput input")
// Search for semicolon
await app.client.keys(":")
await app.client.waitForVisible(".PodLogs .list span.active")
// Click through controls
await app.client.click(".PodLogControls .timestamps-icon")
await app.client.click(".PodLogControls .undo-icon")
})
})
describe("cluster operations", () => {
beforeEach(appStartAddCluster, 40000)

View File

@ -0,0 +1,80 @@
/**
* @jest-environment jsdom
*/
import { SearchStore } from "../search-store"
let searchStore: SearchStore = null;
const logs = [
"1:M 30 Oct 2020 16:17:41.553 # Connection with replica 172.17.0.12:6379 lost",
"1:M 30 Oct 2020 16:17:41.623 * Replica 172.17.0.12:6379 asks for synchronization",
"1:M 30 Oct 2020 16:17:41.623 * Starting Partial resynchronization request from 172.17.0.12:6379 accepted. Sending 0 bytes of backlog starting from offset 14407."
]
describe("search store tests", () => {
beforeEach(async () => {
searchStore = new SearchStore();
})
it("does nothing with empty search query", () => {
searchStore.onSearch([], "");
expect(searchStore.occurrences).toEqual([]);
})
it("doesn't break if no text provided", () => {
searchStore.onSearch(null, "replica");
expect(searchStore.occurrences).toEqual([]);
searchStore.onSearch([], "replica");
expect(searchStore.occurrences).toEqual([]);
})
it("find 3 occurences across 3 lines", () => {
searchStore.onSearch(logs, "172");
expect(searchStore.occurrences).toEqual([0, 1, 2]);
})
it("find occurences within 1 line (case-insensitive)", () => {
searchStore.onSearch(logs, "Starting");
expect(searchStore.occurrences).toEqual([2, 2]);
})
it("sets overlay index equal to first occurence", () => {
searchStore.onSearch(logs, "Replica");
expect(searchStore.activeOverlayIndex).toBe(0);
})
it("set overlay index to next occurence", () => {
searchStore.onSearch(logs, "172");
searchStore.setNextOverlayActive();
expect(searchStore.activeOverlayIndex).toBe(1);
})
it("sets overlay to last occurence", () => {
searchStore.onSearch(logs, "172");
searchStore.setPrevOverlayActive();
expect(searchStore.activeOverlayIndex).toBe(2);
})
it("gets line index where overlay is located", () => {
searchStore.onSearch(logs, "synchronization");
expect(searchStore.activeOverlayLine).toBe(1);
})
it("escapes string for using in regex", () => {
const regex = searchStore.escapeRegex("some.interesting-query\\#?()[]");
expect(regex).toBe("some\\.interesting\\-query\\\\\\#\\?\\(\\)\\[\\]");
})
it("gets active find number", () => {
searchStore.onSearch(logs, "172");
searchStore.setNextOverlayActive();
expect(searchStore.activeFind).toBe(2);
})
it("gets total finds number", () => {
searchStore.onSearch(logs, "Starting");
expect(searchStore.totalFinds).toBe(2);
})
})

126
src/common/search-store.ts Normal file
View File

@ -0,0 +1,126 @@
import { action, computed, observable } from "mobx";
import { autobind } from "../renderer/utils";
export class SearchStore {
@observable searchQuery = ""; // Text in the search input
@observable occurrences: number[] = []; // Array with line numbers, eg [0, 0, 10, 21, 21, 40...]
@observable activeOverlayIndex = -1; // Index withing the occurences array. Showing where is activeOverlay currently located
/**
* Sets default activeOverlayIndex
* @param text An array of any textual data (logs, for example)
* @param query Search query from input
*/
@action
onSearch(text: string[], query = this.searchQuery) {
this.searchQuery = query;
if (!query) {
this.reset();
return;
}
this.occurrences = this.findOccurences(text, query);
if (!this.occurrences.length) return;
// If new highlighted keyword in exact same place as previous one, then no changing in active overlay
if (this.occurrences[this.activeOverlayIndex] !== undefined) return;
this.activeOverlayIndex = this.getNextOverlay(true);
}
/**
* Does searching within text array, create a list of search keyword occurences.
* Each keyword "occurency" is saved as index of the the line where keyword founded
* @param text An array of any textual data (logs, for example)
* @param query Search query from input
* @returns {Array} Array of line indexes [0, 0, 14, 17, 17, 17, 20...]
*/
findOccurences(text: string[], query: string) {
if (!text) return [];
const occurences: number[] = [];
text.forEach((line, index) => {
const regex = new RegExp(this.escapeRegex(query), "gi");
const matches = [...line.matchAll(regex)];
matches.forEach(() => occurences.push(index));
});
return occurences;
}
/**
* Getting next overlay index within the occurences array
* @param loopOver Allows to jump from last element to first
* @returns {number} next overlay index
*/
getNextOverlay(loopOver = false) {
const next = this.activeOverlayIndex + 1;
if (next > this.occurrences.length - 1) {
return loopOver ? 0 : this.activeOverlayIndex;
}
return next;
}
/**
* Getting previous overlay index within the occurences array of occurences
* @param loopOver Allows to jump from first element to last one
* @returns {number} prev overlay index
*/
getPrevOverlay(loopOver = false) {
const prev = this.activeOverlayIndex - 1;
if (prev < 0) {
return loopOver ? this.occurrences.length - 1 : this.activeOverlayIndex;
}
return prev;
}
@autobind()
setNextOverlayActive() {
this.activeOverlayIndex = this.getNextOverlay(true);
}
@autobind()
setPrevOverlayActive() {
this.activeOverlayIndex = this.getPrevOverlay(true);
}
/**
* Gets line index of where active overlay is located
* @returns {number} A line index within the text/logs array
*/
@computed get activeOverlayLine(): number {
return this.occurrences[this.activeOverlayIndex];
}
@computed get activeFind(): number {
return this.activeOverlayIndex + 1;
}
@computed get totalFinds(): number {
return this.occurrences.length;
}
/**
* Checks if overlay is active (to highlight it with orange background usually)
* @param line Index of the line where overlay is located
* @param occurence Number of the overlay within one line
*/
@autobind()
isActiveOverlay(line: number, occurence: number) {
const firstLineIndex = this.occurrences.findIndex(item => item === line);
return firstLineIndex + occurence === this.activeOverlayIndex;
}
/**
* An utility methods escaping user string to safely pass it into new Regex(variable)
* @param value Unescaped string
*/
escapeRegex(value: string) {
return value.replace( /[\-\[\]{}()*+?.,\\\^$|#\s]/g, "\\$&" );
}
@action
reset() {
this.searchQuery = "";
this.activeOverlayIndex = -1;
this.occurrences = [];
}
}
export const searchStore = new SearchStore;

View File

@ -11,7 +11,7 @@ import { navigation } from "../../navigation";
import { ItemListLayout } from "../item-object-list/item-list-layout";
import { t, Trans } from "@lingui/macro";
import { _i18n } from "../../i18n";
import { SearchInput } from "../input";
import { SearchInputUrl } from "../input";
enum sortBy {
name = "name",
@ -72,7 +72,7 @@ export class HelmCharts extends Component<Props> {
(items: HelmChart[]) => items.filter(item => !item.deprecated)
]}
customizeHeader={() => (
<SearchInput placeholder={_i18n._(t`Search Helm Charts`)} />
<SearchInputUrl placeholder={_i18n._(t`Search Helm Charts`)} />
)}
renderTableHeader={[
{ className: "icon" },

View File

@ -8,22 +8,26 @@ import { Icon } from "../icon";
import { _i18n } from "../../i18n";
import { cssNames, downloadFile } from "../../utils";
import { Pod } from "../../api/endpoints";
import { PodLogSearch, PodLogSearchProps } from "./pod-log-search";
interface Props {
interface Props extends PodLogSearchProps {
ready: boolean
tabId: string
tabData: IPodLogsData
logs: string[][]
logs: string[]
save: (data: Partial<IPodLogsData>) => void
reload: () => void
onSearch: (query: string) => void
}
export const PodLogControls = observer((props: Props) => {
if (!props.ready) return null;
const { tabData, tabId, save, reload, logs } = props;
const { tabData, save, reload, tabId, logs } = props;
const { selectedContainer, showTimestamps, previous } = tabData;
const since = podLogsStore.getTimestamps(podLogsStore.logs.get(tabId)[0]);
const rawLogs = podLogsStore.logs.get(tabId);
const since = rawLogs.length ? podLogsStore.getTimestamps(rawLogs[0]) : null;
const pod = new Pod(tabData.pod);
const toggleTimestamps = () => {
save({ showTimestamps: !showTimestamps });
}
@ -35,8 +39,7 @@ export const PodLogControls = observer((props: Props) => {
const downloadLogs = () => {
const fileName = selectedContainer ? selectedContainer.name : pod.getName();
const [oldLogs, newLogs] = logs;
downloadFile(fileName + ".log", [...oldLogs, ...newLogs].join("\n"), "text/plain");
downloadFile(fileName + ".log", logs.join("\n"), "text/plain");
}
const onContainerChange = (option: SelectOption) => {
@ -92,7 +95,7 @@ export const PodLogControls = observer((props: Props) => {
</>
)}
</div>
<div className="flex gaps">
<div className="flex box grow gaps align-center">
<Icon
material="av_timer"
onClick={toggleTimestamps}
@ -109,7 +112,9 @@ export const PodLogControls = observer((props: Props) => {
material="get_app"
onClick={downloadLogs}
tooltip={_i18n._(t`Save`)}
className="download-icon"
/>
<PodLogSearch {...props} />
</div>
</div>
);

View File

@ -0,0 +1,10 @@
.PodLogsSearch {
.SearchInput {
min-width: 150px;
width: 150px;
.find-count {
margin-left: 2px;
}
}
}

View File

@ -0,0 +1,87 @@
import "./pod-log-search.scss";
import React, { useEffect } from "react";
import { observer } from "mobx-react";
import { SearchInput } from "../input";
import { searchStore } from "../../../common/search-store";
import { Icon } from "../icon";
import { _i18n } from "../../i18n";
import { t } from "@lingui/macro";
export interface PodLogSearchProps {
onSearch: (query: string) => void
toPrevOverlay: () => void
toNextOverlay: () => void
logs: string[]
}
export const PodLogSearch = observer((props: PodLogSearchProps) => {
const { logs, onSearch, toPrevOverlay, toNextOverlay } = props;
const { setNextOverlayActive, setPrevOverlayActive, searchQuery, occurrences, activeFind, totalFinds } = searchStore;
const jumpDisabled = !searchQuery || !occurrences.length;
const findCounts = (
<div className="find-count">
{activeFind}/{totalFinds}
</div>
);
const setSearch = (query: string) => {
searchStore.onSearch(logs, query);
onSearch(query);
};
const onPrevOverlay = () => {
setPrevOverlayActive();
toPrevOverlay();
}
const onNextOverlay = () => {
setNextOverlayActive();
toNextOverlay();
}
const onClear = () => {
setSearch("");
}
const onKeyDown = (evt: React.KeyboardEvent<any>) => {
if (evt.key === "Enter") {
onNextOverlay();
}
}
useEffect(() => {
// Refresh search when logs changed
searchStore.onSearch(logs);
}, [logs]);
return (
<div className="PodLogsSearch flex box grow justify-flex-end gaps align-center">
<SearchInput
value={searchQuery}
onChange={setSearch}
closeIcon={false}
contentRight={totalFinds > 0 && findCounts}
onClear={onClear}
onKeyDown={onKeyDown}
/>
<Icon
material="keyboard_arrow_up"
tooltip={_i18n._(t`Previous`)}
onClick={onPrevOverlay}
disabled={jumpDisabled}
/>
<Icon
material="keyboard_arrow_down"
tooltip={_i18n._(t`Next`)}
onClick={onNextOverlay}
disabled={jumpDisabled}
/>
<Icon
material="close"
tooltip={_i18n._(t`Clear`)}
onClick={onClear}
/>
</div>
);
});

View File

@ -6,19 +6,46 @@
// `overflow: overlay` don't allow scroll to the last line
overflow: auto;
position: relative;
color: $textColorAccent;
background: $logsBackground;
line-height: var(--log-line-height);
border-radius: 2px;
padding: $padding * 2;
font-family: $font-monospace;
font-size: smaller;
white-space: pre;
flex-grow: 1;
> div {
// Provides font better readability on large screens
-webkit-font-smoothing: subpixel-antialiased;
.find-overlay {
position: absolute;
border-radius: 2px;
background-color: #8cc474;
margin-top: 4px;
opacity: 0.5;
}
.VirtualList {
height: 100%;
.list {
.LogRow {
padding: 2px 16px;
height: 18px; // Must be equal to lineHeight variable in pod-logs.scss
font-family: $font-monospace;
font-size: smaller;
white-space: pre;
-webkit-font-smoothing: auto; // Better readability on non-retina screens
&:hover {
background: $logRowHoverBackground;
}
span {
border-radius: 2px;
background-color: #8cc474b8;
-webkit-font-smoothing: auto;
&.active {
background-color: orange;
}
}
}
}
}
}
@ -47,7 +74,8 @@
padding: $unit / 2 $unit * 1.5;
border-radius: $unit * 2;
opacity: 0;
transition: opacity 0.2s;
z-index: 2;
top: 20px;
&.active {
opacity: 1;
@ -57,4 +85,20 @@
--size: $unit * 2;
}
}
.PodLogControls {
.Select {
min-width: 150px;
}
}
.logs .VirtualList .list {
overflow-x: scroll!important;
}
&.noscroll {
.logs .VirtualList .list {
overflow-x: hidden!important; // fixing scroll to bottom issues in PodLogs
}
}
}

View File

@ -5,7 +5,7 @@ import { DockTabStore } from "./dock-tab.store";
import { dockStore, IDockTab, TabKind } from "./dock.store";
import { t } from "@lingui/macro";
import { _i18n } from "../../i18n";
import { isDevelopment } from "../../../common/vars";
import { searchStore } from "../../../common/search-store";
export interface IPodLogsData {
pod: Pod;
@ -20,7 +20,7 @@ type TabId = string;
type PodLogLine = string;
// Number for log lines to load
export const logRange = isDevelopment ? 100 : 1000;
export const logRange = 500;
@autobind()
export class PodLogsStore extends DockTabStore<IPodLogsData> {
@ -49,6 +49,11 @@ export class PodLogsStore extends DockTabStore<IPodLogsData> {
reaction(() => this.logs.get(dockStore.selectedTabId), () => {
this.setNewLogSince(dockStore.selectedTabId);
})
reaction(() => dockStore.selectedTabId, () => {
// Clear search query on tab change
searchStore.reset();
})
}
/**
@ -82,6 +87,7 @@ export class PodLogsStore extends DockTabStore<IPodLogsData> {
* @param tabId
*/
loadMore = async (tabId: TabId) => {
if (!this.logs.get(tabId).length) return;
const oldLogs = this.logs.get(tabId);
const logs = await this.loadLogs(tabId, {
sinceTime: this.getLastSinceTime(tabId)
@ -120,7 +126,7 @@ export class PodLogsStore extends DockTabStore<IPodLogsData> {
* @param tabId
*/
setNewLogSince(tabId: TabId) {
if (!this.logs.has(tabId) || this.newLogSince.has(tabId)) return;
if (!this.logs.has(tabId) || !this.logs.get(tabId).length || this.newLogSince.has(tabId)) return;
const timestamp = this.getLastSinceTime(tabId);
this.newLogSince.set(tabId, timestamp.split(".")[0]); // Removing milliseconds from string
}

View File

@ -1,9 +1,7 @@
import "./pod-logs.scss";
import React from "react";
import AnsiUp from "ansi_up";
import DOMPurify from "dompurify";
import { t, Trans } from "@lingui/macro";
import { computed, observable, reaction } from "mobx";
import { Trans } from "@lingui/macro";
import { action, computed, observable, reaction } from "mobx";
import { disposeOnUnmount, observer } from "mobx-react";
import { _i18n } from "../../i18n";
import { autobind, cssNames } from "../../utils";
@ -14,30 +12,33 @@ import { InfoPanel } from "./info-panel";
import { IPodLogsData, logRange, podLogsStore } from "./pod-logs.store";
import { Button } from "../button";
import { PodLogControls } from "./pod-log-controls";
import { VirtualList } from "../virtual-list";
import { searchStore } from "../../../common/search-store";
import { ListOnScrollProps } from "react-window";
interface Props {
className?: string
tab: IDockTab
}
const lineHeight = 18; // Height of a log line. Should correlate with styles in pod-logs.scss
@observer
export class PodLogs extends React.Component<Props> {
@observable ready = false;
@observable preloading = false; // Indicator for setting Spinner (loader) at the top of the logs
@observable showJumpToBottom = false;
@observable hideHorizontalScroll = true; // Hiding scrollbar allows to scroll logs down to last element
private logsElement: HTMLDivElement;
private logsElement = React.createRef<HTMLDivElement>(); // A reference for outer container in VirtualList
private virtualListRef = React.createRef<VirtualList>(); // A reference for VirtualList component
private lastLineIsShown = true; // used for proper auto-scroll content after refresh
private colorConverter = new AnsiUp();
componentDidMount() {
disposeOnUnmount(this, [
reaction(() => this.props.tab.id, async () => {
if (podLogsStore.logs.has(this.tabId)) {
this.ready = true;
return;
}
await this.load();
this.scrollToBottom();
}, { fireImmediately: true }),
// Check if need to show JumpToBottom if new log amount is less than previous one
@ -53,8 +54,8 @@ export class PodLogs extends React.Component<Props> {
componentDidUpdate() {
// scroll logs only when it's already in the end,
// otherwise it can interrupt reading by jumping after loading new logs update
if (this.logsElement && this.lastLineIsShown) {
this.logsElement.scrollTop = this.logsElement.scrollHeight;
if (this.logsElement.current && this.lastLineIsShown) {
this.logsElement.current.scrollTop = this.logsElement.current.scrollHeight;
}
}
@ -86,60 +87,130 @@ export class PodLogs extends React.Component<Props> {
/**
* Function loads more logs (usually after user scrolls to top) and sets proper
* scrolling position
* @param scrollHeight previous scrollHeight position before adding new lines
*/
loadMore = async (scrollHeight: number) => {
if (podLogsStore.lines < logRange) return;
loadMore = async () => {
const lines = podLogsStore.lines;
if (lines < logRange) return;
this.preloading = true;
await podLogsStore.load(this.tabId).then(() => this.preloading = false);
if (this.logsElement.scrollHeight > scrollHeight) {
await podLogsStore.load(this.tabId);
this.preloading = false;
if (podLogsStore.lines > lines) {
// Set scroll position back to place where preloading started
this.logsElement.scrollTop = this.logsElement.scrollHeight - scrollHeight - 48;
this.logsElement.current.scrollTop = (podLogsStore.lines - lines) * lineHeight;
}
}
/**
* Computed prop which returns logs with or without timestamps added to each line and
* does separation between new and old logs
* @returns {Array} An array with 2 items - [oldLogs, newLogs]
* A function for various actions after search is happened
* @param query {string} A text from search field
*/
@computed
get logs() {
if (!podLogsStore.logs.has(this.tabId)) return [];
const logs = podLogsStore.logs.get(this.tabId);
const { getData, removeTimestamps, newLogSince } = podLogsStore;
const { showTimestamps } = getData(this.tabId);
let oldLogs: string[] = logs;
let newLogs: string[] = [];
if (newLogSince.has(this.tabId)) {
// Finding separator timestamp in logs
const index = logs.findIndex(item => item.includes(newLogSince.get(this.tabId)));
if (index !== -1) {
// Splitting logs to old and new ones
oldLogs = logs.slice(0, index);
newLogs = logs.slice(index);
}
}
if (!showTimestamps) {
return [oldLogs, newLogs].map(logs => logs.map(item => removeTimestamps(item)))
}
return [oldLogs, newLogs];
@autobind()
onSearch(query: string) {
this.toOverlay();
}
onScroll = (evt: React.UIEvent<HTMLDivElement>) => {
const logsArea = evt.currentTarget;
const toBottomOffset = 100 * 16; // 100 lines * 16px (height of each line)
const { scrollHeight, clientHeight, scrollTop } = logsArea;
if (scrollTop === 0) {
this.loadMore(scrollHeight);
/**
* Scrolling to active overlay (search word highlight)
*/
@autobind()
toOverlay() {
const { activeOverlayLine } = searchStore;
if (!this.virtualListRef.current || activeOverlayLine === undefined) return;
// Scroll vertically
this.virtualListRef.current.scrollToItem(activeOverlayLine, "center");
// Scroll horizontally in timeout since virtual list need some time to prepare its contents
setTimeout(() => {
const overlay = document.querySelector(".PodLogs .list span.active");
if (!overlay) return;
overlay.scrollIntoViewIfNeeded();
}, 100);
}
/**
* Computed prop which returns logs with or without timestamps added to each line
* @returns {Array} An array log items
*/
@computed
get logs(): string[] {
if (!podLogsStore.logs.has(this.tabId)) return [];
const logs = podLogsStore.logs.get(this.tabId);
const { getData, removeTimestamps } = podLogsStore;
const { showTimestamps } = getData(this.tabId);
if (!showTimestamps) {
return logs.map(item => removeTimestamps(item));
}
if (scrollHeight - scrollTop > toBottomOffset) {
this.showJumpToBottom = true;
return logs;
}
onScroll = (props: ListOnScrollProps) => {
if (!this.logsElement.current) return;
const toBottomOffset = 100 * lineHeight; // 100 lines * 18px (height of each line)
const { scrollHeight, clientHeight } = this.logsElement.current;
const { scrollDirection, scrollOffset, scrollUpdateWasRequested } = props;
if (scrollDirection == "forward") {
if (scrollHeight - scrollOffset < toBottomOffset) {
this.showJumpToBottom = false;
}
if (clientHeight + scrollOffset === scrollHeight) {
this.lastLineIsShown = true;
}
} else {
this.showJumpToBottom = false;
this.lastLineIsShown = false;
// Trigger loading only if scrolled by user
if (scrollOffset === 0 && !scrollUpdateWasRequested) {
this.loadMore();
}
if (scrollHeight - scrollOffset > toBottomOffset) {
this.showJumpToBottom = true;
}
}
this.lastLineIsShown = clientHeight + scrollTop === scrollHeight;
};
}
@action
scrollToBottom = () => {
if (!this.virtualListRef.current) return;
this.hideHorizontalScroll = true;
this.virtualListRef.current.scrollToItem(this.logs.length, "end");
this.showJumpToBottom = false;
// Showing horizontal scrollbar after VirtualList settles down
setTimeout(() => this.hideHorizontalScroll = false, 500);
}
/**
* A function is called by VirtualList for rendering each of the row
* @param rowIndex {Number} index of the log element in logs array
* @returns A react element with a row itself
*/
getLogRow = (rowIndex: number) => {
const { searchQuery, isActiveOverlay } = searchStore;
const item = this.logs[rowIndex];
const contents: React.ReactElement[] = [];
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 overlay = !lastItem ?
<span className={cssNames({ active })}>{matches.next().value}</span> :
null
contents.push(
<React.Fragment key={piece + index}>
{piece}{overlay}
</React.Fragment>
);
})
}
return (
<div className={cssNames("LogRow")}>
{contents.length > 1 ? contents : item}
</div>
);
}
renderJumpToBottom() {
if (!this.logsElement) return null;
@ -149,10 +220,7 @@ export class PodLogs extends React.Component<Props> {
className={cssNames("jump-to-bottom flex gaps", {active: this.showJumpToBottom})}
onClick={evt => {
evt.currentTarget.blur();
this.logsElement.scrollTo({
top: this.logsElement.scrollHeight,
behavior: "auto"
});
this.scrollToBottom();
}}
>
<Trans>Jump to bottom</Trans>
@ -162,13 +230,15 @@ export class PodLogs extends React.Component<Props> {
}
renderLogs() {
const [oldLogs, newLogs] = this.logs;
// Generating equal heights for each row with ability to do multyrow logs in future
// e. g. for wrapping logs feature
const rowHeights = new Array(this.logs.length).fill(lineHeight);
if (!this.ready) {
return <Spinner center/>;
}
if (!oldLogs.length && !newLogs.length) {
if (!this.logs.length) {
return (
<div className="flex align-center justify-center">
<div className="flex box grow align-center justify-center">
<Trans>There are no logs available for container.</Trans>
</div>
);
@ -177,16 +247,18 @@ export class PodLogs extends React.Component<Props> {
<>
{this.preloading && (
<div className="flex justify-center">
<Spinner />
<Spinner center />
</div>
)}
<div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(this.colorConverter.ansi_to_html(oldLogs.join("\n"))) }} />
{newLogs.length > 0 && (
<>
<p className="new-logs-sep" title={_i18n._(t`New logs since opening logs tab`)}/>
<div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(this.colorConverter.ansi_to_html(newLogs.join("\n"))) }} />
</>
)}
<VirtualList
items={this.logs}
rowHeights={rowHeights}
getRow={this.getLogRow}
onScroll={this.onScroll}
outerRef={this.logsElement}
ref={this.virtualListRef}
className="box grow"
/>
</>
);
}
@ -201,17 +273,20 @@ export class PodLogs extends React.Component<Props> {
logs={this.logs}
save={this.save}
reload={this.reload}
onSearch={this.onSearch}
toPrevOverlay={this.toOverlay}
toNextOverlay={this.toOverlay}
/>
)
return (
<div className={cssNames("PodLogs flex column", className)}>
<div className={cssNames("PodLogs flex column", className, { noscroll: this.hideHorizontalScroll })}>
<InfoPanel
tabId={this.props.tab.id}
controls={controls}
showSubmitClose={false}
showButtons={false}
/>
<div className="logs" onScroll={this.onScroll} ref={e => this.logsElement = e}>
<div className="logs flex">
{this.renderJumpToBottom()}
{this.renderLogs()}
</div>

View File

@ -1,3 +1,4 @@
export * from './input'
export * from './search-input'
export * from './search-input-url'
export * from './file-input'

View File

@ -27,6 +27,7 @@ export type InputProps<T = string> = Omit<InputElementProps, "onChange" | "onSub
showValidationLine?: boolean; // show animated validation line for async validators
iconLeft?: string | React.ReactNode; // material-icon name in case of string-type
iconRight?: string | React.ReactNode;
contentRight?: string | React.ReactNode; // Any component of string goes after iconRight
validators?: InputValidator | InputValidator[];
onChange?(value: T, evt: React.ChangeEvent<InputElement>): void;
onSubmit?(value: T): void;
@ -216,6 +217,10 @@ export class Input extends React.Component<InputProps, State> {
onKeyDown(evt: React.KeyboardEvent<any>) {
const modified = evt.shiftKey || evt.metaKey || evt.altKey || evt.ctrlKey;
if (this.props.onKeyDown) {
this.props.onKeyDown(evt);
}
switch (evt.key) {
case "Enter":
if (this.props.onSubmit && !modified && !evt.repeat) {
@ -261,7 +266,7 @@ export class Input extends React.Component<InputProps, State> {
render() {
const {
multiLine, showValidationLine, validators, theme, maxRows, children,
maxLength, rows, disabled, autoSelectOnFocus, iconLeft, iconRight,
maxLength, rows, disabled, autoSelectOnFocus, iconLeft, iconRight, contentRight,
...inputProps
} = this.props;
const { focused, dirty, valid, validating, errors } = this.state;
@ -293,7 +298,8 @@ export class Input extends React.Component<InputProps, State> {
<label className="input-area flex gaps align-center">
{isString(iconLeft) ? <Icon material={iconLeft}/> : iconLeft}
{multiLine ? <textarea {...inputProps as any} /> : <input {...inputProps as any} />}
{isString(iconRight) ? <Icon material={iconRight}/> : iconRight}
{isString(iconRight) ? <Icon material={iconRight} /> : iconRight}
{contentRight}
</label>
<div className="input-info flex gaps">
{!valid && dirty && (

View File

@ -0,0 +1,49 @@
import React from "react";
import debounce from "lodash/debounce";
import { autorun, observable } from "mobx";
import { disposeOnUnmount, observer } from "mobx-react";
import { getSearch, setSearch } from "../../navigation";
import { InputProps } from "./input";
import { SearchInput } from "./search-input";
interface Props extends InputProps {
compact?: boolean; // show only search-icon when not focused
}
@observer
export class SearchInputUrl extends React.Component<Props> {
@observable inputVal = ""; // fix: use empty string to avoid react warnings
@disposeOnUnmount
updateInput = autorun(() => this.inputVal = getSearch())
updateUrl = debounce((val: string) => setSearch(val), 250)
setValue = (value: string) => {
this.inputVal = value;
this.updateUrl(value);
}
clear = () => {
this.setValue("");
this.updateUrl.flush();
}
onChange = (val: string, evt: React.ChangeEvent<any>) => {
this.setValue(val);
if (this.props.onChange) {
this.props.onChange(val, evt);
}
}
render() {
const { inputVal } = this;
return (
<SearchInput
value={inputVal}
onChange={this.onChange}
onClear={this.clear}
{...this.props}
/>
)
}
}

View File

@ -6,7 +6,10 @@
> label {
color: inherit;
background: none;
border: none;
border-radius: $radius;
box-shadow: 0 0 0 1px $halfGray;
padding: 6px 6px 6px 10px;
.Icon {

View File

@ -1,22 +1,22 @@
import "./search-input.scss";
import React from "react";
import debounce from "lodash/debounce"
import { autorun, observable } from "mobx";
import { disposeOnUnmount, observer } from "mobx-react";
import React, { createRef } from "react";
import { t } from "@lingui/macro";
import { observer } from "mobx-react";
import { _i18n } from "../../i18n";
import { autobind, cssNames } from "../../utils";
import { Icon } from "../icon";
import { cssNames } from "../../utils";
import { Input, InputProps } from "./input";
import { getSearch, setSearch } from "../../navigation";
import { _i18n } from '../../i18n';
interface Props extends InputProps {
compact?: boolean; // show only search-icon when not focused
closeIcon?: boolean;
onClear?: () => void;
}
const defaultProps: Partial<Props> = {
autoFocus: true,
closeIcon: true,
get placeholder() {
return _i18n._(t`Search...`)
},
@ -26,27 +26,24 @@ const defaultProps: Partial<Props> = {
export class SearchInput extends React.Component<Props> {
static defaultProps = defaultProps as object;
@observable inputVal = ""; // fix: use empty string to avoid react warnings
private input = createRef<Input>();
@disposeOnUnmount
updateInput = autorun(() => this.inputVal = getSearch())
updateUrl = debounce((val: string) => setSearch(val), 250)
componentDidMount() {
addEventListener("keydown", this.focus);
}
setValue = (value: string) => {
this.inputVal = value;
this.updateUrl(value);
componentWillUnmount() {
removeEventListener("keydown", this.focus);
}
clear = () => {
this.setValue("");
this.updateUrl.flush();
if (this.props.onClear) {
this.props.onClear();
}
}
onChange = (val: string, evt: React.ChangeEvent<any>) => {
this.setValue(val);
if (this.props.onChange) {
this.props.onChange(val, evt);
}
this.props.onChange(val, evt);
}
onKeyDown = (evt: React.KeyboardEvent<any>) => {
@ -61,20 +58,27 @@ export class SearchInput extends React.Component<Props> {
}
}
@autobind()
focus(evt: KeyboardEvent) {
const meta = evt.metaKey || evt.ctrlKey;
if (meta && evt.key == "f") {
this.input.current.focus();
}
}
render() {
const { inputVal } = this;
const { className, compact, ...inputProps } = this.props;
const icon = inputVal
? <Icon small material="close" onClick={this.clear}/>
const { className, compact, closeIcon, onClear, ...inputProps } = this.props;
const icon = this.props.value
? closeIcon ? <Icon small material="close" onClick={this.clear}/> : null
: <Icon small material="search"/>
return (
<Input
{...inputProps}
className={cssNames("SearchInput", className, { compact })}
value={inputVal}
onChange={this.onChange}
onKeyDown={this.onKeyDown}
iconRight={icon}
ref={this.input}
/>
)
}

View File

@ -22,15 +22,6 @@
}
}
}
.SearchInput {
label {
background: none;
border: none;
border-radius: $radius;
box-shadow: 0 0 0 1px $halfGray;
}
}
}
> .items {

View File

@ -12,7 +12,7 @@ import { AddRemoveButtons, AddRemoveButtonsProps } from "../add-remove-buttons";
import { NoItems } from "../no-items";
import { Spinner } from "../spinner";
import { ItemObject, ItemStore } from "../../item.store";
import { SearchInput } from "../input";
import { SearchInputUrl } from "../input";
import { namespaceStore } from "../+namespaces/namespace.store";
import { Filter, FilterType, pageFilters } from "./page-filters.store";
import { PageFiltersList } from "./page-filters-list";
@ -349,7 +349,7 @@ export class ItemListLayout extends React.Component<ItemListLayoutProps> {
[FilterType.NAMESPACE]: true, // namespace-select used instead
}}/>
</>,
search: <SearchInput/>,
search: <SearchInputUrl/>,
}
let header = this.renderHeaderContent(placeholders);
if (customizeHeader) {

View File

@ -159,7 +159,7 @@ export class Table extends React.Component<TableProps> {
<VirtualList
items={sortedItems}
rowHeights={rowHeights}
getTableRow={getTableRow}
getRow={getTableRow}
selectedItemId={selectedItemId}
className={className}
/>

View File

@ -9,6 +9,5 @@
}
overflow-y: overlay !important;
overflow-x: hidden !important;
}
}

View File

@ -4,8 +4,8 @@ import "./virtual-list.scss";
import React, { Component } from "react";
import { observer } from "mobx-react";
import { ListChildComponentProps, VariableSizeList } from "react-window";
import { cssNames } from "../../utils";
import { Align, ListChildComponentProps, ListOnScrollProps, VariableSizeList } from "react-window";
import { cssNames, noop } from "../../utils";
import { TableRowProps } from "../table/table-row";
import { ItemObject } from "../../item.store";
import throttle from "lodash/throttle";
@ -13,15 +13,17 @@ import debounce from "lodash/debounce";
import isEqual from "lodash/isEqual";
import ResizeSensor from "css-element-queries/src/ResizeSensor";
interface Props {
items: ItemObject[];
interface Props<T extends ItemObject = any> {
items: T[];
rowHeights: number[];
className?: string;
width?: number | string;
initialOffset?: number;
readyOffset?: number;
selectedItemId?: string;
getTableRow?: (uid: string) => React.ReactElement<TableRowProps>;
getRow?: (uid: string | number) => React.ReactElement<any>;
onScroll?: (props: ListOnScrollProps) => void;
outerRef?: React.Ref<any>
}
interface State {
@ -33,6 +35,7 @@ const defaultProps: Partial<Props> = {
width: "100%",
initialOffset: 1,
readyOffset: 10,
onScroll: noop
}
export class VirtualList extends Component<Props, State> {
@ -56,7 +59,7 @@ export class VirtualList extends Component<Props, State> {
componentDidUpdate(prevProps: Props) {
const { items, rowHeights } = this.props;
if (prevProps.items.length !== items.length || !isEqual(prevProps.rowHeights, rowHeights)) {
this.listRef.current.resetAfterIndex(0, true);
this.listRef.current.resetAfterIndex(0, false);
}
}
@ -73,18 +76,23 @@ export class VirtualList extends Component<Props, State> {
getItemSize = (index: number) => this.props.rowHeights[index];
scrollToSelectedItem = debounce(() => {
if (!this.props.selectedItemId) return;
const { items, selectedItemId } = this.props;
const index = items.findIndex(item => item.getId() == selectedItemId);
if (index === -1) return;
this.listRef.current.scrollToItem(index, "start");
})
scrollToItem = (index: number, align: Align) => {
this.listRef.current.scrollToItem(index, align)
}
render() {
const { width, className, items, getTableRow } = this.props;
const { width, className, items, getRow, onScroll, outerRef } = this.props;
const { height, overscanCount } = this.state;
const rowData: RowData = {
items,
getTableRow
getRow
};
return (
<div className={cssNames("VirtualList", className)} ref={this.parentRef}>
@ -97,7 +105,9 @@ export class VirtualList extends Component<Props, State> {
itemData={rowData}
overscanCount={overscanCount}
ref={this.listRef}
outerRef={outerRef}
children={Row}
onScroll={onScroll}
/>
</div>
);
@ -106,7 +116,7 @@ export class VirtualList extends Component<Props, State> {
interface RowData {
items: ItemObject[];
getTableRow?: (uid: string) => React.ReactElement<TableRowProps>;
getRow?: (uid: string | number) => React.ReactElement<TableRowProps>;
}
interface RowProps extends ListChildComponentProps {
@ -115,9 +125,10 @@ interface RowProps extends ListChildComponentProps {
const Row = observer((props: RowProps) => {
const { index, style, data } = props;
const { items, getTableRow } = data;
const uid = items[index].getId();
const row = getTableRow(uid);
const { items, getRow } = data;
const item = items[index];
const uid = typeof item == "string" ? index : items[index].getId();
const row = getRow(uid);
if (!row) return null;
return React.cloneElement(row, {
style: Object.assign({}, row.props.style, style)

View File

@ -61,6 +61,7 @@
"dockInfoBackground": "#1e2125",
"dockInfoBorderColor": "#303136",
"logsBackground": "#000000",
"logRowHoverBackground": "#35373a",
"terminalBackground": "#000000",
"terminalForeground": "#ffffff",
"terminalCursor": "#ffffff",

View File

@ -62,6 +62,7 @@
"dockInfoBackground": "#e8e8e8",
"dockInfoBorderColor": "#c9cfd3",
"logsBackground": "#ffffff",
"logRowHoverBackground": "#eeeeee",
"terminalBackground": "#ffffff",
"terminalForeground": "#2d2d2d",
"terminalCursor": "#2d2d2d",

View File

@ -93,6 +93,7 @@ $terminalBrightWhite: var(--terminalBrightWhite);
// Logs
$logsBackground: var(--logsBackground);
$logRowHoverBackground: var(--logRowHoverBackground);
// Dialogs
$dialogTextColor: var(--dialogTextColor);

7
types/dom.d.ts vendored Normal file
View File

@ -0,0 +1,7 @@
export {}
declare global {
interface Element {
scrollIntoViewIfNeeded(opt_center?: boolean): void;
}
}