fix scrolling and use chunked log download

This commit is contained in:
Josh Hawkins 2025-01-31 07:41:07 -06:00
parent 811987ac6f
commit 75da1f8452
3 changed files with 157 additions and 54 deletions

View File

@ -8,7 +8,7 @@ import os
import traceback import traceback
from datetime import datetime, timedelta from datetime import datetime, timedelta
from functools import reduce from functools import reduce
from typing import Any, Optional from typing import Any, List, Optional, Tuple
import aiofiles import aiofiles
import requests import requests
@ -399,7 +399,7 @@ def process_logs(
service: Optional[str] = None, service: Optional[str] = None,
start: Optional[int] = None, start: Optional[int] = None,
end: Optional[int] = None, end: Optional[int] = None,
) -> list: ) -> Tuple[int, List[str]]:
log_lines = [] log_lines = []
key_length = 0 key_length = 0
date_end = 0 date_end = 0
@ -436,7 +436,7 @@ def process_logs(
current_line = clean_line current_line = clean_line
log_lines.append(current_line) log_lines.append(current_line)
return log_lines[start:end] return len(log_lines), log_lines[start:end]
@router.get("/logs/{service}", tags=[Tags.logs]) @router.get("/logs/{service}", tags=[Tags.logs])
@ -474,7 +474,7 @@ async def logs(
buffer += line buffer += line
# Process logs only when there are enough lines in the buffer # Process logs only when there are enough lines in the buffer
if "\n" in buffer: if "\n" in buffer:
processed_lines = process_logs(buffer, service) _, processed_lines = process_logs(buffer, service)
buffer = "" buffer = ""
for processed_line in processed_lines: for processed_line in processed_lines:
yield f"{processed_line}\n" yield f"{processed_line}\n"
@ -507,9 +507,9 @@ async def logs(
async with aiofiles.open(service_location, "r") as file: async with aiofiles.open(service_location, "r") as file:
contents = await file.read() contents = await file.read()
log_lines = process_logs(contents, service, start, end) total_lines, log_lines = process_logs(contents, service, start, end)
return JSONResponse( return JSONResponse(
content={"totalLines": len(log_lines), "lines": log_lines}, content={"totalLines": total_lines, "lines": log_lines},
status_code=200, status_code=200,
) )
except FileNotFoundError as e: except FileNotFoundError as e:

View File

@ -1,9 +1,14 @@
import { useRef, useCallback, ReactNode } from "react"; import { useRef, useCallback, useEffect, type ReactNode } from "react";
import { ScrollFollow } from "@melloware/react-logviewer"; import { ScrollFollow } from "@melloware/react-logviewer";
export type ScrollFollowProps = { export type ScrollFollowProps = {
startFollowing?: boolean; startFollowing?: boolean;
render: (renderProps: ScrollFollowRenderProps) => ReactNode; render: (renderProps: ScrollFollowRenderProps) => ReactNode;
onCustomScroll?: (
scrollTop: number,
scrollHeight: number,
clientHeight: number,
) => void;
}; };
export type ScrollFollowRenderProps = { export type ScrollFollowRenderProps = {
@ -15,12 +20,22 @@ export type ScrollFollowRenderProps = {
}) => void; }) => void;
startFollowing: () => void; startFollowing: () => void;
stopFollowing: () => void; stopFollowing: () => void;
onCustomScroll?: (
scrollTop: number,
scrollHeight: number,
clientHeight: number,
) => void;
}; };
const SCROLL_BUFFER = 5; // Additional buffer for scroll checks const SCROLL_BUFFER = 5;
export default function EnhancedScrollFollow(props: ScrollFollowProps) { export default function EnhancedScrollFollow(props: ScrollFollowProps) {
const followRef = useRef(props.startFollowing || false); const followRef = useRef(props.startFollowing || false);
const prevScrollTopRef = useRef<number | undefined>(undefined);
useEffect(() => {
prevScrollTopRef.current = undefined;
}, []);
const wrappedRender = useCallback( const wrappedRender = useCallback(
(renderProps: ScrollFollowRenderProps) => { (renderProps: ScrollFollowRenderProps) => {
@ -29,6 +44,17 @@ export default function EnhancedScrollFollow(props: ScrollFollowProps) {
scrollHeight: number; scrollHeight: number;
clientHeight: 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 = const bottomThreshold =
args.scrollHeight - args.clientHeight - SCROLL_BUFFER; args.scrollHeight - args.clientHeight - SCROLL_BUFFER;
const isNearBottom = args.scrollTop >= bottomThreshold; const isNearBottom = args.scrollTop >= bottomThreshold;
@ -41,24 +67,20 @@ export default function EnhancedScrollFollow(props: ScrollFollowProps) {
followRef.current = false; followRef.current = false;
} }
prevScrollTopRef.current = args.scrollTop;
renderProps.onScroll(args); renderProps.onScroll(args);
}; if (props.onCustomScroll) {
props.onCustomScroll(
const wrappedStartFollowing = () => { args.scrollTop,
renderProps.startFollowing(); args.scrollHeight,
followRef.current = true; args.clientHeight,
}; );
}
const wrappedStopFollowing = () => {
renderProps.stopFollowing();
followRef.current = false;
}; };
return props.render({ return props.render({
...renderProps, ...renderProps,
onScroll: wrappedOnScroll, onScroll: wrappedOnScroll,
startFollowing: wrappedStartFollowing,
stopFollowing: wrappedStopFollowing,
follow: followRef.current, follow: followRef.current,
}); });
}, },

View File

@ -2,7 +2,7 @@ import { Button } from "@/components/ui/button";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { LogLine, LogSeverity, LogType, logTypes } from "@/types/log"; import { LogLine, LogSeverity, LogType, logTypes } from "@/types/log";
import copy from "copy-to-clipboard"; import copy from "copy-to-clipboard";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import axios from "axios"; import axios from "axios";
import LogInfoDialog from "@/components/overlay/LogInfoDialog"; import LogInfoDialog from "@/components/overlay/LogInfoDialog";
import { LogChip } from "@/components/indicators/Chip"; import { LogChip } from "@/components/indicators/Chip";
@ -24,6 +24,7 @@ import {
TooltipContent, TooltipContent,
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { debounce } from "lodash";
function Logs() { function Logs() {
const [logService, setLogService] = useState<LogType>("frigate"); const [logService, setLogService] = useState<LogType>("frigate");
@ -34,6 +35,7 @@ function Logs() {
const [selectedLog, setSelectedLog] = useState<LogLine>(); const [selectedLog, setSelectedLog] = useState<LogLine>();
const lazyLogRef = useRef<LazyLog>(null); const lazyLogRef = useRef<LazyLog>(null);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const lastFetchedIndexRef = useRef(-1);
useEffect(() => { useEffect(() => {
document.title = `${logService[0].toUpperCase()}${logService.substring(1)} Logs - Frigate`; document.title = `${logService[0].toUpperCase()}${logService.substring(1)} Logs - Frigate`;
@ -53,6 +55,17 @@ function Logs() {
} }
}, [tabsRef, logService]); }, [tabsRef, logService]);
// scrolling data
const pageSize = useMemo(() => {
const startIndex =
lazyLogRef.current?.listRef.current?.findStartIndex() ?? 0;
const endIndex = lazyLogRef.current?.listRef.current?.findEndIndex() ?? 0;
return endIndex - startIndex;
// recalculate page size when new logs load too
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [lazyLogRef, logs]);
// filter // filter
const filterLines = useCallback( const filterLines = useCallback(
@ -69,10 +82,38 @@ function Logs() {
// fetchers // fetchers
const fetchLogRange = useCallback(
async (start: number, end: number) => {
try {
const response = await axios.get(`logs/${logService}`, {
params: { start, end },
});
if (
response.status === 200 &&
response.data &&
Array.isArray(response.data.lines)
) {
const filteredLines = filterLines(response.data.lines);
return filteredLines;
}
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : "An unknown error occurred";
toast.error(`Error fetching logs: ${errorMessage}`, {
position: "top-center",
});
}
return [];
},
[logService, filterLines],
);
const fetchInitialLogs = useCallback(async () => { const fetchInitialLogs = useCallback(async () => {
setIsLoading(true); setIsLoading(true);
try { try {
const response = await axios.get(`logs/${logService}`); const response = await axios.get(`logs/${logService}`, {
params: { start: -100 },
});
if ( if (
response.status === 200 && response.status === 200 &&
response.data && response.data &&
@ -80,6 +121,8 @@ function Logs() {
) { ) {
const filteredLines = filterLines(response.data.lines); const filteredLines = filterLines(response.data.lines);
setLogs(filteredLines); setLogs(filteredLines);
lastFetchedIndexRef.current =
response.data.totalLines - filteredLines.length;
} }
} catch (error) { } catch (error) {
const errorMessage = const errorMessage =
@ -92,7 +135,6 @@ function Logs() {
} }
}, [logService, filterLines]); }, [logService, filterLines]);
const [isStreaming, setIsStreaming] = useState(false);
const abortControllerRef = useRef<AbortController | null>(null); const abortControllerRef = useRef<AbortController | null>(null);
const fetchLogsStream = useCallback(() => { const fetchLogsStream = useCallback(() => {
@ -157,7 +199,6 @@ function Logs() {
? error.message ? error.message
: "An unknown error occurred"; : "An unknown error occurred";
toast.error(`Error while streaming logs: ${errorMessage}`); toast.error(`Error while streaming logs: ${errorMessage}`);
setIsStreaming(false);
} }
}); });
}, [logService, filterSeverity]); }, [logService, filterSeverity]);
@ -165,20 +206,56 @@ function Logs() {
useEffect(() => { useEffect(() => {
setIsLoading(true); setIsLoading(true);
setLogs([]); setLogs([]);
lastFetchedIndexRef.current = -1;
fetchInitialLogs().then(() => { fetchInitialLogs().then(() => {
// Start streaming after initial load // Start streaming after initial load
setIsStreaming(true);
fetchLogsStream(); fetchLogsStream();
}); });
return () => { return () => {
abortControllerRef.current?.abort(); abortControllerRef.current?.abort();
setIsStreaming(false);
}; };
}, [fetchInitialLogs, fetchLogsStream]); // we know that these deps are correct
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [logService, filterSeverity]);
// handlers // handlers
// debounced 200ms
const handleScroll = useMemo(
() =>
debounce((scrollTop: number) => {
const scrollThreshold = 0;
if (
scrollTop <= scrollThreshold &&
lastFetchedIndexRef.current > 0 &&
!isLoading
) {
const savedStart =
lazyLogRef.current?.listRef.current?.findStartIndex() ?? 0;
const savedEnd =
lazyLogRef.current?.listRef.current?.findEndIndex() ?? 0;
const nextEnd = lastFetchedIndexRef.current;
const nextStart = Math.max(0, nextEnd - (pageSize || 100));
setIsLoading(true);
fetchLogRange(nextStart, nextEnd).then((newLines) => {
if (newLines.length > 0) {
setLogs((prev) => [...newLines, ...prev]);
lastFetchedIndexRef.current = nextStart;
lazyLogRef.current?.listRef.current?.scrollTo(
savedEnd + (pageSize - savedStart),
);
}
});
setIsLoading(false);
}
}, 200),
[fetchLogRange, isLoading, pageSize],
);
const handleCopyLogs = useCallback(() => { const handleCopyLogs = useCallback(() => {
if (logs.length) { if (logs.length) {
fetchInitialLogs() fetchInitialLogs()
@ -444,18 +521,6 @@ function Logs() {
)} )}
> >
<div className="flex flex-1">Message</div> <div className="flex flex-1">Message</div>
{isStreaming && (
<div className="flex flex-row justify-end">
<Tooltip>
<TooltipTrigger>
<MdCircle className="mr-2 size-2 animate-pulse cursor-default text-selected shadow-selected drop-shadow-md" />
</TooltipTrigger>
<TooltipContent>
Logs are streaming from the server
</TooltipContent>
</Tooltip>
</div>
)}
</div> </div>
</div> </div>
@ -464,21 +529,37 @@ function Logs() {
<ActivityIndicator className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" /> <ActivityIndicator className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" />
) : ( ) : (
<EnhancedScrollFollow <EnhancedScrollFollow
startFollowing={true} startFollowing={!isLoading}
onCustomScroll={handleScroll}
render={({ follow, onScroll }) => ( render={({ follow, onScroll }) => (
<LazyLog <>
ref={lazyLogRef} {follow && (
enableLineNumbers={false} <div className="absolute right-1 top-3">
selectableLines <Tooltip>
lineClassName="text-primary bg-background" <TooltipTrigger>
highlightLineClassName="bg-primary/20" <MdCircle className="mr-2 size-2 animate-pulse cursor-default text-selected shadow-selected drop-shadow-md" />
onRowClick={handleRowClick} </TooltipTrigger>
formatPart={formatPart} <TooltipContent>
text={logs.join("\n")} Logs are streaming from the server
follow={follow} </TooltipContent>
onScroll={onScroll} </Tooltip>
loadingComponent={<ActivityIndicator />} </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 />}
loading={isLoading}
/>
</>
)} )}
/> />
)} )}
@ -506,7 +587,7 @@ function LogLineData({
return ( return (
<div <div
className={cn( className={cn(
"grid w-full cursor-pointer grid-cols-5 gap-2 border-t border-secondary py-2 hover:bg-muted md:grid-cols-12 md:py-0", "grid w-full cursor-pointer grid-cols-5 gap-2 border-t border-secondary bg-background_alt py-2 hover:bg-muted md:grid-cols-12 md:py-0",
className, className,
"*:text-xs", "*:text-xs",
)} )}