fix scrolling and use chunked log download

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

View File

@ -9,7 +9,7 @@ import traceback
from datetime import datetime, timedelta
from functools import reduce
from io import StringIO
from typing import Any, Optional
from typing import Any, List, Optional, Tuple
import aiofiles
import requests
@ -461,7 +461,7 @@ def process_logs(
service: Optional[str] = None,
start: Optional[int] = None,
end: Optional[int] = None,
) -> list:
) -> Tuple[int, List[str]]:
log_lines = []
key_length = 0
date_end = 0
@ -498,7 +498,7 @@ def process_logs(
current_line = clean_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])
@ -536,7 +536,7 @@ async def logs(
buffer += line
# Process logs only when there are enough lines in the buffer
if "\n" in buffer:
processed_lines = process_logs(buffer, service)
_, processed_lines = process_logs(buffer, service)
buffer = ""
for processed_line in processed_lines:
yield f"{processed_line}\n"
@ -569,9 +569,9 @@ async def logs(
async with aiofiles.open(service_location, "r") as file:
contents = await file.read()
log_lines = process_logs(contents, service, start, end)
total_lines, log_lines = process_logs(contents, service, start, end)
return JSONResponse(
content={"totalLines": len(log_lines), "lines": log_lines},
content={"totalLines": total_lines, "lines": log_lines},
status_code=200,
)
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";
export type ScrollFollowProps = {
startFollowing?: boolean;
render: (renderProps: ScrollFollowRenderProps) => ReactNode;
onCustomScroll?: (
scrollTop: number,
scrollHeight: number,
clientHeight: number,
) => void;
};
export type ScrollFollowRenderProps = {
@ -15,12 +20,22 @@ export type ScrollFollowRenderProps = {
}) => void;
startFollowing: () => 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) {
const followRef = useRef(props.startFollowing || false);
const prevScrollTopRef = useRef<number | undefined>(undefined);
useEffect(() => {
prevScrollTopRef.current = undefined;
}, []);
const wrappedRender = useCallback(
(renderProps: ScrollFollowRenderProps) => {
@ -29,6 +44,17 @@ export default function EnhancedScrollFollow(props: ScrollFollowProps) {
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;
@ -41,24 +67,20 @@ export default function EnhancedScrollFollow(props: ScrollFollowProps) {
followRef.current = false;
}
prevScrollTopRef.current = args.scrollTop;
renderProps.onScroll(args);
};
const wrappedStartFollowing = () => {
renderProps.startFollowing();
followRef.current = true;
};
const wrappedStopFollowing = () => {
renderProps.stopFollowing();
followRef.current = false;
if (props.onCustomScroll) {
props.onCustomScroll(
args.scrollTop,
args.scrollHeight,
args.clientHeight,
);
}
};
return props.render({
...renderProps,
onScroll: wrappedOnScroll,
startFollowing: wrappedStartFollowing,
stopFollowing: wrappedStopFollowing,
follow: followRef.current,
});
},

View File

@ -2,7 +2,7 @@ import { Button } from "@/components/ui/button";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { LogLine, LogSeverity, LogType, logTypes } from "@/types/log";
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 LogInfoDialog from "@/components/overlay/LogInfoDialog";
import { LogChip } from "@/components/indicators/Chip";
@ -24,6 +24,7 @@ import {
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { debounce } from "lodash";
function Logs() {
const [logService, setLogService] = useState<LogType>("frigate");
@ -34,6 +35,7 @@ function Logs() {
const [selectedLog, setSelectedLog] = useState<LogLine>();
const lazyLogRef = useRef<LazyLog>(null);
const [isLoading, setIsLoading] = useState(true);
const lastFetchedIndexRef = useRef(-1);
useEffect(() => {
document.title = `${logService[0].toUpperCase()}${logService.substring(1)} Logs - Frigate`;
@ -53,6 +55,17 @@ function Logs() {
}
}, [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
const filterLines = useCallback(
@ -69,10 +82,38 @@ function Logs() {
// 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 () => {
setIsLoading(true);
try {
const response = await axios.get(`logs/${logService}`);
const response = await axios.get(`logs/${logService}`, {
params: { start: -100 },
});
if (
response.status === 200 &&
response.data &&
@ -80,6 +121,8 @@ function Logs() {
) {
const filteredLines = filterLines(response.data.lines);
setLogs(filteredLines);
lastFetchedIndexRef.current =
response.data.totalLines - filteredLines.length;
}
} catch (error) {
const errorMessage =
@ -92,7 +135,6 @@ function Logs() {
}
}, [logService, filterLines]);
const [isStreaming, setIsStreaming] = useState(false);
const abortControllerRef = useRef<AbortController | null>(null);
const fetchLogsStream = useCallback(() => {
@ -157,7 +199,6 @@ function Logs() {
? error.message
: "An unknown error occurred";
toast.error(`Error while streaming logs: ${errorMessage}`);
setIsStreaming(false);
}
});
}, [logService, filterSeverity]);
@ -165,20 +206,56 @@ function Logs() {
useEffect(() => {
setIsLoading(true);
setLogs([]);
lastFetchedIndexRef.current = -1;
fetchInitialLogs().then(() => {
// Start streaming after initial load
setIsStreaming(true);
fetchLogsStream();
});
return () => {
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
// 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(() => {
if (logs.length) {
fetchInitialLogs()
@ -444,18 +521,6 @@ function Logs() {
)}
>
<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>
@ -464,21 +529,37 @@ function Logs() {
<ActivityIndicator className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" />
) : (
<EnhancedScrollFollow
startFollowing={true}
startFollowing={!isLoading}
onCustomScroll={handleScroll}
render={({ follow, onScroll }) => (
<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 />}
/>
<>
{follow && (
<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>
Logs are streaming from the server
</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 />}
loading={isLoading}
/>
</>
)}
/>
)}
@ -506,7 +587,7 @@ function LogLineData({
return (
<div
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,
"*:text-xs",
)}