mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-02-18 17:14:26 +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 { 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}>
|
||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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;
|
||||||
|
};
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user