mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-09-24 18:26:51 +03:00
Replace react-logviewer with virtua (#24334)
* bump react-logviewer to 6.5.5 * use virtua for UI logs
This commit is contained in:
Generated
+4
-44
@@ -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",
|
||||
|
||||
+1
-1
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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<number | undefined>(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 <ScrollFollow {...props} render={wrappedRender} />;
|
||||
}
|
||||
@@ -184,8 +184,3 @@ html {
|
||||
.react-grid-layout .react-grid-item {
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
.react-lazylog,
|
||||
.react-lazylog-searchbar {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
+198
-166
@@ -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<LogType>("frigate");
|
||||
const isWebsocket = logService === "websocket";
|
||||
const tabsRef = useRef<HTMLDivElement | null>(null);
|
||||
const lazyLogWrapperRef = useRef<HTMLDivElement>(null);
|
||||
const [logs, setLogs] = useState<string[]>([]);
|
||||
const logWrapperRef = useRef<HTMLDivElement>(null);
|
||||
const listRef = useRef<VListHandle>(null);
|
||||
const [logState, setLogState] = useState<LogState>({
|
||||
entries: [],
|
||||
shift: false,
|
||||
stick: true,
|
||||
});
|
||||
const [filterSeverity, setFilterSeverity] = useState<LogSeverity[]>();
|
||||
const [selectedLog, setSelectedLog] = useState<LogLine>();
|
||||
const lazyLogRef = useRef<LazyLog>(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<AbortController | null>(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<string>("");
|
||||
|
||||
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 (
|
||||
<LogLineData
|
||||
line={parsedLine}
|
||||
logService={logService}
|
||||
onClickSeverity={() => 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() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ref={lazyLogWrapperRef} className="size-full">
|
||||
<div
|
||||
ref={logWrapperRef}
|
||||
className="min-h-0 flex-1"
|
||||
onPointerDown={() => {
|
||||
scrolledUpRef.current = false;
|
||||
}}
|
||||
>
|
||||
{isLoading ? (
|
||||
<ActivityIndicator className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" />
|
||||
) : (
|
||||
<EnhancedScrollFollow
|
||||
startFollowing={!isLoading}
|
||||
onCustomScroll={handleScroll}
|
||||
render={({ follow, onScroll }) => (
|
||||
<>
|
||||
{follow && !logSettings.disableStreaming && (
|
||||
<div className="absolute right-1 top-3">
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<MdCircle className="mr-2 size-2 animate-pulse cursor-default text-selected shadow-selected drop-shadow-md" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("logs.tips")}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
<LazyLog
|
||||
ref={lazyLogRef}
|
||||
enableLineNumbers={false}
|
||||
selectableLines
|
||||
lineClassName="text-primary bg-background"
|
||||
highlightLineClassName="bg-primary/20"
|
||||
onRowClick={handleRowClick}
|
||||
formatPart={formatPart}
|
||||
text={logs.join("\n")}
|
||||
follow={follow}
|
||||
onScroll={onScroll}
|
||||
loadingComponent={
|
||||
<ActivityIndicator className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" />
|
||||
}
|
||||
loading={isLoading}
|
||||
/>
|
||||
</>
|
||||
<>
|
||||
{follow && !logSettings.disableStreaming && (
|
||||
<div className="absolute right-1 top-3">
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<MdCircle className="mr-2 size-2 animate-pulse cursor-default text-selected shadow-selected drop-shadow-md" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("logs.tips")}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<VList
|
||||
ref={listRef}
|
||||
itemSize={isDesktop ? ROW_HEIGHT_HINT_PX : undefined}
|
||||
data={logState.entries}
|
||||
shift={logState.shift}
|
||||
onScroll={syncScrollState}
|
||||
onWheel={(e) => {
|
||||
if (!e.deltaY) return;
|
||||
|
||||
scrolledUpRef.current = e.deltaY < 0;
|
||||
syncScrollState();
|
||||
}}
|
||||
>
|
||||
{(entry) => {
|
||||
const line = parseLogLines(logService, [entry.text])[0];
|
||||
return (
|
||||
<LogLineData
|
||||
key={entry.id}
|
||||
line={line}
|
||||
logService={logService}
|
||||
onClickSeverity={() =>
|
||||
setFilterSeverity([line.severity])
|
||||
}
|
||||
onSelect={() => setSelectedLog(line)}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
</VList>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user