mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-02-19 01:17:06 +03:00
improve layout and scrolling behavior
This commit is contained in:
parent
1c32a1cf63
commit
3053a33c3d
@ -1,36 +1,85 @@
|
||||
import { Button } from "../ui/button";
|
||||
import { FaFilter } from "react-icons/fa";
|
||||
import { FaCog } from "react-icons/fa";
|
||||
import { isMobile } from "react-device-detect";
|
||||
import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
|
||||
import { LogSeverity } from "@/types/log";
|
||||
import { LogSettingsType, LogSeverity } from "@/types/log";
|
||||
import { Label } from "../ui/label";
|
||||
import { Switch } from "../ui/switch";
|
||||
import { DropdownMenuSeparator } from "../ui/dropdown-menu";
|
||||
import { cn } from "@/lib/utils";
|
||||
import FilterSwitch from "./FilterSwitch";
|
||||
|
||||
type LogLevelFilterButtonProps = {
|
||||
type LogSettingsButtonProps = {
|
||||
selectedLabels?: LogSeverity[];
|
||||
updateLabelFilter: (labels: LogSeverity[] | undefined) => void;
|
||||
logSettings?: LogSettingsType;
|
||||
setLogSettings: (logSettings: LogSettingsType) => void;
|
||||
};
|
||||
export function LogLevelFilterButton({
|
||||
export function LogSettingsButton({
|
||||
selectedLabels,
|
||||
updateLabelFilter,
|
||||
}: LogLevelFilterButtonProps) {
|
||||
logSettings,
|
||||
setLogSettings,
|
||||
}: LogSettingsButtonProps) {
|
||||
const trigger = (
|
||||
<Button
|
||||
size="sm"
|
||||
className="flex items-center gap-2"
|
||||
aria-label="Filter log level"
|
||||
>
|
||||
<FaFilter className="text-secondary-foreground" />
|
||||
<div className="hidden text-primary md:block">Filter</div>
|
||||
<FaCog className="text-secondary-foreground" />
|
||||
<div className="hidden text-primary md:block">Settings</div>
|
||||
</Button>
|
||||
);
|
||||
const content = (
|
||||
<div className={cn("my-3 space-y-3 py-3 md:mt-0 md:py-0")}>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-0.5">
|
||||
<div className="text-md">Filter</div>
|
||||
<div className="space-y-1 text-xs text-muted-foreground">
|
||||
Filter logs by severity.
|
||||
</div>
|
||||
</div>
|
||||
<GeneralFilterContent
|
||||
selectedLabels={selectedLabels}
|
||||
updateLabelFilter={updateLabelFilter}
|
||||
/>
|
||||
</div>
|
||||
<DropdownMenuSeparator />
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-0.5">
|
||||
<div className="text-md">Loading</div>
|
||||
<div className="mt-2.5 flex flex-col gap-2.5">
|
||||
<div className="space-y-1 text-xs text-muted-foreground">
|
||||
By default, logs are loaded in chunks on scroll to save bandwidth,
|
||||
and when the log pane is scrolled to the bottom, new logs
|
||||
automatically stream as they are added.
|
||||
</div>
|
||||
<FilterSwitch
|
||||
label="Always load full log"
|
||||
isChecked={logSettings?.alwaysLoadFull ?? false}
|
||||
onCheckedChange={(isChecked) => {
|
||||
setLogSettings({
|
||||
alwaysLoadFull: isChecked,
|
||||
disableStreaming: logSettings?.disableStreaming ?? false,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<FilterSwitch
|
||||
label="Disable log streaming"
|
||||
isChecked={logSettings?.disableStreaming ?? false}
|
||||
onCheckedChange={(isChecked) => {
|
||||
setLogSettings({
|
||||
alwaysLoadFull: logSettings?.alwaysLoadFull ?? false,
|
||||
disableStreaming: isChecked,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (isMobile) {
|
||||
@ -63,7 +112,7 @@ export function GeneralFilterContent({
|
||||
return (
|
||||
<>
|
||||
<div className="scrollbar-container h-auto overflow-y-auto overflow-x-hidden">
|
||||
<div className="my-2.5 flex items-center justify-between">
|
||||
<div className="mb-5 flex items-center justify-between">
|
||||
<Label
|
||||
className="mx-2 cursor-pointer text-primary"
|
||||
htmlFor="allLabels"
|
||||
@ -81,7 +130,6 @@ export function GeneralFilterContent({
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<DropdownMenuSeparator />
|
||||
<div className="my-2.5 flex flex-col gap-2.5">
|
||||
{["debug", "info", "warning", "error"].map((item) => (
|
||||
<div className="flex items-center justify-between" key={item}>
|
||||
@ -70,7 +70,8 @@ export function LogChip({ severity, onClickSeverity }: LogChipProps) {
|
||||
}, [severity]);
|
||||
|
||||
return (
|
||||
<div
|
||||
<div className="min-w-16 lg:min-w-20">
|
||||
<span
|
||||
className={`rounded-md px-1 py-[1px] text-xs capitalize ${onClickSeverity ? "cursor-pointer" : ""} ${severityClassName}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
@ -81,6 +82,7 @@ export function LogChip({ severity, onClickSeverity }: LogChipProps) {
|
||||
}}
|
||||
>
|
||||
{severity}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,12 +1,18 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||
import { LogLine, LogSeverity, LogType, logTypes } from "@/types/log";
|
||||
import {
|
||||
LogLine,
|
||||
LogSettingsType,
|
||||
LogSeverity,
|
||||
LogType,
|
||||
logTypes,
|
||||
} from "@/types/log";
|
||||
import copy from "copy-to-clipboard";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import axios from "axios";
|
||||
import LogInfoDialog from "@/components/overlay/LogInfoDialog";
|
||||
import { LogChip } from "@/components/indicators/Chip";
|
||||
import { LogLevelFilterButton } from "@/components/filter/LogLevelFilter";
|
||||
import { LogSettingsButton } from "@/components/filter/LogSettingsButton";
|
||||
import { FaCopy, FaDownload } from "react-icons/fa";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { toast } from "sonner";
|
||||
@ -25,6 +31,7 @@ import {
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { debounce } from "lodash";
|
||||
import { usePersistence } from "@/hooks/use-persistence";
|
||||
|
||||
function Logs() {
|
||||
const [logService, setLogService] = useState<LogType>("frigate");
|
||||
@ -55,16 +62,17 @@ function Logs() {
|
||||
}
|
||||
}, [tabsRef, logService]);
|
||||
|
||||
// scrolling data
|
||||
// log settings
|
||||
|
||||
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]);
|
||||
// const [logSettings, setLogSettings] = usePersistence<LogSettingsType>(
|
||||
// "logSettings",
|
||||
// { alwaysLoadFull: false, disableStreaming: false },
|
||||
// );
|
||||
|
||||
const [logSettings, setLogSettings] = useState<LogSettingsType>({
|
||||
alwaysLoadFull: false,
|
||||
disableStreaming: false,
|
||||
});
|
||||
|
||||
// filter
|
||||
|
||||
@ -112,7 +120,7 @@ function Logs() {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await axios.get(`logs/${logService}`, {
|
||||
params: { start: -100 },
|
||||
params: { start: logSettings.alwaysLoadFull ? 0 : -100 },
|
||||
});
|
||||
if (
|
||||
response.status === 200 &&
|
||||
@ -133,7 +141,7 @@ function Logs() {
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [logService, filterLines]);
|
||||
}, [logService, filterLines, logSettings]);
|
||||
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
@ -209,7 +217,9 @@ function Logs() {
|
||||
lastFetchedIndexRef.current = -1;
|
||||
fetchInitialLogs().then(() => {
|
||||
// Start streaming after initial load
|
||||
if (!logSettings.disableStreaming) {
|
||||
fetchLogsStream();
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
@ -221,39 +231,56 @@ function Logs() {
|
||||
|
||||
// handlers
|
||||
|
||||
// debounced 200ms
|
||||
const prependLines = useCallback((newLines: string[]) => {
|
||||
if (!lazyLogRef.current) return;
|
||||
|
||||
const newLinesArray = newLines.map(
|
||||
(line) => new Uint8Array(new TextEncoder().encode(line + "\n")),
|
||||
);
|
||||
|
||||
lazyLogRef.current.setState((prevState) => ({
|
||||
...prevState,
|
||||
lines: prevState.lines.unshift(...newLinesArray),
|
||||
count: prevState.count + newLines.length,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// debounced
|
||||
const handleScroll = useMemo(
|
||||
() =>
|
||||
debounce((scrollTop: number) => {
|
||||
const scrollThreshold = 0;
|
||||
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 (
|
||||
scrollTop <= scrollThreshold &&
|
||||
scrollThreshold < pageSize + pageSize / 2 &&
|
||||
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]);
|
||||
prependLines(newLines);
|
||||
lastFetchedIndexRef.current = nextStart;
|
||||
|
||||
lazyLogRef.current?.listRef.current?.scrollTo(
|
||||
savedEnd + (pageSize - savedStart),
|
||||
newLines.length *
|
||||
lazyLogRef.current?.listRef.current?.getItemSize(1),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, 200),
|
||||
[fetchLogRange, isLoading, pageSize],
|
||||
}, 50),
|
||||
[fetchLogRange, isLoading, prependLines],
|
||||
);
|
||||
|
||||
const handleCopyLogs = useCallback(() => {
|
||||
@ -490,19 +517,22 @@ function Logs() {
|
||||
<FaDownload className="text-secondary-foreground" />
|
||||
<div className="hidden text-primary md:block">Download</div>
|
||||
</Button>
|
||||
<LogLevelFilterButton
|
||||
<LogSettingsButton
|
||||
selectedLabels={filterSeverity}
|
||||
updateLabelFilter={setFilterSeverity}
|
||||
// setStreaming={setStreaming}
|
||||
logSettings={logSettings}
|
||||
setLogSettings={setLogSettings}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="font-mono relative my-2 flex size-full flex-col overflow-hidden whitespace-pre-wrap rounded-md border border-secondary bg-background_alt text-xs sm:p-1">
|
||||
<div className="grid grid-cols-5 *:px-0 *:py-3 *:text-sm *:text-primary/40 md:grid-cols-12">
|
||||
<div className="ml-1 flex items-center p-1 capitalize">Type</div>
|
||||
<div className="col-span-2 flex items-center lg:col-span-1">
|
||||
Timestamp
|
||||
<div className="col-span-3 lg:col-span-2">
|
||||
<div className="flex w-full flex-row items-center">
|
||||
<div className="ml-1 min-w-16 capitalize lg:min-w-20">Type</div>
|
||||
<div className="mr-3">Timestamp</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
@ -533,7 +563,7 @@ function Logs() {
|
||||
onCustomScroll={handleScroll}
|
||||
render={({ follow, onScroll }) => (
|
||||
<>
|
||||
{follow && (
|
||||
{follow && !logSettings.disableStreaming && (
|
||||
<div className="absolute right-1 top-3">
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
@ -556,7 +586,9 @@ function Logs() {
|
||||
text={logs.join("\n")}
|
||||
follow={follow}
|
||||
onScroll={onScroll}
|
||||
loadingComponent={<ActivityIndicator />}
|
||||
loadingComponent={
|
||||
<ActivityIndicator className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" />
|
||||
}
|
||||
loading={isLoading}
|
||||
/>
|
||||
</>
|
||||
@ -587,18 +619,26 @@ function LogLineData({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"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",
|
||||
"grid w-full cursor-pointer grid-cols-5 gap-2 border-t border-secondary bg-background_alt py-1 hover:bg-muted md:grid-cols-12 md:py-0",
|
||||
className,
|
||||
"*:text-xs",
|
||||
"text-xs lg:text-sm/5",
|
||||
)}
|
||||
onClick={onSelect}
|
||||
>
|
||||
<div className="log-severity flex h-full items-center gap-2 p-1">
|
||||
<LogChip severity={line.severity} onClickSeverity={onClickSeverity} />
|
||||
<div className="col-span-3 flex h-full items-center gap-2 lg:col-span-2">
|
||||
<div className="flex w-full flex-row items-center">
|
||||
<div className="log-severity p-1">
|
||||
<LogChip
|
||||
severity={line.severity}
|
||||
onClickSeverity={onClickSeverity}
|
||||
/>
|
||||
</div>
|
||||
<div className="log-timestamp col-span-2 flex h-full items-center whitespace-normal lg:col-span-1">
|
||||
<div className="log-timestamp whitespace-normal">
|
||||
{line.dateStamp}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"log-section flex size-full items-center pr-2",
|
||||
|
||||
@ -14,3 +14,8 @@ export type LogLine = {
|
||||
|
||||
export const logTypes = ["frigate", "go2rtc", "nginx"] as const;
|
||||
export type LogType = (typeof logTypes)[number];
|
||||
|
||||
export type LogSettingsType = {
|
||||
alwaysLoadFull: boolean;
|
||||
disableStreaming: boolean;
|
||||
};
|
||||
|
||||
Loading…
Reference in New Issue
Block a user