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:
parent
f69f8c793f
commit
16fb35e3f9
@ -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: |
|
||||
|
@ -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)
|
||||
|
||||
|
80
src/common/__tests__/search-store.test.ts
Normal file
80
src/common/__tests__/search-store.test.ts
Normal 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
126
src/common/search-store.ts
Normal 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;
|
@ -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" },
|
||||
|
@ -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>
|
||||
);
|
||||
|
10
src/renderer/components/dock/pod-log-search.scss
Normal file
10
src/renderer/components/dock/pod-log-search.scss
Normal file
@ -0,0 +1,10 @@
|
||||
.PodLogsSearch {
|
||||
.SearchInput {
|
||||
min-width: 150px;
|
||||
width: 150px;
|
||||
|
||||
.find-count {
|
||||
margin-left: 2px;
|
||||
}
|
||||
}
|
||||
}
|
87
src/renderer/components/dock/pod-log-search.tsx
Normal file
87
src/renderer/components/dock/pod-log-search.tsx
Normal 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>
|
||||
);
|
||||
});
|
@ -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
|
||||
}
|
||||
|
@ -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>
|
||||
|
@ -1,3 +1,4 @@
|
||||
export * from './input'
|
||||
export * from './search-input'
|
||||
export * from './search-input-url'
|
||||
export * from './file-input'
|
||||
|
@ -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 && (
|
||||
|
49
src/renderer/components/input/search-input-url.tsx
Normal file
49
src/renderer/components/input/search-input-url.tsx
Normal 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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
@ -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 {
|
||||
|
@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
@ -22,15 +22,6 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.SearchInput {
|
||||
label {
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: $radius;
|
||||
box-shadow: 0 0 0 1px $halfGray;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> .items {
|
||||
|
@ -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) {
|
||||
|
@ -159,7 +159,7 @@ export class Table extends React.Component<TableProps> {
|
||||
<VirtualList
|
||||
items={sortedItems}
|
||||
rowHeights={rowHeights}
|
||||
getTableRow={getTableRow}
|
||||
getRow={getTableRow}
|
||||
selectedItemId={selectedItemId}
|
||||
className={className}
|
||||
/>
|
||||
|
@ -9,6 +9,5 @@
|
||||
}
|
||||
|
||||
overflow-y: overlay !important;
|
||||
overflow-x: hidden !important;
|
||||
}
|
||||
}
|
@ -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)
|
||||
|
@ -61,6 +61,7 @@
|
||||
"dockInfoBackground": "#1e2125",
|
||||
"dockInfoBorderColor": "#303136",
|
||||
"logsBackground": "#000000",
|
||||
"logRowHoverBackground": "#35373a",
|
||||
"terminalBackground": "#000000",
|
||||
"terminalForeground": "#ffffff",
|
||||
"terminalCursor": "#ffffff",
|
||||
|
@ -62,6 +62,7 @@
|
||||
"dockInfoBackground": "#e8e8e8",
|
||||
"dockInfoBorderColor": "#c9cfd3",
|
||||
"logsBackground": "#ffffff",
|
||||
"logRowHoverBackground": "#eeeeee",
|
||||
"terminalBackground": "#ffffff",
|
||||
"terminalForeground": "#2d2d2d",
|
||||
"terminalCursor": "#2d2d2d",
|
||||
|
@ -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
7
types/dom.d.ts
vendored
Normal file
@ -0,0 +1,7 @@
|
||||
export {}
|
||||
|
||||
declare global {
|
||||
interface Element {
|
||||
scrollIntoViewIfNeeded(opt_center?: boolean): void;
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user