Improve UI logs (#16434)

* use react-logviewer and backend streaming

* layout adjustments

* readd copy handler

* reorder and fix key

* add loading state

* handle frigate log consolidation

* handle newlines in sheet

* update react-logviewer

* fix scrolling and use chunked log download

* don't combine frigate log lines with timestamp

* basic deduplication

* use react-logviewer and backend streaming

* layout adjustments

* readd copy handler

* reorder and fix key

* add loading state

* handle frigate log consolidation

* handle newlines in sheet

* update react-logviewer

* fix scrolling and use chunked log download

* don't combine frigate log lines with timestamp

* basic deduplication

* move process logs function to services util

* improve layout and scrolling behavior

* clean up
This commit is contained in:
Josh Hawkins
2025-02-10 08:38:56 -07:00
committed by GitHub
parent e207b2f50b
commit 2a28964e63
13 changed files with 813 additions and 377 deletions
@@ -0,0 +1,91 @@
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 = {
follow: boolean;
onScroll: (args: {
scrollTop: number;
scrollHeight: number;
clientHeight: number;
}) => void;
startFollowing: () => void;
stopFollowing: () => void;
onCustomScroll?: (
scrollTop: number,
scrollHeight: number,
clientHeight: number,
) => void;
};
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) => {
const wrappedOnScroll = (args: {
scrollTop: number;
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;
if (isNearBottom && !followRef.current) {
renderProps.startFollowing();
followRef.current = true;
} else if (!isNearBottom && followRef.current) {
renderProps.stopFollowing();
followRef.current = false;
}
prevScrollTopRef.current = args.scrollTop;
renderProps.onScroll(args);
if (props.onCustomScroll) {
props.onCustomScroll(
args.scrollTop,
args.scrollHeight,
args.clientHeight,
);
}
};
return props.render({
...renderProps,
onScroll: wrappedOnScroll,
follow: followRef.current,
});
},
[props],
);
return <ScrollFollow {...props} render={wrappedRender} />;
}
@@ -1,36 +1,73 @@
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">
When the log pane is scrolled to the bottom, new logs
automatically stream as they are added.
</div>
<FilterSwitch
label="Disable log streaming"
isChecked={logSettings?.disableStreaming ?? false}
onCheckedChange={(isChecked) => {
setLogSettings({
disableStreaming: isChecked,
});
}}
/>
</div>
</div>
</div>
</div>
);
if (isMobile) {
@@ -63,7 +100,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,10 +118,9 @@ 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">
<div className="flex items-center justify-between" key={item}>
<Label
className="mx-2 w-full cursor-pointer capitalize text-primary"
htmlFor={item}
+12 -10
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>
);
}
+8 -1
View File
@@ -66,7 +66,14 @@ export default function LogInfoDialog({
</div>
<div className="flex flex-col gap-1.5">
<div className="text-sm text-primary/40">Message</div>
<div className="text-sm">{logLine.content}</div>
<div className="text-sm">
{logLine.content.split("\n").map((line) => (
<>
{line}
<br />
</>
))}
</div>
</div>
{helpfulLinks.length > 0 && (
<div className="flex flex-col gap-1.5">