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 AndreevandLauri Nevala authored and GitHub committed 2020-11-09 17:46:14 +03:00
1 parent f69f8c793f
commit 16fb35e3f9
25 files changed
+697 -144

No files matched your search

@@ -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>
);
@@ -0,0 +1,10 @@
.PodLogsSearch {
.SearchInput {
min-width: 150px;
width: 150px;
.find-count {
margin-left: 2px;
}
}
}
@@ -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>
);
});
+54 -10
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
}
}
}
@@ -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
}
+145 -70
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>