From 16fb35e3f976d0d57cc52958da66cacbd72ea4e2 Mon Sep 17 00:00:00 2001 From: Alex Andreev Date: Mon, 9 Nov 2020 17:46:14 +0300 Subject: [PATCH] Log search (#1114) * Moving logs to virtual list Signed-off-by: Alex Andreev * Introducing log search Signed-off-by: Alex Andreev * Setting ref for VirtualList to access its methods Signed-off-by: Alex Andreev * Introducing search store Signed-off-by: Alex Andreev * Centering overlay when scroll to it Signed-off-by: Alex Andreev * Using SearchInput in PodLogSearch Signed-off-by: Alex Andreev * Using Prev/Next icons for search Signed-off-by: Alex Andreev * No trigger logs load when scrolled by method Signed-off-by: Alex Andreev * SearchInput refactoring Signed-off-by: Alex Andreev * Adding find counters Signed-off-by: Alex Andreev * Clean search query on dock tab change Signed-off-by: Alex Andreev * Refresh search when logs get changed Signed-off-by: Alex Andreev * Case-insensitive search Signed-off-by: Alex Andreev * Improve logs scrolling experience Signed-off-by: Alex Andreev * Catching empty logs in various places Signed-off-by: Alex Andreev * Fixing downloading logs Signed-off-by: Alex Andreev * Clean up some duplicated styles Signed-off-by: Alex Andreev * Removing jump-to-bottom animation Signed-off-by: Alex Andreev * Fixing since label Signed-off-by: Alex Andreev * Reducing container selector size Signed-off-by: Alex Andreev * Scroll down to bottom after each reload Signed-off-by: Alex Andreev * Fix search within timestamps if they not provided Signed-off-by: Alex Andreev * Use log row hover color from theme Signed-off-by: Alex Andreev * Add search bindings for 'Esc' & 'Enter' hits Signed-off-by: Alex Andreev * Focus input fields on CmdOrCtrl+F Signed-off-by: Alex Andreev * Move search.store.ts in to /common folder Signed-off-by: Alex Andreev * search.store.ts -> search-store.ts Signed-off-by: Alex Andreev * Adding test for search store Signed-off-by: Alex Andreev * Adding integration tests for logs Signed-off-by: Alex Andreev * Fixing scroll jumping bug Signed-off-by: Alex Andreev * Removing download icon check for testing purpose Signed-off-by: Alex Andreev * Removing clicking on nginx-create-pod-test Signed-off-by: Alex Andreev * Moving log tests before cluster operations Signed-off-by: Alex Andreev * Build extensions before integration tests Signed-off-by: Lauri Nevala * Build also npm before integration tests Signed-off-by: Lauri Nevala * Move npm build and extension build into own build step Signed-off-by: Lauri Nevala * Removing separator sketches Signed-off-by: Alex Andreev * Horizontal scrolling to founded keyword Signed-off-by: Alex Andreev * Delaying horizontal scrolling Signed-off-by: Alex Andreev Co-authored-by: Lauri Nevala --- .azure-pipelines.yml | 12 + integration/__tests__/app.tests.ts | 34 +++ src/common/__tests__/search-store.test.ts | 80 +++++++ src/common/search-store.ts | 126 ++++++++++ .../+apps-helm-charts/helm-charts.tsx | 4 +- .../components/dock/pod-log-controls.tsx | 19 +- .../components/dock/pod-log-search.scss | 10 + .../components/dock/pod-log-search.tsx | 87 +++++++ src/renderer/components/dock/pod-logs.scss | 64 +++++- .../components/dock/pod-logs.store.ts | 12 +- src/renderer/components/dock/pod-logs.tsx | 215 ++++++++++++------ src/renderer/components/input/index.ts | 1 + src/renderer/components/input/input.tsx | 10 +- .../components/input/search-input-url.tsx | 49 ++++ .../components/input/search-input.scss | 3 + .../components/input/search-input.tsx | 54 +++-- .../item-object-list/item-list-layout.scss | 9 - .../item-object-list/item-list-layout.tsx | 4 +- src/renderer/components/table/table.tsx | 2 +- .../components/virtual-list/virtual-list.scss | 1 - .../components/virtual-list/virtual-list.tsx | 35 ++- src/renderer/themes/lens-dark.json | 1 + src/renderer/themes/lens-light.json | 1 + src/renderer/themes/theme-vars.scss | 1 + types/dom.d.ts | 7 + 25 files changed, 697 insertions(+), 144 deletions(-) create mode 100644 src/common/__tests__/search-store.test.ts create mode 100644 src/common/search-store.ts create mode 100644 src/renderer/components/dock/pod-log-search.scss create mode 100644 src/renderer/components/dock/pod-log-search.tsx create mode 100644 src/renderer/components/input/search-input-url.tsx create mode 100644 types/dom.d.ts diff --git a/.azure-pipelines.yml b/.azure-pipelines.yml index f6060ca1dd..8b71a5411c 100644 --- a/.azure-pipelines.yml +++ b/.azure-pipelines.yml @@ -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: | diff --git a/integration/__tests__/app.tests.ts b/integration/__tests__/app.tests.ts index c9567a195f..604ac43323 100644 --- a/integration/__tests__/app.tests.ts +++ b/integration/__tests__/app.tests.ts @@ -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) diff --git a/src/common/__tests__/search-store.test.ts b/src/common/__tests__/search-store.test.ts new file mode 100644 index 0000000000..517182a04a --- /dev/null +++ b/src/common/__tests__/search-store.test.ts @@ -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); + }) +}) \ No newline at end of file diff --git a/src/common/search-store.ts b/src/common/search-store.ts new file mode 100644 index 0000000000..3288bbb3a0 --- /dev/null +++ b/src/common/search-store.ts @@ -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; \ No newline at end of file diff --git a/src/renderer/components/+apps-helm-charts/helm-charts.tsx b/src/renderer/components/+apps-helm-charts/helm-charts.tsx index 7948cf950e..473d78194e 100644 --- a/src/renderer/components/+apps-helm-charts/helm-charts.tsx +++ b/src/renderer/components/+apps-helm-charts/helm-charts.tsx @@ -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 { (items: HelmChart[]) => items.filter(item => !item.deprecated) ]} customizeHeader={() => ( - + )} renderTableHeader={[ { className: "icon" }, diff --git a/src/renderer/components/dock/pod-log-controls.tsx b/src/renderer/components/dock/pod-log-controls.tsx index d3ba81e7ab..bf247077af 100644 --- a/src/renderer/components/dock/pod-log-controls.tsx +++ b/src/renderer/components/dock/pod-log-controls.tsx @@ -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) => 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) => { )} -
+
{ material="get_app" onClick={downloadLogs} tooltip={_i18n._(t`Save`)} + className="download-icon" /> +
); diff --git a/src/renderer/components/dock/pod-log-search.scss b/src/renderer/components/dock/pod-log-search.scss new file mode 100644 index 0000000000..7d3ea9d92a --- /dev/null +++ b/src/renderer/components/dock/pod-log-search.scss @@ -0,0 +1,10 @@ +.PodLogsSearch { + .SearchInput { + min-width: 150px; + width: 150px; + + .find-count { + margin-left: 2px; + } + } +} \ No newline at end of file diff --git a/src/renderer/components/dock/pod-log-search.tsx b/src/renderer/components/dock/pod-log-search.tsx new file mode 100644 index 0000000000..7dd83eef3d --- /dev/null +++ b/src/renderer/components/dock/pod-log-search.tsx @@ -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 = ( +
+ {activeFind}/{totalFinds} +
+ ); + + 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) => { + if (evt.key === "Enter") { + onNextOverlay(); + } + } + + useEffect(() => { + // Refresh search when logs changed + searchStore.onSearch(logs); + }, [logs]); + + return ( +
+ 0 && findCounts} + onClear={onClear} + onKeyDown={onKeyDown} + /> + + + +
+ ); +}); \ No newline at end of file diff --git a/src/renderer/components/dock/pod-logs.scss b/src/renderer/components/dock/pod-logs.scss index 2ea1d1bff1..bb2f223f72 100644 --- a/src/renderer/components/dock/pod-logs.scss +++ b/src/renderer/components/dock/pod-logs.scss @@ -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 + } + } } \ No newline at end of file diff --git a/src/renderer/components/dock/pod-logs.store.ts b/src/renderer/components/dock/pod-logs.store.ts index 8a4e0a90e9..35cbe6a90f 100644 --- a/src/renderer/components/dock/pod-logs.store.ts +++ b/src/renderer/components/dock/pod-logs.store.ts @@ -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 { @@ -49,6 +49,11 @@ export class PodLogsStore extends DockTabStore { 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 { * @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 { * @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 } diff --git a/src/renderer/components/dock/pod-logs.tsx b/src/renderer/components/dock/pod-logs.tsx index d8723187c8..8426a1b5ba 100644 --- a/src/renderer/components/dock/pod-logs.tsx +++ b/src/renderer/components/dock/pod-logs.tsx @@ -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 { @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(); // A reference for outer container in VirtualList + private virtualListRef = React.createRef(); // 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 { 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 { /** * 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) => { - 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 + // 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 ? + {matches.next().value} : + null + contents.push( + + {piece}{overlay} + + ); + }) + } + return ( +
+ {contents.length > 1 ? contents : item} +
+ ); + } renderJumpToBottom() { if (!this.logsElement) return null; @@ -149,10 +220,7 @@ export class PodLogs extends React.Component { 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(); }} > Jump to bottom @@ -162,13 +230,15 @@ export class PodLogs extends React.Component { } 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 ; } - if (!oldLogs.length && !newLogs.length) { + if (!this.logs.length) { return ( -
+
There are no logs available for container.
); @@ -177,16 +247,18 @@ export class PodLogs extends React.Component { <> {this.preloading && (
- +
)} -
- {newLogs.length > 0 && ( - <> -

-

- - )} + ); } @@ -201,17 +273,20 @@ export class PodLogs extends React.Component { logs={this.logs} save={this.save} reload={this.reload} + onSearch={this.onSearch} + toPrevOverlay={this.toOverlay} + toNextOverlay={this.toOverlay} /> ) return ( -
+
-
this.logsElement = e}> +
{this.renderJumpToBottom()} {this.renderLogs()}
diff --git a/src/renderer/components/input/index.ts b/src/renderer/components/input/index.ts index ea9117e3e1..b4bfe3a56b 100644 --- a/src/renderer/components/input/index.ts +++ b/src/renderer/components/input/index.ts @@ -1,3 +1,4 @@ export * from './input' export * from './search-input' +export * from './search-input-url' export * from './file-input' diff --git a/src/renderer/components/input/input.tsx b/src/renderer/components/input/input.tsx index 75bfa31e0c..f0938255a6 100644 --- a/src/renderer/components/input/input.tsx +++ b/src/renderer/components/input/input.tsx @@ -27,6 +27,7 @@ export type InputProps = Omit): void; onSubmit?(value: T): void; @@ -216,6 +217,10 @@ export class Input extends React.Component { onKeyDown(evt: React.KeyboardEvent) { 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 { 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 {