Skip to content

Commit

Permalink
feat: search in service logs (#1830)
Browse files Browse the repository at this point in the history
## Description:
Adding a search bar to the service logs window

![image](https://github.com/kurtosis-tech/kurtosis/assets/11248996/60f962af-743d-49a4-87d5-74e2f02fed82)


## Is this change user facing?
YES
## References (if applicable):
Closes #1791
  • Loading branch information
adschwartz authored Nov 20, 2023
1 parent 6facb66 commit 7fce5b5
Show file tree
Hide file tree
Showing 9 changed files with 296 additions and 22 deletions.
72 changes: 65 additions & 7 deletions enclave-manager/web/src/components/enclaves/logs/LogLine.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ import { DateTime } from "luxon";
import { isDefined } from "../../../utils";
// @ts-ignore
import hasAnsi from "has-ansi";
import { ReactElement } from "react";
import { normalizeLogText } from "./LogViewer";

const Convert = require("ansi-to-html");
const convert = new Convert();

Expand All @@ -15,9 +18,21 @@ export type LogLineProps = {
status?: LogStatus;
};

export type LogLineSearch = {
searchTerm: string;
pattern: RegExp;
};

export type LogLineInput = {
logLineProps: LogLineProps;
logLineSearch?: LogLineSearch;
selected: boolean | undefined;
};

const logFontFamily = "Menlo, Monaco, Inconsolata, Consolas, Courier, monospace";

export const LogLine = ({ timestamp, message, status }: LogLineProps) => {
export const LogLine = ({ logLineProps, logLineSearch, selected }: LogLineInput) => {
const { timestamp, message, status } = logLineProps;
const statusToColor = (status?: LogStatus) => {
switch (status) {
case "error":
Expand All @@ -29,16 +44,58 @@ export const LogLine = ({ timestamp, message, status }: LogLineProps) => {
}
};

const processText = (message: string) => {
if (hasAnsi(message)) {
return parse(convert.toHtml(message));
const processText = (text: string, selected: boolean | undefined) => {
let reactComponent;
if (hasAnsi(text)) {
reactComponent = parse(convert.toHtml(text));
} else {
return <>{message}</>;
reactComponent = <>{text}</>;
}

if (logLineSearch) {
reactComponent = HighlightPattern({ text, regex: logLineSearch.pattern, selected });
}
return reactComponent;
};

const HighlightPattern = ({
text,
regex,
selected,
}: {
text: string;
regex: RegExp;
selected: boolean | undefined;
}) => {
const normalizedLogText = normalizeLogText(text);
const splitText = normalizedLogText.split(regex);
const matches = normalizedLogText.match(regex);

if (!isDefined(matches)) {
return <span>{text}</span>;
}

return (
<span>
{splitText.reduce(
(arr: (ReactElement | string)[], element, index) =>
matches[index]
? [
...arr,
element,
<mark key={index}>
{matches[index]}
</mark>,
]
: [...arr, element],
[],
)}
</span>
);
};

return (
<Flex p={"2px 0"} m={"0 16px"} gap={"8px"} alignItems={"top"}>
<Flex p={"2px 0"} m={"0 16px"} gap={"8px"} alignItems={"top"} backgroundColor={selected ? "gray.600" : ""}>
{isDefined(timestamp) && (
<Box
as={"pre"}
Expand All @@ -62,8 +119,9 @@ export const LogLine = ({ timestamp, message, status }: LogLineProps) => {
fontWeight={400}
fontFamily={logFontFamily}
color={statusToColor(status)}
_focus={{ boxShadow: "outline" }}
>
{message && processText(message)}
{message && processText(message, selected)}
</Box>
</Flex>
);
Expand Down
226 changes: 219 additions & 7 deletions enclave-manager/web/src/components/enclaves/logs/LogViewer.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,30 @@
import { Box, ButtonGroup, Flex, FormControl, FormLabel, Progress, Switch } from "@chakra-ui/react";
import { throttle } from "lodash";
import { ChangeEvent, ReactElement, useEffect, useMemo, useRef, useState } from "react";
import { SmallCloseIcon } from "@chakra-ui/icons";
import {
Box,
Button,
ButtonGroup,
Editable,
EditableInput,
EditablePreview,
Flex,
FormControl,
FormLabel,
HStack,
Input,
InputGroup,
InputRightElement,
Progress,
Switch,
Text,
Tooltip,
} from "@chakra-ui/react";
import { debounce, throttle } from "lodash";
import { ChangeEvent, MutableRefObject, ReactElement, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Virtuoso, VirtuosoHandle } from "react-virtuoso";
import { isDefined, stripAnsi } from "../../../utils";
import { isDefined, isNotEmpty, stripAnsi } from "../../../utils";
import { CopyButton } from "../../CopyButton";
import { DownloadButton } from "../../DownloadButton";
import { LogLine, LogLineProps } from "./LogLine";
import { LogLine, LogLineProps, LogLineSearch } from "./LogLine";

type LogViewerProps = {
logLines: LogLineProps[];
Expand All @@ -14,6 +33,10 @@ type LogViewerProps = {
logsFileName?: string;
};

export const normalizeLogText = (rawText: string) => {
return rawText.trim();
};

export const LogViewer = ({
progressPercent,
logLines: propsLogLines,
Expand All @@ -24,9 +47,43 @@ export const LogViewer = ({
const [logLines, setLogLines] = useState(propsLogLines);
const [userIsScrolling, setUserIsScrolling] = useState(false);
const [automaticScroll, setAutomaticScroll] = useState(true);

const throttledSetLogLines = useMemo(() => throttle(setLogLines, 500), []);

const searchRef: MutableRefObject<HTMLInputElement | null> = useRef(null);
const [search, setSearch] = useState<LogLineSearch | undefined>(undefined);
const [rawSearchTerm, setRawSearchTerm] = useState("");
const [searchMatchesIndices, setSearchMatchesIndices] = useState<number[]>([]);
const [currentSearchIndex, setCurrentSearchIndex] = useState<number | undefined>(undefined);

useEffect(() => {
window.addEventListener("keydown", function (e) {
const element = searchRef?.current;
if ((e.ctrlKey && e.keyCode === 70) || (e.metaKey && e.keyCode === 70)) {
if (element !== document.activeElement) {
e.preventDefault();
element?.focus();
}
}
// Next search match with cmd/ctrl+G
// if ((e.ctrlKey && e.keyCode === 71) || (e.metaKey && e.keyCode === 71)) {
// console.log("NEXT", e.keyCode);
// e.preventDefault();
// nextMatch();
// }

// Clear the search on escape
if (e.key === "Escape" || e.keyCode === 27) {
if (element === document.activeElement) {
e.preventDefault();
setSearch(undefined);
setRawSearchTerm("");
setSearchMatchesIndices([]);
setCurrentSearchIndex(undefined);
}
}
});
}, []);

useEffect(() => {
throttledSetLogLines(propsLogLines);
}, [propsLogLines, throttledSetLogLines]);
Expand Down Expand Up @@ -54,9 +111,158 @@ export const LogViewer = ({
.join("\n");
};

useEffect(() => {
if (search) findMatches(search);
}, [search?.searchTerm, logLines]);

const updateSearchTerm = (rawText: string) => {
setCurrentSearchIndex(undefined);
const searchTerm = normalizeLogText(rawText);
const logLineSearch: LogLineSearch = {
searchTerm: searchTerm,
pattern: new RegExp(searchTerm, "gi"), // `i` is invariant case
};
setSearch(logLineSearch);
};
const debouncedUpdateSearchTerm = debounce(updateSearchTerm, 100);
const debouncedUpdateSearchTermCallback = useCallback(debouncedUpdateSearchTerm, []);

const hasSearchTerm = () => {
if (!search) return false;
return isDefined(search.searchTerm) && isNotEmpty(search.searchTerm);
};

const findMatches = (search: LogLineSearch) => {
setSearchMatchesIndices([]);
if (hasSearchTerm()) {
const matches = logLines.flatMap((line, index) => {
if (line?.message && normalizeLogText(line.message).match(search.pattern)) {
return index;
} else {
return [];
}
});
setSearchMatchesIndices(matches);
}
};

const handleOnChange = (e: ChangeEvent<HTMLInputElement>) => {
setRawSearchTerm(e.target.value);
debouncedUpdateSearchTermCallback(e.target.value);
};

const priorMatch = () => {
if (searchMatchesIndices.length > 0) {
const newIndex = isDefined(currentSearchIndex) ? currentSearchIndex - 1 : 0;
updateSearchIndexBounded(newIndex);
}
};

const nextMatch = () => {
if (searchMatchesIndices.length > 0) {
const newIndex = isDefined(currentSearchIndex) ? currentSearchIndex + 1 : 0;
updateSearchIndexBounded(newIndex);
}
};

const updateSearchIndexBounded = (newIndex: number) => {
if (newIndex > searchMatchesIndices.length - 1) {
newIndex = 0;
}
if (newIndex < 0) {
newIndex = searchMatchesIndices.length - 1;
}
setCurrentSearchIndex(newIndex);
return newIndex;
};

useEffect(() => {
if (virtuosoRef?.current && currentSearchIndex !== undefined && currentSearchIndex >= 0) {
virtuosoRef.current.scrollToIndex(searchMatchesIndices[currentSearchIndex]);
}
}, [currentSearchIndex]);

const clearSearch = () => {
setRawSearchTerm("");
setSearch(undefined);
setSearchMatchesIndices([]);
setCurrentSearchIndex(undefined);
};

const parseMatchIndexRequest = (input: string) => {
let parsed = parseInt(input);
if (isNaN(parsed) || parsed < 1) return 1;
if (parsed > searchMatchesIndices.length) return searchMatchesIndices.length;
return parsed;
};

const highlight = (currentSearchIndex: number | undefined, thisIndex: number, searchableIndices: number[]) => {
return (
currentSearchIndex !== undefined &&
searchableIndices.length > 0 &&
searchableIndices[currentSearchIndex] === thisIndex
);
};

return (
<Flex flexDirection={"column"} gap={"32px"} h={"100%"}>
<Flex flexDirection={"column"} position={"relative"} bg={"gray.800"} h={"100%"}>
<Box width={"100%"}>
<Flex m={4}>
<Flex width={"40%"}>
<InputGroup size="sm">
<Input
size={"sm"}
ref={searchRef}
value={rawSearchTerm}
onChange={handleOnChange}
placeholder={"search"}
/>
{rawSearchTerm && (
<InputRightElement>
<SmallCloseIcon onClick={clearSearch} />
</InputRightElement>
)}
</InputGroup>
</Flex>
<Button size={"sm"} ml={2} onClick={priorMatch}>
Previous
</Button>
<Button size={"sm"} ml={2} onClick={nextMatch}>
Next
</Button>
{hasSearchTerm() && (
<Box ml={2}>
<Text align={"left"} color={searchMatchesIndices.length === 0 ? "red" : "kurtosisGreen.400"}>
<HStack alignItems={"center"}>
<>
{searchMatchesIndices.length > 0 && currentSearchIndex !== undefined && (
<>
<Editable
p={0}
m={0}
size={"sm"}
value={`${currentSearchIndex + 1}`}
onChange={(inputString) =>
updateSearchIndexBounded(parseMatchIndexRequest(inputString) - 1)
}
>
<Tooltip label="Click to edit" shouldWrapChildren={true}>
<EditablePreview />
</Tooltip>
<EditableInput p={1} width={"50px"} />
</Editable>
<>/ </>
</>
)}
<>{searchMatchesIndices.length} matches</>
</>
</HStack>
</Text>
</Box>
)}
</Flex>
</Box>
{isDefined(ProgressWidget) && (
<Box
display={"inline-flex"}
Expand Down Expand Up @@ -84,7 +290,13 @@ export const LogViewer = ({
isScrolling={setUserIsScrolling}
style={{ height: "100%" }}
data={logLines.filter(({ message }) => isDefined(message))}
itemContent={(_, line) => <LogLine {...line} />}
itemContent={(index, line) => (
<LogLine
logLineProps={line}
logLineSearch={search}
selected={highlight(currentSearchIndex, index, searchMatchesIndices)}
/>
)}
/>
{isDefined(progressPercent) && (
<Progress
Expand Down
4 changes: 4 additions & 0 deletions enclave-manager/web/src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ export function isDefined<T>(it: T | null | undefined): it is T {
return it !== null && it !== undefined;
}

export function isNotEmpty(it: string): it is string {
return it.length > 0;
}

export function isStringTrue(value?: string | null) {
return (value + "").toLowerCase() === "true";
}
Expand Down
6 changes: 3 additions & 3 deletions engine/server/webapp/asset-manifest.json
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
{
"files": {
"main.js": "./static/js/main.14d7f9cc.js",
"main.js": "./static/js/main.bab03440.js",
"index.html": "./index.html",
"main.14d7f9cc.js.map": "./static/js/main.14d7f9cc.js.map"
"main.bab03440.js.map": "./static/js/main.bab03440.js.map"
},
"entrypoints": [
"static/js/main.14d7f9cc.js"
"static/js/main.bab03440.js"
]
}
Loading

0 comments on commit 7fce5b5

Please sign in to comment.