mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-04-28 23:06:13 +02:00
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:
91
web/src/components/dynamic/EnhancedScrollFollow.tsx
Normal file
91
web/src/components/dynamic/EnhancedScrollFollow.tsx
Normal file
@@ -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}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -179,3 +179,8 @@ html {
|
||||
border: 3px solid #a00000 !important;
|
||||
opacity: 0.5 !important;
|
||||
}
|
||||
|
||||
.react-lazylog,
|
||||
.react-lazylog-searchbar {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
@@ -1,35 +1,47 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||
import { LogData, 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 { FaCopy } from "react-icons/fa6";
|
||||
import { LogSettingsButton } from "@/components/filter/LogSettingsButton";
|
||||
import { FaCopy, FaDownload } from "react-icons/fa";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
isDesktop,
|
||||
isMobile,
|
||||
isMobileOnly,
|
||||
isTablet,
|
||||
} from "react-device-detect";
|
||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { MdVerticalAlignBottom } from "react-icons/md";
|
||||
import { parseLogLines } from "@/utils/logUtil";
|
||||
import useKeyboardListener from "@/hooks/use-keyboard-listener";
|
||||
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
|
||||
import scrollIntoView from "scroll-into-view-if-needed";
|
||||
import { FaDownload } from "react-icons/fa";
|
||||
|
||||
type LogRange = { start: number; end: number };
|
||||
import { LazyLog } from "@melloware/react-logviewer";
|
||||
import useKeyboardListener from "@/hooks/use-keyboard-listener";
|
||||
import EnhancedScrollFollow from "@/components/dynamic/EnhancedScrollFollow";
|
||||
import { MdCircle } from "react-icons/md";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { debounce } from "lodash";
|
||||
|
||||
function Logs() {
|
||||
const [logService, setLogService] = useState<LogType>("frigate");
|
||||
const tabsRef = useRef<HTMLDivElement | null>(null);
|
||||
const lazyLogWrapperRef = useRef<HTMLDivElement>(null);
|
||||
const [logs, setLogs] = useState<string[]>([]);
|
||||
const [filterSeverity, setFilterSeverity] = useState<LogSeverity[]>();
|
||||
const [selectedLog, setSelectedLog] = useState<LogLine>();
|
||||
const lazyLogRef = useRef<LazyLog>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const lastFetchedIndexRef = useRef(-1);
|
||||
|
||||
useEffect(() => {
|
||||
document.title = `${logService[0].toUpperCase()}${logService.substring(1)} Logs - Frigate`;
|
||||
@@ -49,92 +61,233 @@ function Logs() {
|
||||
}
|
||||
}, [tabsRef, logService]);
|
||||
|
||||
// log data handling
|
||||
// log settings
|
||||
|
||||
const logPageSize = useMemo(() => {
|
||||
if (isMobileOnly) {
|
||||
return 15;
|
||||
}
|
||||
const [logSettings, setLogSettings] = useState<LogSettingsType>({
|
||||
disableStreaming: false,
|
||||
});
|
||||
|
||||
if (isTablet) {
|
||||
return 25;
|
||||
}
|
||||
// filter
|
||||
|
||||
return 40;
|
||||
}, []);
|
||||
const filterLines = useCallback(
|
||||
(lines: string[]) => {
|
||||
if (!filterSeverity?.length) return lines;
|
||||
|
||||
const [logRange, setLogRange] = useState<LogRange>({ start: 0, end: 0 });
|
||||
const [logs, setLogs] = useState<string[]>([]);
|
||||
const [logLines, setLogLines] = useState<LogLine[]>([]);
|
||||
return lines.filter((line) => {
|
||||
const parsedLine = parseLogLines(logService, [line])[0];
|
||||
return filterSeverity.includes(parsedLine.severity);
|
||||
});
|
||||
},
|
||||
[filterSeverity, logService],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
axios
|
||||
.get(`logs/${logService}?start=-${logPageSize}`)
|
||||
.then((resp) => {
|
||||
if (resp.status == 200) {
|
||||
const data = resp.data as LogData;
|
||||
setLogRange({
|
||||
start: Math.max(0, data.totalLines - logPageSize),
|
||||
end: data.totalLines,
|
||||
});
|
||||
setLogs(data.lines);
|
||||
setLogLines(parseLogLines(logService, data.lines));
|
||||
// fetchers
|
||||
|
||||
const fetchLogRange = useCallback(
|
||||
async (start: number, end: number) => {
|
||||
try {
|
||||
const response = await axios.get(`logs/${logService}`, {
|
||||
params: { start, end },
|
||||
});
|
||||
if (
|
||||
response.status === 200 &&
|
||||
response.data &&
|
||||
Array.isArray(response.data.lines)
|
||||
) {
|
||||
const filteredLines = filterLines(response.data.lines);
|
||||
return filteredLines;
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : "An unknown error occurred";
|
||||
toast.error(`Error fetching logs: ${errorMessage}`, {
|
||||
position: "top-center",
|
||||
});
|
||||
}
|
||||
return [];
|
||||
},
|
||||
[logService, filterLines],
|
||||
);
|
||||
|
||||
const fetchInitialLogs = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await axios.get(`logs/${logService}`, {
|
||||
params: { start: filterSeverity ? 0 : -100 },
|
||||
});
|
||||
if (
|
||||
response.status === 200 &&
|
||||
response.data &&
|
||||
Array.isArray(response.data.lines)
|
||||
) {
|
||||
const filteredLines = filterLines(response.data.lines);
|
||||
setLogs(filteredLines);
|
||||
lastFetchedIndexRef.current =
|
||||
response.data.totalLines - filteredLines.length;
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : "An unknown error occurred";
|
||||
toast.error(`Error fetching logs: ${errorMessage}`, {
|
||||
position: "top-center",
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [logService, filterLines, filterSeverity]);
|
||||
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
const fetchLogsStream = useCallback(() => {
|
||||
// Cancel any existing stream
|
||||
abortControllerRef.current?.abort();
|
||||
const abortController = new AbortController();
|
||||
abortControllerRef.current = abortController;
|
||||
let buffer = "";
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
const processStreamChunk = (
|
||||
reader: ReadableStreamDefaultReader<Uint8Array>,
|
||||
): Promise<void> => {
|
||||
return reader.read().then(({ done, value }) => {
|
||||
if (done) return;
|
||||
|
||||
// Decode the chunk and add it to our buffer
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
// Split on newlines, keeping any partial line in the buffer
|
||||
const lines = buffer.split("\n");
|
||||
|
||||
// Keep the last partial line
|
||||
buffer = lines.pop() || "";
|
||||
|
||||
// Filter and append complete lines
|
||||
if (lines.length > 0) {
|
||||
const filteredLines = filterSeverity?.length
|
||||
? lines.filter((line) => {
|
||||
const parsedLine = parseLogLines(logService, [line])[0];
|
||||
return filterSeverity.includes(parsedLine.severity);
|
||||
})
|
||||
: lines;
|
||||
if (filteredLines.length > 0) {
|
||||
lazyLogRef.current?.appendLines(filteredLines);
|
||||
}
|
||||
}
|
||||
// Process next chunk
|
||||
return processStreamChunk(reader);
|
||||
});
|
||||
};
|
||||
|
||||
fetch(`api/logs/${logService}?stream=true`, {
|
||||
signal: abortController.signal,
|
||||
})
|
||||
.then((response): Promise<void> => {
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Error while fetching log stream, status: ${response.status}`,
|
||||
);
|
||||
}
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) {
|
||||
throw new Error("No reader available");
|
||||
}
|
||||
return processStreamChunk(reader);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [logPageSize, logService]);
|
||||
.catch((error) => {
|
||||
if (error.name !== "AbortError") {
|
||||
const errorMessage =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "An unknown error occurred";
|
||||
toast.error(`Error while streaming logs: ${errorMessage}`);
|
||||
}
|
||||
});
|
||||
}, [logService, filterSeverity]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!logs || logs.length == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const id = setTimeout(() => {
|
||||
axios
|
||||
.get(`logs/${logService}?start=${logRange.end}`)
|
||||
.then((resp) => {
|
||||
if (resp.status == 200) {
|
||||
const data = resp.data as LogData;
|
||||
|
||||
if (data.lines.length > 0) {
|
||||
setLogRange({
|
||||
start: logRange.start,
|
||||
end: data.totalLines,
|
||||
});
|
||||
setLogs([...logs, ...data.lines]);
|
||||
setLogLines([
|
||||
...logLines,
|
||||
...parseLogLines(logService, data.lines),
|
||||
]);
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}, 5000);
|
||||
setIsLoading(true);
|
||||
setLogs([]);
|
||||
lastFetchedIndexRef.current = -1;
|
||||
fetchInitialLogs().then(() => {
|
||||
// Start streaming after initial load
|
||||
if (!logSettings.disableStreaming) {
|
||||
fetchLogsStream();
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
if (id) {
|
||||
clearTimeout(id);
|
||||
}
|
||||
abortControllerRef.current?.abort();
|
||||
};
|
||||
// we need to listen on the current range of visible items
|
||||
// we know that these deps are correct
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [logLines, logService, logRange]);
|
||||
}, [logService, filterSeverity]);
|
||||
|
||||
// convert to log data
|
||||
// handlers
|
||||
|
||||
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(() => {
|
||||
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 (
|
||||
scrollThreshold < pageSize + pageSize / 2 &&
|
||||
lastFetchedIndexRef.current > 0 &&
|
||||
!isLoading
|
||||
) {
|
||||
const nextEnd = lastFetchedIndexRef.current;
|
||||
const nextStart = Math.max(0, nextEnd - (pageSize || 100));
|
||||
setIsLoading(true);
|
||||
|
||||
fetchLogRange(nextStart, nextEnd).then((newLines) => {
|
||||
if (newLines.length > 0) {
|
||||
prependLines(newLines);
|
||||
lastFetchedIndexRef.current = nextStart;
|
||||
|
||||
lazyLogRef.current?.listRef.current?.scrollTo(
|
||||
newLines.length *
|
||||
lazyLogRef.current?.listRef.current?.getItemSize(1),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, 50),
|
||||
[fetchLogRange, isLoading, prependLines],
|
||||
);
|
||||
|
||||
const handleCopyLogs = useCallback(() => {
|
||||
if (logs) {
|
||||
copy(logs.join("\n"));
|
||||
toast.success(
|
||||
logRange.start == 0
|
||||
? "Copied logs to clipboard"
|
||||
: "Copied visible logs to clipboard",
|
||||
);
|
||||
} else {
|
||||
toast.error("Could not copy logs to clipboard");
|
||||
if (logs.length) {
|
||||
fetchInitialLogs()
|
||||
.then(() => {
|
||||
copy(logs.join("\n"));
|
||||
toast.success("Copied logs to clipboard");
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Could not copy logs to clipboard");
|
||||
});
|
||||
}
|
||||
}, [logs, logRange]);
|
||||
}, [logs, fetchInitialLogs]);
|
||||
|
||||
const handleDownloadLogs = useCallback(() => {
|
||||
axios
|
||||
@@ -157,153 +310,76 @@ function Logs() {
|
||||
.catch(() => {});
|
||||
}, [logService]);
|
||||
|
||||
// scroll to bottom
|
||||
|
||||
const [initialScroll, setInitialScroll] = useState(false);
|
||||
|
||||
const contentRef = useRef<HTMLDivElement | null>(null);
|
||||
const [endVisible, setEndVisible] = useState(true);
|
||||
const endObserver = useRef<IntersectionObserver | null>(null);
|
||||
const endLogRef = useCallback(
|
||||
(node: HTMLElement | null) => {
|
||||
if (endObserver.current) endObserver.current.disconnect();
|
||||
try {
|
||||
endObserver.current = new IntersectionObserver((entries) => {
|
||||
setEndVisible(entries[0].isIntersecting);
|
||||
});
|
||||
if (node) endObserver.current.observe(node);
|
||||
} catch (e) {
|
||||
// no op
|
||||
}
|
||||
const handleRowClick = useCallback(
|
||||
(rowInfo: { lineNumber: number; rowIndex: number }) => {
|
||||
const clickedLine = parseLogLines(logService, [
|
||||
logs[rowInfo.rowIndex],
|
||||
])[0];
|
||||
setSelectedLog(clickedLine);
|
||||
},
|
||||
[setEndVisible],
|
||||
);
|
||||
const startObserver = useRef<IntersectionObserver | null>(null);
|
||||
const startLogRef = useCallback(
|
||||
(node: HTMLElement | null) => {
|
||||
if (startObserver.current) startObserver.current.disconnect();
|
||||
|
||||
if (logs.length == 0 || !initialScroll) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
startObserver.current = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0].isIntersecting && logRange.start > 0) {
|
||||
const start = Math.max(0, logRange.start - logPageSize);
|
||||
|
||||
axios
|
||||
.get(`logs/${logService}?start=${start}&end=${logRange.start}`)
|
||||
.then((resp) => {
|
||||
if (resp.status == 200) {
|
||||
const data = resp.data as LogData;
|
||||
|
||||
if (data.lines.length > 0) {
|
||||
setLogRange({
|
||||
start: start,
|
||||
end: logRange.end,
|
||||
});
|
||||
setLogs([...data.lines, ...logs]);
|
||||
setLogLines([
|
||||
...parseLogLines(logService, data.lines),
|
||||
...logLines,
|
||||
]);
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
contentRef.current?.scrollBy({
|
||||
top: 10,
|
||||
});
|
||||
}
|
||||
},
|
||||
{ rootMargin: `${10 * (isMobile ? 64 : 48)}px 0px 0px 0px` },
|
||||
);
|
||||
if (node) startObserver.current.observe(node);
|
||||
} catch (e) {
|
||||
// no op
|
||||
}
|
||||
},
|
||||
// we need to listen on the current range of visible items
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[logRange, initialScroll],
|
||||
[logs, logService],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (logLines.length == 0) {
|
||||
setInitialScroll(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (initialScroll) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!contentRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (contentRef.current.scrollHeight <= contentRef.current.clientHeight) {
|
||||
setInitialScroll(true);
|
||||
return;
|
||||
}
|
||||
|
||||
contentRef.current?.scrollTo({
|
||||
top: contentRef.current?.scrollHeight,
|
||||
behavior: "instant",
|
||||
});
|
||||
setTimeout(() => setInitialScroll(true), 300);
|
||||
// we need to listen on the current range of visible items
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [logLines, logService]);
|
||||
|
||||
// log filtering
|
||||
|
||||
const [filterSeverity, setFilterSeverity] = useState<LogSeverity[]>();
|
||||
|
||||
// log selection
|
||||
|
||||
const [selectedLog, setSelectedLog] = useState<LogLine>();
|
||||
|
||||
// interaction
|
||||
// keyboard listener
|
||||
|
||||
useKeyboardListener(
|
||||
["PageDown", "PageUp", "ArrowDown", "ArrowUp"],
|
||||
(key, modifiers) => {
|
||||
if (!modifiers.down) {
|
||||
if (!key || !modifiers.down || !lazyLogWrapperRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (key) {
|
||||
case "PageDown":
|
||||
contentRef.current?.scrollBy({
|
||||
top: 480,
|
||||
});
|
||||
break;
|
||||
case "PageUp":
|
||||
contentRef.current?.scrollBy({
|
||||
top: -480,
|
||||
});
|
||||
break;
|
||||
case "ArrowDown":
|
||||
contentRef.current?.scrollBy({
|
||||
top: 48,
|
||||
});
|
||||
break;
|
||||
case "ArrowUp":
|
||||
contentRef.current?.scrollBy({
|
||||
top: -48,
|
||||
});
|
||||
break;
|
||||
const container =
|
||||
lazyLogWrapperRef.current.querySelector(".react-lazylog");
|
||||
|
||||
const logLineHeight = container?.querySelector(".log-line")?.clientHeight;
|
||||
|
||||
if (!logLineHeight) {
|
||||
return;
|
||||
}
|
||||
|
||||
const scrollAmount = key.includes("Page")
|
||||
? logLineHeight * 10
|
||||
: logLineHeight;
|
||||
const direction = key.includes("Down") ? 1 : -1;
|
||||
container?.scrollBy({ top: scrollAmount * direction });
|
||||
},
|
||||
);
|
||||
|
||||
// format lines
|
||||
|
||||
const lineBufferRef = useRef<string>("");
|
||||
|
||||
const formatPart = useCallback(
|
||||
(text: string) => {
|
||||
lineBufferRef.current += text;
|
||||
|
||||
if (text.endsWith("\n")) {
|
||||
const completeLine = lineBufferRef.current.trim();
|
||||
lineBufferRef.current = "";
|
||||
|
||||
if (completeLine) {
|
||||
const parsedLine = parseLogLines(logService, [completeLine])[0];
|
||||
return (
|
||||
<LogLineData
|
||||
line={parsedLine}
|
||||
logService={logService}
|
||||
onClickSeverity={() => setFilterSeverity([parsedLine.severity])}
|
||||
onSelect={() => setSelectedLog(parsedLine)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
[logService, setFilterSeverity, setSelectedLog],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handleCopy = (e: ClipboardEvent) => {
|
||||
e.preventDefault();
|
||||
if (!contentRef.current) return;
|
||||
if (!lazyLogWrapperRef.current) return;
|
||||
|
||||
const selection = window.getSelection();
|
||||
if (!selection) return;
|
||||
@@ -371,7 +447,7 @@ function Logs() {
|
||||
e.clipboardData?.setData("text/plain", copyText);
|
||||
};
|
||||
|
||||
const content = contentRef.current;
|
||||
const content = lazyLogWrapperRef.current;
|
||||
content?.addEventListener("copy", handleCopy);
|
||||
return () => {
|
||||
content?.removeEventListener("copy", handleCopy);
|
||||
@@ -393,11 +469,10 @@ function Logs() {
|
||||
onValueChange={(value: LogType) => {
|
||||
if (value) {
|
||||
setLogs([]);
|
||||
setLogLines([]);
|
||||
setFilterSeverity(undefined);
|
||||
setLogService(value);
|
||||
}
|
||||
}} // don't allow the severity to be unselected
|
||||
}}
|
||||
>
|
||||
{Object.values(logTypes).map((item) => (
|
||||
<ToggleGroupItem
|
||||
@@ -435,128 +510,146 @@ function Logs() {
|
||||
<FaDownload className="text-secondary-foreground" />
|
||||
<div className="hidden text-primary md:block">Download</div>
|
||||
</Button>
|
||||
<LogLevelFilterButton
|
||||
<LogSettingsButton
|
||||
selectedLabels={filterSeverity}
|
||||
updateLabelFilter={setFilterSeverity}
|
||||
logSettings={logSettings}
|
||||
setLogSettings={setLogSettings}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{initialScroll && !endVisible && (
|
||||
<Button
|
||||
className="absolute bottom-8 left-[50%] z-20 flex -translate-x-[50%] items-center gap-1 rounded-md p-2"
|
||||
aria-label="Jump to bottom of logs"
|
||||
onClick={() =>
|
||||
contentRef.current?.scrollTo({
|
||||
top: contentRef.current?.scrollHeight,
|
||||
behavior: "smooth",
|
||||
})
|
||||
}
|
||||
>
|
||||
<MdVerticalAlignBottom />
|
||||
Jump to Bottom
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<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-sm sm:p-2">
|
||||
<div className="grid grid-cols-5 *:px-2 *:py-3 *:text-sm *:text-primary/40 sm:grid-cols-8 md:grid-cols-12">
|
||||
<div className="flex items-center p-1 capitalize">Type</div>
|
||||
<div className="col-span-2 flex items-center sm:col-span-1">
|
||||
Timestamp
|
||||
<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="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="col-span-2 flex items-center">Tag</div>
|
||||
<div className="col-span-5 flex items-center sm:col-span-4 md:col-span-8">
|
||||
Message
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center",
|
||||
logService == "frigate" ? "col-span-2" : "col-span-1",
|
||||
)}
|
||||
>
|
||||
Tag
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"col-span-5 flex items-center",
|
||||
logService == "frigate"
|
||||
? "md:col-span-7 lg:col-span-8"
|
||||
: "md:col-span-8 lg:col-span-9",
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-1">Message</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
ref={contentRef}
|
||||
className="no-scrollbar flex w-full flex-col overflow-y-auto overscroll-contain"
|
||||
>
|
||||
{logLines.length > 0 &&
|
||||
[...Array(logRange.end).keys()].map((idx) => {
|
||||
const logLine =
|
||||
idx >= logRange.start
|
||||
? logLines[idx - logRange.start]
|
||||
: undefined;
|
||||
|
||||
if (logLine) {
|
||||
const line = logLines[idx - logRange.start];
|
||||
if (filterSeverity && !filterSeverity.includes(line.severity)) {
|
||||
return (
|
||||
<div
|
||||
ref={idx == logRange.start + 10 ? startLogRef : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<LogLineData
|
||||
key={`${idx}-${logService}`}
|
||||
startRef={
|
||||
idx == logRange.start + 10 ? startLogRef : undefined
|
||||
<div ref={lazyLogWrapperRef} className="size-full">
|
||||
{isLoading ? (
|
||||
<ActivityIndicator className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" />
|
||||
) : (
|
||||
<EnhancedScrollFollow
|
||||
startFollowing={!isLoading}
|
||||
onCustomScroll={handleScroll}
|
||||
render={({ follow, onScroll }) => (
|
||||
<>
|
||||
{follow && !logSettings.disableStreaming && (
|
||||
<div className="absolute right-1 top-3">
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<MdCircle className="mr-2 size-2 animate-pulse cursor-default text-selected shadow-selected drop-shadow-md" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Logs are streaming from the server
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
<LazyLog
|
||||
ref={lazyLogRef}
|
||||
enableLineNumbers={false}
|
||||
selectableLines
|
||||
lineClassName="text-primary bg-background"
|
||||
highlightLineClassName="bg-primary/20"
|
||||
onRowClick={handleRowClick}
|
||||
formatPart={formatPart}
|
||||
text={logs.join("\n")}
|
||||
follow={follow}
|
||||
onScroll={onScroll}
|
||||
loadingComponent={
|
||||
<ActivityIndicator className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" />
|
||||
}
|
||||
className={initialScroll ? "" : "invisible"}
|
||||
line={line}
|
||||
onClickSeverity={() => setFilterSeverity([line.severity])}
|
||||
onSelect={() => setSelectedLog(line)}
|
||||
loading={isLoading}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${idx}-${logService}`}
|
||||
className={isDesktop ? "h-12" : "h-16"}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{logLines.length > 0 && <div id="page-bottom" ref={endLogRef} />}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{logLines.length == 0 && (
|
||||
<ActivityIndicator className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type LogLineDataProps = {
|
||||
startRef?: (node: HTMLDivElement | null) => void;
|
||||
className: string;
|
||||
className?: string;
|
||||
line: LogLine;
|
||||
logService: string;
|
||||
onClickSeverity: () => void;
|
||||
onSelect: () => void;
|
||||
};
|
||||
|
||||
function LogLineData({
|
||||
startRef,
|
||||
className,
|
||||
line,
|
||||
logService,
|
||||
onClickSeverity,
|
||||
onSelect,
|
||||
}: LogLineDataProps) {
|
||||
return (
|
||||
<div
|
||||
ref={startRef}
|
||||
className={cn(
|
||||
"grid w-full cursor-pointer grid-cols-5 gap-2 border-t border-secondary py-2 hover:bg-muted sm:grid-cols-8 md:grid-cols-12",
|
||||
"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-sm",
|
||||
"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 whitespace-normal">
|
||||
{line.dateStamp}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="log-timestamp col-span-2 flex h-full items-center sm:col-span-1">
|
||||
{line.dateStamp}
|
||||
</div>
|
||||
<div className="log-section col-span-2 flex size-full items-center pr-2">
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"log-section flex size-full items-center pr-2",
|
||||
logService == "frigate" ? "col-span-2" : "col-span-1",
|
||||
)}
|
||||
>
|
||||
<div className="w-full overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
{line.section}
|
||||
</div>
|
||||
</div>
|
||||
<div className="log-content col-span-5 flex size-full items-center justify-between pl-2 pr-2 sm:col-span-4 sm:pl-0 md:col-span-8">
|
||||
<div
|
||||
className={cn(
|
||||
"log-content col-span-5 flex size-full items-center justify-between px-2 md:px-0 md:pr-2",
|
||||
logService == "frigate"
|
||||
? "md:col-span-7 lg:col-span-8"
|
||||
: "md:col-span-8 lg:col-span-9",
|
||||
)}
|
||||
>
|
||||
<div className="w-full overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
{line.content}
|
||||
</div>
|
||||
|
||||
@@ -14,3 +14,7 @@ export type LogLine = {
|
||||
|
||||
export const logTypes = ["frigate", "go2rtc", "nginx"] as const;
|
||||
export type LogType = (typeof logTypes)[number];
|
||||
|
||||
export type LogSettingsType = {
|
||||
disableStreaming: boolean;
|
||||
};
|
||||
|
||||
@@ -18,13 +18,29 @@ export function parseLogLines(logService: LogType, logs: string[]) {
|
||||
|
||||
if (!match) {
|
||||
const infoIndex = line.indexOf("[INFO]");
|
||||
const loggingIndex = line.indexOf("[LOGGING]");
|
||||
|
||||
if (loggingIndex != -1) {
|
||||
return {
|
||||
dateStamp: line.substring(0, 19),
|
||||
severity: "info",
|
||||
section: "logging",
|
||||
content: line
|
||||
.substring(loggingIndex + 9)
|
||||
.trim()
|
||||
.replace(/\u200b/g, "\n"),
|
||||
};
|
||||
}
|
||||
|
||||
if (infoIndex != -1) {
|
||||
return {
|
||||
dateStamp: line.substring(0, 19),
|
||||
severity: "info",
|
||||
section: "startup",
|
||||
content: line.substring(infoIndex + 6).trim(),
|
||||
content: line
|
||||
.substring(infoIndex + 6)
|
||||
.trim()
|
||||
.replace(/\u200b/g, "\n"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -32,7 +48,10 @@ export function parseLogLines(logService: LogType, logs: string[]) {
|
||||
dateStamp: line.substring(0, 19),
|
||||
severity: "unknown",
|
||||
section: "unknown",
|
||||
content: line.substring(30).trim(),
|
||||
content: line
|
||||
.substring(30)
|
||||
.trim()
|
||||
.replace(/\u200b/g, "\n"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -54,7 +73,8 @@ export function parseLogLines(logService: LogType, logs: string[]) {
|
||||
section: sectionMatch.toString(),
|
||||
content: line
|
||||
.substring(line.indexOf(":", match.index + match[0].length) + 2)
|
||||
.trim(),
|
||||
.trim()
|
||||
.replace(/\u200b/g, "\n"),
|
||||
};
|
||||
})
|
||||
.filter((value) => value != null) as LogLine[];
|
||||
@@ -86,6 +106,15 @@ export function parseLogLines(logService: LogType, logs: string[]) {
|
||||
contentStart = line.indexOf(section) + section.length + 2;
|
||||
}
|
||||
|
||||
if (line.includes("[LOGGING]")) {
|
||||
return {
|
||||
dateStamp: line.substring(0, 19),
|
||||
severity: "info",
|
||||
section: "logging",
|
||||
content: line.substring(line.indexOf("[LOGGING]") + 9).trim(),
|
||||
};
|
||||
}
|
||||
|
||||
let severityCat: LogSeverity;
|
||||
switch (severity?.at(0)?.toString().trim()) {
|
||||
case "INF":
|
||||
@@ -116,18 +145,68 @@ export function parseLogLines(logService: LogType, logs: string[]) {
|
||||
} else if (logService == "nginx") {
|
||||
return logs
|
||||
.map((line) => {
|
||||
if (line.length == 0) {
|
||||
return null;
|
||||
}
|
||||
if (line.trim().length === 0) return null;
|
||||
|
||||
return {
|
||||
dateStamp: line.substring(0, 19),
|
||||
severity: "info",
|
||||
section: httpMethods.exec(line)?.at(0)?.toString() ?? "META",
|
||||
content: line.substring(line.indexOf(" ", 20)).trim(),
|
||||
};
|
||||
// Match full timestamp including nanoseconds
|
||||
const timestampRegex = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d+/;
|
||||
const timestampMatch = timestampRegex.exec(line);
|
||||
const fullTimestamp = timestampMatch ? timestampMatch[0] : "";
|
||||
// Remove nanoseconds from the final output
|
||||
const dateStamp = fullTimestamp.split(".")[0];
|
||||
|
||||
if (line.includes("[LOGGING]")) {
|
||||
return {
|
||||
dateStamp,
|
||||
severity: "info",
|
||||
section: "logging",
|
||||
content: line.slice(line.indexOf("[LOGGING]") + 9).trim(),
|
||||
};
|
||||
} else if (line.includes("[INFO]")) {
|
||||
return {
|
||||
dateStamp,
|
||||
severity: "info",
|
||||
section: "startup",
|
||||
content: line.slice(fullTimestamp.length).trim(),
|
||||
};
|
||||
} else if (line.includes("[error]")) {
|
||||
// Error log
|
||||
const errorMatch = line.match(/(\[error\].*?,.*request: "[^"]*")/);
|
||||
const content = errorMatch ? errorMatch[1] : line;
|
||||
return {
|
||||
dateStamp,
|
||||
severity: "error",
|
||||
section: "error",
|
||||
content,
|
||||
};
|
||||
} else if (
|
||||
line.includes("GET") ||
|
||||
line.includes("POST") ||
|
||||
line.includes("HTTP")
|
||||
) {
|
||||
// HTTP request log
|
||||
const httpMethodMatch = httpMethods.exec(line);
|
||||
const section = httpMethodMatch ? httpMethodMatch[0] : "META";
|
||||
const contentStart = line.indexOf('"', fullTimestamp.length);
|
||||
const content =
|
||||
contentStart !== -1 ? line.slice(contentStart).trim() : line;
|
||||
|
||||
return {
|
||||
dateStamp,
|
||||
severity: "info",
|
||||
section,
|
||||
content,
|
||||
};
|
||||
} else {
|
||||
// Fallback: unknown format
|
||||
return {
|
||||
dateStamp,
|
||||
severity: "unknown",
|
||||
section: "unknown",
|
||||
content: line.slice(fullTimestamp.length).trim(),
|
||||
};
|
||||
}
|
||||
})
|
||||
.filter((value) => value != null) as LogLine[];
|
||||
.filter((value) => value !== null) as LogLine[];
|
||||
}
|
||||
|
||||
return [];
|
||||
|
||||
Reference in New Issue
Block a user