-
Notifications
You must be signed in to change notification settings - Fork 60
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat: search in service logs #1830
Changes from all commits
3125223
ac0be3b
da2dffd
a505e9f
ba19516
01b4afc
4304584
58793f8
d2b83a4
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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"; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is a circular dependency now between |
||
|
||
const Convert = require("ansi-to-html"); | ||
const convert = new Convert(); | ||
|
||
|
@@ -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) => { | ||
Comment on lines
+26
to
-20
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Here
|
||
export const LogLine = ({ logLineProps, logLineSearch, selected }: LogLineInput) => { | ||
const { timestamp, message, status } = logLineProps; | ||
const statusToColor = (status?: LogStatus) => { | ||
switch (status) { | ||
case "error": | ||
|
@@ -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) => { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. this is also actually another react component - it should be moved out of the |
||
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; | ||
}) => { | ||
Comment on lines
+61
to
+69
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I don't think this component (because that's what it is) should be inlined with |
||
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"} | ||
|
@@ -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> | ||
); | ||
|
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[]; | ||
|
@@ -14,6 +33,10 @@ type LogViewerProps = { | |
logsFileName?: string; | ||
}; | ||
|
||
export const normalizeLogText = (rawText: string) => { | ||
return rawText.trim(); | ||
}; | ||
|
||
export const LogViewer = ({ | ||
progressPercent, | ||
logLines: propsLogLines, | ||
|
@@ -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); | ||
} | ||
} | ||
}); | ||
}, []); | ||
Comment on lines
+58
to
+85
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This effect should have an unsubscribe return so that the listener is not hanging around when this component is unmounted |
||
|
||
useEffect(() => { | ||
throttledSetLogLines(propsLogLines); | ||
}, [propsLogLines, throttledSetLogLines]); | ||
|
@@ -54,9 +111,158 @@ export const LogViewer = ({ | |
.join("\n"); | ||
}; | ||
|
||
useEffect(() => { | ||
if (search) findMatches(search); | ||
}, [search?.searchTerm, logLines]); | ||
Comment on lines
+114
to
+116
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Also I generally try to structure components in roughly this order:
I think this makes the code more readable, and less surprising (ie reading top to bottom at this point where did |
||
|
||
const updateSearchTerm = (rawText: string) => { | ||
setCurrentSearchIndex(undefined); | ||
const searchTerm = normalizeLogText(rawText); | ||
const logLineSearch: LogLineSearch = { | ||
searchTerm: searchTerm, | ||
pattern: new RegExp(searchTerm, "gi"), // `i` is invariant case | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
}; | ||
setSearch(logLineSearch); | ||
}; | ||
const debouncedUpdateSearchTerm = debounce(updateSearchTerm, 100); | ||
const debouncedUpdateSearchTermCallback = useCallback(debouncedUpdateSearchTerm, []); | ||
Comment on lines
+118
to
+128
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
|
||
const hasSearchTerm = () => { | ||
if (!search) return false; | ||
return isDefined(search.searchTerm) && isNotEmpty(search.searchTerm); | ||
Comment on lines
+131
to
+132
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. (nit):
|
||
}; | ||
|
||
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 = () => { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. (very minor) wasn't immediately obvious to me this is a function - should be named something like |
||
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]); | ||
Comment on lines
+179
to
+183
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. this should not be a |
||
|
||
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) | ||
} | ||
> | ||
Comment on lines
+241
to
+249
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm not familiar with this component, but wondering if we can apply numeric constraint to it. |
||
<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"} | ||
|
@@ -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 | ||
|
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" | ||
] | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Just noticed this - lets not use
has-ansi
(or its dependencyansi-regex
) at all and just inline the code insrc/utils/index.ts
(and combine it withstripAnsi
):