diff --git a/web/package-lock.json b/web/package-lock.json index d907619875..9dfea7caa7 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -11,7 +11,6 @@ "dependencies": { "@cycjimmy/jsmpeg-player": "^6.1.2", "@hookform/resolvers": "^3.10.0", - "@melloware/react-logviewer": "^6.1.2", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-aspect-ratio": "^1.1.15", "@radix-ui/react-checkbox": "^1.1.4", @@ -84,6 +83,7 @@ "tailwindcss-animate": "^1.0.7", "use-long-press": "^3.2.0", "vaul": "^1.1.2", + "virtua": "^0.49.3", "vite-plugin-monaco-editor": "^1.1.0", "zod": "^3.23.8" }, @@ -1649,22 +1649,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@melloware/react-logviewer": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/@melloware/react-logviewer/-/react-logviewer-6.1.2.tgz", - "integrity": "sha512-WDw3VIGqhoXxDn93HFDicwRhi4+FQyaKiVTB07bWerT82gTgyWV7bOciVV33z25N3WJrz62j5FKVzvFZCu17/A==", - "license": "MPL-2.0", - "dependencies": { - "hotkeys-js": "3.13.9", - "mitt": "3.0.1", - "react-string-replace": "1.1.1", - "virtua": "0.39.3" - }, - "peerDependencies": { - "react": ">=17.0.0", - "react-dom": ">=17.0.0" - } - }, "node_modules/@mswjs/interceptors": { "version": "0.37.6", "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.37.6.tgz", @@ -8770,15 +8754,6 @@ "dev": true, "license": "MIT" }, - "node_modules/hotkeys-js": { - "version": "3.13.9", - "resolved": "https://registry.npmjs.org/hotkeys-js/-/hotkeys-js-3.13.9.tgz", - "integrity": "sha512-3TRCj9u9KUH6cKo25w4KIdBfdBfNRjfUwrljCLDC2XhmPDG0SjAZFcFZekpUZFmXzfYoGhFDcdx2gX/vUVtztQ==", - "license": "MIT", - "funding": { - "url": "https://jaywcjlove.github.io/#/sponsor" - } - }, "node_modules/html-encoding-sniffer": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", @@ -11126,12 +11101,6 @@ "node": ">=16 || 14 >=14.17" } }, - "node_modules/mitt": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", - "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", - "license": "MIT" - }, "node_modules/mock-socket": { "version": "9.3.1", "resolved": "https://registry.npmjs.org/mock-socket/-/mock-socket-9.3.1.tgz", @@ -12577,15 +12546,6 @@ "react-dom": ">=16.8" } }, - "node_modules/react-string-replace": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/react-string-replace/-/react-string-replace-1.1.1.tgz", - "integrity": "sha512-26TUbLzLfHQ5jO5N7y3Mx88eeKo0Ml0UjCQuX4BMfOd/JX+enQqlKpL1CZnmjeBRvQE8TR+ds9j1rqx9CxhKHQ==", - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } - }, "node_modules/react-style-singleton": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", @@ -13881,9 +13841,9 @@ } }, "node_modules/virtua": { - "version": "0.39.3", - "resolved": "https://registry.npmjs.org/virtua/-/virtua-0.39.3.tgz", - "integrity": "sha512-Ep3aiJXSGPm1UUniThr5mGDfG0upAleP7pqQs5mvvCgM1wPhII1ZKa7eNCWAJRLkC+InpXKokKozyaaj/aMYOQ==", + "version": "0.49.3", + "resolved": "https://registry.npmjs.org/virtua/-/virtua-0.49.3.tgz", + "integrity": "sha512-k1Yn988Vz/L40uDtEWPjfdVo15Suumh4tU4/z5Srs0elNcU9DgBskqdh3llpHyAlXrCeHWTfcclYvY1uU4MmIg==", "license": "MIT", "peerDependencies": { "react": ">=16.14.0", diff --git a/web/package.json b/web/package.json index 1b2e11a530..e4218927f7 100644 --- a/web/package.json +++ b/web/package.json @@ -25,7 +25,6 @@ "dependencies": { "@cycjimmy/jsmpeg-player": "^6.1.2", "@hookform/resolvers": "^3.10.0", - "@melloware/react-logviewer": "^6.1.2", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-aspect-ratio": "^1.1.15", "@radix-ui/react-checkbox": "^1.1.4", @@ -98,6 +97,7 @@ "tailwindcss-animate": "^1.0.7", "use-long-press": "^3.2.0", "vaul": "^1.1.2", + "virtua": "^0.49.3", "vite-plugin-monaco-editor": "^1.1.0", "zod": "^3.23.8" }, diff --git a/web/src/components/dynamic/EnhancedScrollFollow.tsx b/web/src/components/dynamic/EnhancedScrollFollow.tsx deleted file mode 100644 index 35673c80ec..0000000000 --- a/web/src/components/dynamic/EnhancedScrollFollow.tsx +++ /dev/null @@ -1,91 +0,0 @@ -import { useRef, useCallback, useEffect, type ReactNode } from "react"; -import { ScrollFollow } from "@melloware/react-logviewer"; - -export type ScrollFollowProps = { - startFollowing?: boolean; - render: (renderProps: ScrollFollowRenderProps) => ReactNode; - onCustomScroll?: ( - scrollTop: number, - scrollHeight: number, - clientHeight: number, - ) => void; -}; - -export type ScrollFollowRenderProps = { - follow: boolean; - onScroll: (args: { - scrollTop: number; - scrollHeight: number; - clientHeight: number; - }) => void; - startFollowing: () => void; - stopFollowing: () => void; - onCustomScroll?: ( - scrollTop: number, - scrollHeight: number, - clientHeight: number, - ) => void; -}; - -const SCROLL_BUFFER = 5; - -export default function EnhancedScrollFollow(props: ScrollFollowProps) { - const followRef = useRef(props.startFollowing || false); - const prevScrollTopRef = useRef(undefined); - - useEffect(() => { - prevScrollTopRef.current = undefined; - }, []); - - const wrappedRender = useCallback( - (renderProps: ScrollFollowRenderProps) => { - const wrappedOnScroll = (args: { - scrollTop: number; - scrollHeight: number; - clientHeight: number; - }) => { - // Check if scrolling up and immediately stop following - if ( - prevScrollTopRef.current !== undefined && - args.scrollTop < prevScrollTopRef.current - ) { - if (followRef.current) { - renderProps.stopFollowing(); - followRef.current = false; - } - } - - const bottomThreshold = - args.scrollHeight - args.clientHeight - SCROLL_BUFFER; - const isNearBottom = args.scrollTop >= bottomThreshold; - - if (isNearBottom && !followRef.current) { - renderProps.startFollowing(); - followRef.current = true; - } else if (!isNearBottom && followRef.current) { - renderProps.stopFollowing(); - followRef.current = false; - } - - prevScrollTopRef.current = args.scrollTop; - renderProps.onScroll(args); - if (props.onCustomScroll) { - props.onCustomScroll( - args.scrollTop, - args.scrollHeight, - args.clientHeight, - ); - } - }; - - return props.render({ - ...renderProps, - onScroll: wrappedOnScroll, - follow: followRef.current, - }); - }, - [props], - ); - - return ; -} diff --git a/web/src/index.css b/web/src/index.css index 9a294ff7c9..ee57cce771 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -184,8 +184,3 @@ html { .react-grid-layout .react-grid-item { transition: none !important; } - -.react-lazylog, -.react-lazylog-searchbar { - background-color: transparent !important; -} diff --git a/web/src/pages/Logs.tsx b/web/src/pages/Logs.tsx index b01b4c712b..7167b61688 100644 --- a/web/src/pages/Logs.tsx +++ b/web/src/pages/Logs.tsx @@ -8,7 +8,13 @@ import { logTypes, } from "@/types/log"; import copy from "copy-to-clipboard"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, +} from "react"; import axios from "axios"; import LogInfoDialog from "@/components/overlay/LogInfoDialog"; import { LogChip } from "@/components/indicators/Chip"; @@ -21,34 +27,110 @@ import { cn } from "@/lib/utils"; import { parseLogLines } from "@/utils/logUtil"; import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"; import scrollIntoView from "scroll-into-view-if-needed"; -import { LazyLog } from "@melloware/react-logviewer"; +import { VList, type VListHandle } from "virtua"; import useKeyboardListener from "@/hooks/use-keyboard-listener"; -import EnhancedScrollFollow from "@/components/dynamic/EnhancedScrollFollow"; import { MdCircle } from "react-icons/md"; import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; -import { debounce } from "lodash"; -import { isIOS, isMobile } from "react-device-detect"; +import { isDesktop, isIOS, isMobile } from "react-device-detect"; import { isPWA } from "@/utils/isPWA"; import { isInIframe } from "@/utils/isIFrame"; import { useTranslation } from "react-i18next"; import WsMessageFeed from "@/components/ws/WsMessageFeed"; +const OLDER_LINES_CHUNK_SIZE = 100; +const FOLLOW_THRESHOLD_PX = 40; + +// Desktop row height. Without it, virtua guesses 40px and the first render +// leaves the viewport partly empty. Mobile rows are taller, and a low hint +// there shrinks the scroll room iOS gets while it defers scroll correction +const ROW_HEIGHT_HINT_PX = 29; + +// Stable ids keep row measurements attached to the right line after a prepend +type LogEntry = { id: number; text: string }; + +// shift anchors the viewport to the end when lines are prepended. stick keeps +// the newest line in view when lines are appended +type LogState = { entries: LogEntry[]; shift: boolean; stick: boolean }; + function Logs() { const { t } = useTranslation(["views/system"]); const [logService, setLogService] = useState("frigate"); const isWebsocket = logService === "websocket"; const tabsRef = useRef(null); - const lazyLogWrapperRef = useRef(null); - const [logs, setLogs] = useState([]); + const logWrapperRef = useRef(null); + const listRef = useRef(null); + const [logState, setLogState] = useState({ + entries: [], + shift: false, + stick: true, + }); const [filterSeverity, setFilterSeverity] = useState(); const [selectedLog, setSelectedLog] = useState(); - const lazyLogRef = useRef(null); const [isLoading, setIsLoading] = useState(true); + const [follow, setFollow] = useState(true); const lastFetchedIndexRef = useRef(-1); + const loadingOlderRef = useRef(false); + const firstIdRef = useRef(0); + const lastIdRef = useRef(0); + + // The last wheel or key scroll went up. The view can still sit at the + // bottom for a moment, so position alone would keep following + const scrolledUpRef = useRef(false); + + // lines + + const isFollowing = useCallback(() => { + const list = listRef.current; + if (!list || scrolledUpRef.current) return false; + + return ( + list.scrollSize - list.scrollOffset - list.viewportSize < + FOLLOW_THRESHOLD_PX + ); + }, []); + + const resetLines = useCallback((lines: string[]) => { + firstIdRef.current = 0; + lastIdRef.current = lines.length; + setLogState({ + entries: lines.map((text, id) => ({ id, text })), + shift: false, + stick: true, + }); + }, []); + + const appendLines = useCallback( + (lines: string[]) => { + const entries = lines + .filter((text) => text.trim()) + .map((text) => ({ id: lastIdRef.current++, text })); + if (!entries.length) return; + + const stick = isFollowing(); + setLogState((prev) => ({ + entries: [...prev.entries, ...entries], + shift: false, + stick, + })); + }, + [isFollowing], + ); + + const prependLines = useCallback((lines: string[]) => { + firstIdRef.current -= lines.length; + const firstId = firstIdRef.current; + const entries = lines.map((text, i) => ({ id: firstId + i, text })); + + setLogState((prev) => ({ + entries: [...entries, ...prev.entries], + shift: true, + stick: false, + })); + }, []); useEffect(() => { document.title = t("documentTitle.logs." + logService); @@ -102,8 +184,7 @@ function Logs() { response.data && Array.isArray(response.data.lines) ) { - const filteredLines = filterLines(response.data.lines); - return filteredLines; + return response.data.lines as string[]; } } catch (error) { const errorMessage = @@ -115,26 +196,27 @@ function Logs() { }, ); } - return []; + return null; }, - [logService, filterLines, t], + [logService, t], ); const fetchInitialLogs = useCallback(async () => { setIsLoading(true); try { const response = await axios.get(`logs/${logService}`, { - params: { start: filterSeverity ? 0 : -100 }, + params: { start: filterSeverity?.length ? 0 : -100 }, }); if ( response.status === 200 && response.data && Array.isArray(response.data.lines) ) { - const filteredLines = filterLines(response.data.lines); - setLogs(filteredLines); + resetLines(filterLines(response.data.lines)); + + // A filtered load fetches the whole file, so nothing older remains lastFetchedIndexRef.current = - response.data.totalLines - filteredLines.length; + response.data.totalLines - response.data.lines.length; } } catch (error) { const errorMessage = @@ -145,7 +227,7 @@ function Logs() { } finally { setIsLoading(false); } - }, [logService, filterLines, filterSeverity, t]); + }, [logService, filterLines, filterSeverity, resetLines, t]); const abortControllerRef = useRef(null); @@ -180,9 +262,7 @@ function Logs() { return filterSeverity.includes(parsedLine.severity); }) : lines; - if (filteredLines.length > 0) { - lazyLogRef.current?.appendLines(filteredLines); - } + appendLines(filteredLines); } // Process next chunk return processStreamChunk(reader); @@ -215,17 +295,19 @@ function Logs() { ); } }); - }, [logService, filterSeverity, t]); + }, [logService, filterSeverity, appendLines, t]); useEffect(() => { if (isWebsocket) { setIsLoading(false); - setLogs([]); + resetLines([]); return; } setIsLoading(true); - setLogs([]); + setFollow(true); + scrolledUpRef.current = false; + resetLines([]); lastFetchedIndexRef.current = -1; fetchInitialLogs().then(() => { // Start streaming after initial load @@ -243,70 +325,52 @@ function Logs() { // handlers - const prependLines = useCallback((newLines: string[]) => { - if (!lazyLogRef.current) return; + const loadOlderLines = useCallback(async () => { + const end = lastFetchedIndexRef.current; + if (loadingOlderRef.current || end <= 0) return; - const newLinesArray = newLines.map( - (line) => new Uint8Array(new TextEncoder().encode(line + "\n")), - ); + loadingOlderRef.current = true; + const start = Math.max(0, end - OLDER_LINES_CHUNK_SIZE); + const lines = await fetchLogRange(start, end); + loadingOlderRef.current = false; - lazyLogRef.current.setState((prevState) => ({ - ...prevState, - lines: prevState.lines.unshift(...newLinesArray), - count: prevState.count + newLines.length, - })); - }, []); + // A service or filter change resets the index while the request is in flight + if (!lines || lastFetchedIndexRef.current !== end) return; - // debounced - const handleScroll = useMemo( - () => - debounce(() => { - const scrollThreshold = - lazyLogRef.current?.listRef.current?.findEndIndex() ?? 10; - const startIndex = - lazyLogRef.current?.listRef.current?.findStartIndex() ?? 0; - const endIndex = - lazyLogRef.current?.listRef.current?.findEndIndex() ?? 0; - const pageSize = endIndex - startIndex; - if ( - scrollThreshold < pageSize + pageSize / 2 && - lastFetchedIndexRef.current > 0 && - !isLoading - ) { - const nextEnd = lastFetchedIndexRef.current; - const nextStart = Math.max(0, nextEnd - (pageSize || 100)); - setIsLoading(true); + lastFetchedIndexRef.current = start; + prependLines(lines); + }, [fetchLogRange, prependLines]); - fetchLogRange(nextStart, nextEnd).then((newLines) => { - if (newLines.length > 0) { - prependLines(newLines); - lastFetchedIndexRef.current = nextStart; + // Runs on scroll events and on wheel or key input, because input at the top + // or bottom edge doesn't scroll and fires no scroll event + const syncScrollState = useCallback(() => { + const list = listRef.current; + if (!list) return; - lazyLogRef.current?.listRef.current?.scrollTo( - newLines.length * - lazyLogRef.current?.listRef.current?.getItemSize(1), - ); - } - }); + setFollow(isFollowing()); - setIsLoading(false); - } - }, 50), - [fetchLogRange, isLoading, prependLines], - ); + if (list.scrollOffset < list.viewportSize) { + loadOlderLines(); + } + }, [isFollowing, loadOlderLines]); + + useLayoutEffect(() => { + if (isLoading || !logState.stick || !logState.entries.length) return; + + listRef.current?.scrollToIndex(logState.entries.length - 1, { + align: "end", + }); + }, [isLoading, logState]); const handleCopyLogs = useCallback(() => { - if (logs.length) { - fetchInitialLogs() - .then(() => { - copy(logs.join("\n")); - toast.success(t("logs.copy.success")); - }) - .catch(() => { - toast.error(t("logs.copy.error")); - }); + if (!logState.entries.length) return; + + if (copy(logState.entries.map((entry) => entry.text).join("\n"))) { + toast.success(t("logs.copy.success")); + } else { + toast.error(t("logs.copy.error")); } - }, [logs, fetchInitialLogs, t]); + }, [logState, t]); const handleDownloadLogs = useCallback(() => { axios @@ -329,77 +393,34 @@ function Logs() { .catch(() => {}); }, [logService]); - const handleRowClick = useCallback( - (rowInfo: { lineNumber: number; rowIndex: number }) => { - const clickedLine = parseLogLines(logService, [ - logs[rowInfo.rowIndex], - ])[0]; - setSelectedLog(clickedLine); - }, - [logs, logService], - ); - // keyboard listener useKeyboardListener( ["PageDown", "PageUp", "ArrowDown", "ArrowUp"], (key, modifiers) => { - if (!key || !modifiers.down || !lazyLogWrapperRef.current) { + const list = listRef.current; + if (!key || !modifiers.down || !list) { return true; } - const container = - lazyLogWrapperRef.current.querySelector(".react-lazylog"); - - const logLineHeight = container?.querySelector(".log-line")?.clientHeight; - - if (!logLineHeight) { + const rowHeight = list.getItemSize(list.findItemIndex(list.scrollOffset)); + if (!rowHeight) { return true; } - const scrollAmount = key.includes("Page") - ? logLineHeight * 10 - : logLineHeight; + const rows = key.includes("Page") ? 10 : 1; const direction = key.includes("Down") ? 1 : -1; - container?.scrollBy({ top: scrollAmount * direction }); + scrolledUpRef.current = direction < 0; + list.scrollBy(rowHeight * rows * direction); + syncScrollState(); return true; }, ); - // format lines - - const lineBufferRef = useRef(""); - - const formatPart = useCallback( - (text: string) => { - lineBufferRef.current += text; - - if (text.endsWith("\n")) { - const completeLine = lineBufferRef.current.trim(); - lineBufferRef.current = ""; - - if (completeLine) { - const parsedLine = parseLogLines(logService, [completeLine])[0]; - return ( - setFilterSeverity([parsedLine.severity])} - onSelect={() => setSelectedLog(parsedLine)} - /> - ); - } - } - - return null; - }, - [logService, setFilterSeverity, setSelectedLog], - ); - useEffect(() => { const handleCopy = (e: ClipboardEvent) => { e.preventDefault(); - if (!lazyLogWrapperRef.current) return; + if (!logWrapperRef.current) return; const selection = window.getSelection(); if (!selection) return; @@ -467,7 +488,7 @@ function Logs() { e.clipboardData?.setData("text/plain", copyText); }; - const content = lazyLogWrapperRef.current; + const content = logWrapperRef.current; content?.addEventListener("copy", handleCopy); return () => { content?.removeEventListener("copy", handleCopy); @@ -488,7 +509,6 @@ function Logs() { value={logService} onValueChange={(value: LogType) => { if (value) { - setLogs([]); setFilterSeverity(undefined); setLogService(value); } @@ -582,44 +602,56 @@ function Logs() { -
+
{ + scrolledUpRef.current = false; + }} + > {isLoading ? ( ) : ( - ( - <> - {follow && !logSettings.disableStreaming && ( -
- - - - - {t("logs.tips")} - -
- )} - - } - loading={isLoading} - /> - + <> + {follow && !logSettings.disableStreaming && ( +
+ + + + + {t("logs.tips")} + +
)} - /> + { + if (!e.deltaY) return; + + scrolledUpRef.current = e.deltaY < 0; + syncScrollState(); + }} + > + {(entry) => { + const line = parseLogLines(logService, [entry.text])[0]; + return ( + + setFilterSeverity([line.severity]) + } + onSelect={() => setSelectedLog(line)} + /> + ); + }} + + )}