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 { 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 = (
<GeneralFilterContent
selectedLabels={selectedLabels}
updateLabelFilter={updateLabelFilter}
/>
<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}>

View File

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

View File

@ -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
fetchLogsStream();
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>
<div className="log-timestamp col-span-2 flex h-full items-center whitespace-normal lg:col-span-1">
{line.dateStamp}
<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 whitespace-normal">
{line.dateStamp}
</div>
</div>
</div>
<div
className={cn(
"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 type LogType = (typeof logTypes)[number];
export type LogSettingsType = {
alwaysLoadFull: boolean;
disableStreaming: boolean;
};