improve layout and scrolling behavior

This commit is contained in:
Josh Hawkins 2025-02-10 07:48:49 -06:00
parent 1c32a1cf63
commit 3053a33c3d
4 changed files with 158 additions and 63 deletions

View File

@ -1,36 +1,85 @@
import { Button } from "../ui/button"; import { Button } from "../ui/button";
import { FaFilter } from "react-icons/fa"; import { FaCog } from "react-icons/fa";
import { isMobile } from "react-device-detect"; import { isMobile } from "react-device-detect";
import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer"; import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer";
import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover"; import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
import { LogSeverity } from "@/types/log"; import { LogSettingsType, LogSeverity } from "@/types/log";
import { Label } from "../ui/label"; import { Label } from "../ui/label";
import { Switch } from "../ui/switch"; import { Switch } from "../ui/switch";
import { DropdownMenuSeparator } from "../ui/dropdown-menu"; import { DropdownMenuSeparator } from "../ui/dropdown-menu";
import { cn } from "@/lib/utils";
import FilterSwitch from "./FilterSwitch";
type LogLevelFilterButtonProps = { type LogSettingsButtonProps = {
selectedLabels?: LogSeverity[]; selectedLabels?: LogSeverity[];
updateLabelFilter: (labels: LogSeverity[] | undefined) => void; updateLabelFilter: (labels: LogSeverity[] | undefined) => void;
logSettings?: LogSettingsType;
setLogSettings: (logSettings: LogSettingsType) => void;
}; };
export function LogLevelFilterButton({ export function LogSettingsButton({
selectedLabels, selectedLabels,
updateLabelFilter, updateLabelFilter,
}: LogLevelFilterButtonProps) { logSettings,
setLogSettings,
}: LogSettingsButtonProps) {
const trigger = ( const trigger = (
<Button <Button
size="sm" size="sm"
className="flex items-center gap-2" className="flex items-center gap-2"
aria-label="Filter log level" aria-label="Filter log level"
> >
<FaFilter className="text-secondary-foreground" /> <FaCog className="text-secondary-foreground" />
<div className="hidden text-primary md:block">Filter</div> <div className="hidden text-primary md:block">Settings</div>
</Button> </Button>
); );
const content = ( const content = (
<GeneralFilterContent <div className={cn("my-3 space-y-3 py-3 md:mt-0 md:py-0")}>
selectedLabels={selectedLabels} <div className="space-y-4">
updateLabelFilter={updateLabelFilter} <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) { if (isMobile) {
@ -63,7 +112,7 @@ export function GeneralFilterContent({
return ( return (
<> <>
<div className="scrollbar-container h-auto overflow-y-auto overflow-x-hidden"> <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 <Label
className="mx-2 cursor-pointer text-primary" className="mx-2 cursor-pointer text-primary"
htmlFor="allLabels" htmlFor="allLabels"
@ -81,7 +130,6 @@ export function GeneralFilterContent({
}} }}
/> />
</div> </div>
<DropdownMenuSeparator />
<div className="my-2.5 flex flex-col gap-2.5"> <div className="my-2.5 flex flex-col gap-2.5">
{["debug", "info", "warning", "error"].map((item) => ( {["debug", "info", "warning", "error"].map((item) => (
<div className="flex items-center justify-between" key={item}> <div className="flex items-center justify-between" key={item}>

View File

@ -70,17 +70,19 @@ export function LogChip({ severity, onClickSeverity }: LogChipProps) {
}, [severity]); }, [severity]);
return ( return (
<div <div className="min-w-16 lg:min-w-20">
className={`rounded-md px-1 py-[1px] text-xs capitalize ${onClickSeverity ? "cursor-pointer" : ""} ${severityClassName}`} <span
onClick={(e) => { className={`rounded-md px-1 py-[1px] text-xs capitalize ${onClickSeverity ? "cursor-pointer" : ""} ${severityClassName}`}
e.stopPropagation(); onClick={(e) => {
e.stopPropagation();
if (onClickSeverity) { if (onClickSeverity) {
onClickSeverity(); onClickSeverity();
} }
}} }}
> >
{severity} {severity}
</span>
</div> </div>
); );
} }

View File

@ -1,12 +1,18 @@
import { Button } from "@/components/ui/button"; 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,
LogSettingsType,
LogSeverity,
LogType,
logTypes,
} from "@/types/log";
import copy from "copy-to-clipboard"; import copy from "copy-to-clipboard";
import { useCallback, useEffect, useMemo, 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";
import { LogLevelFilterButton } from "@/components/filter/LogLevelFilter"; import { LogSettingsButton } from "@/components/filter/LogSettingsButton";
import { FaCopy, FaDownload } from "react-icons/fa"; import { FaCopy, FaDownload } from "react-icons/fa";
import { Toaster } from "@/components/ui/sonner"; import { Toaster } from "@/components/ui/sonner";
import { toast } from "sonner"; import { toast } from "sonner";
@ -25,6 +31,7 @@ import {
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { debounce } from "lodash"; import { debounce } from "lodash";
import { usePersistence } from "@/hooks/use-persistence";
function Logs() { function Logs() {
const [logService, setLogService] = useState<LogType>("frigate"); const [logService, setLogService] = useState<LogType>("frigate");
@ -55,16 +62,17 @@ function Logs() {
} }
}, [tabsRef, logService]); }, [tabsRef, logService]);
// scrolling data // log settings
const pageSize = useMemo(() => { // const [logSettings, setLogSettings] = usePersistence<LogSettingsType>(
const startIndex = // "logSettings",
lazyLogRef.current?.listRef.current?.findStartIndex() ?? 0; // { alwaysLoadFull: false, disableStreaming: false },
const endIndex = lazyLogRef.current?.listRef.current?.findEndIndex() ?? 0; // );
return endIndex - startIndex;
// recalculate page size when new logs load too const [logSettings, setLogSettings] = useState<LogSettingsType>({
// eslint-disable-next-line react-hooks/exhaustive-deps alwaysLoadFull: false,
}, [lazyLogRef, logs]); disableStreaming: false,
});
// filter // filter
@ -112,7 +120,7 @@ function Logs() {
setIsLoading(true); setIsLoading(true);
try { try {
const response = await axios.get(`logs/${logService}`, { const response = await axios.get(`logs/${logService}`, {
params: { start: -100 }, params: { start: logSettings.alwaysLoadFull ? 0 : -100 },
}); });
if ( if (
response.status === 200 && response.status === 200 &&
@ -133,7 +141,7 @@ function Logs() {
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
}, [logService, filterLines]); }, [logService, filterLines, logSettings]);
const abortControllerRef = useRef<AbortController | null>(null); const abortControllerRef = useRef<AbortController | null>(null);
@ -209,7 +217,9 @@ function Logs() {
lastFetchedIndexRef.current = -1; lastFetchedIndexRef.current = -1;
fetchInitialLogs().then(() => { fetchInitialLogs().then(() => {
// Start streaming after initial load // Start streaming after initial load
fetchLogsStream(); if (!logSettings.disableStreaming) {
fetchLogsStream();
}
}); });
return () => { return () => {
@ -221,39 +231,56 @@ function Logs() {
// handlers // 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( const handleScroll = useMemo(
() => () =>
debounce((scrollTop: number) => { debounce(() => {
const scrollThreshold = 0; 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 ( if (
scrollTop <= scrollThreshold && scrollThreshold < pageSize + pageSize / 2 &&
lastFetchedIndexRef.current > 0 && lastFetchedIndexRef.current > 0 &&
!isLoading !isLoading
) { ) {
const savedStart =
lazyLogRef.current?.listRef.current?.findStartIndex() ?? 0;
const savedEnd =
lazyLogRef.current?.listRef.current?.findEndIndex() ?? 0;
const nextEnd = lastFetchedIndexRef.current; const nextEnd = lastFetchedIndexRef.current;
const nextStart = Math.max(0, nextEnd - (pageSize || 100)); const nextStart = Math.max(0, nextEnd - (pageSize || 100));
setIsLoading(true); setIsLoading(true);
fetchLogRange(nextStart, nextEnd).then((newLines) => { fetchLogRange(nextStart, nextEnd).then((newLines) => {
if (newLines.length > 0) { if (newLines.length > 0) {
setLogs((prev) => [...newLines, ...prev]); prependLines(newLines);
lastFetchedIndexRef.current = nextStart; lastFetchedIndexRef.current = nextStart;
lazyLogRef.current?.listRef.current?.scrollTo( lazyLogRef.current?.listRef.current?.scrollTo(
savedEnd + (pageSize - savedStart), newLines.length *
lazyLogRef.current?.listRef.current?.getItemSize(1),
); );
} }
}); });
setIsLoading(false); setIsLoading(false);
} }
}, 200), }, 50),
[fetchLogRange, isLoading, pageSize], [fetchLogRange, isLoading, prependLines],
); );
const handleCopyLogs = useCallback(() => { const handleCopyLogs = useCallback(() => {
@ -490,19 +517,22 @@ function Logs() {
<FaDownload className="text-secondary-foreground" /> <FaDownload className="text-secondary-foreground" />
<div className="hidden text-primary md:block">Download</div> <div className="hidden text-primary md:block">Download</div>
</Button> </Button>
<LogLevelFilterButton <LogSettingsButton
selectedLabels={filterSeverity} selectedLabels={filterSeverity}
updateLabelFilter={setFilterSeverity} updateLabelFilter={setFilterSeverity}
// setStreaming={setStreaming} logSettings={logSettings}
setLogSettings={setLogSettings}
/> />
</div> </div>
</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="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="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-3 lg:col-span-2">
<div className="col-span-2 flex items-center lg:col-span-1"> <div className="flex w-full flex-row items-center">
Timestamp <div className="ml-1 min-w-16 capitalize lg:min-w-20">Type</div>
<div className="mr-3">Timestamp</div>
</div>
</div> </div>
<div <div
className={cn( className={cn(
@ -533,7 +563,7 @@ function Logs() {
onCustomScroll={handleScroll} onCustomScroll={handleScroll}
render={({ follow, onScroll }) => ( render={({ follow, onScroll }) => (
<> <>
{follow && ( {follow && !logSettings.disableStreaming && (
<div className="absolute right-1 top-3"> <div className="absolute right-1 top-3">
<Tooltip> <Tooltip>
<TooltipTrigger> <TooltipTrigger>
@ -556,7 +586,9 @@ function Logs() {
text={logs.join("\n")} text={logs.join("\n")}
follow={follow} follow={follow}
onScroll={onScroll} onScroll={onScroll}
loadingComponent={<ActivityIndicator />} loadingComponent={
<ActivityIndicator className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" />
}
loading={isLoading} loading={isLoading}
/> />
</> </>
@ -587,18 +619,26 @@ function LogLineData({
return ( return (
<div <div
className={cn( 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, className,
"*:text-xs", "text-xs lg:text-sm/5",
)} )}
onClick={onSelect} onClick={onSelect}
> >
<div className="log-severity flex h-full items-center gap-2 p-1"> <div className="col-span-3 flex h-full items-center gap-2 lg:col-span-2">
<LogChip severity={line.severity} onClickSeverity={onClickSeverity} /> <div className="flex w-full flex-row items-center">
</div> <div className="log-severity p-1">
<div className="log-timestamp col-span-2 flex h-full items-center whitespace-normal lg:col-span-1"> <LogChip
{line.dateStamp} severity={line.severity}
onClickSeverity={onClickSeverity}
/>
</div>
<div className="log-timestamp whitespace-normal">
{line.dateStamp}
</div>
</div>
</div> </div>
<div <div
className={cn( className={cn(
"log-section flex size-full items-center pr-2", "log-section flex size-full items-center pr-2",

View File

@ -14,3 +14,8 @@ export type LogLine = {
export const logTypes = ["frigate", "go2rtc", "nginx"] as const; export const logTypes = ["frigate", "go2rtc", "nginx"] as const;
export type LogType = (typeof logTypes)[number]; export type LogType = (typeof logTypes)[number];
export type LogSettingsType = {
alwaysLoadFull: boolean;
disableStreaming: boolean;
};