mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-02-18 17:14:26 +03:00
fix scrolling and use chunked log download
This commit is contained in:
parent
811987ac6f
commit
75da1f8452
@ -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:
|
||||||
|
|||||||
@ -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,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|||||||
@ -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",
|
||||||
)}
|
)}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user