import { Button } from "@/components/ui/button"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; 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 { LogSettingsButton } from "@/components/filter/LogSettingsButton"; import { FaCopy, FaDownload } from "react-icons/fa"; import { Toaster } from "@/components/ui/sonner"; import { toast } from "sonner"; import ActivityIndicator from "@/components/indicators/activity-indicator"; import { cn } from "@/lib/utils"; import { parseLogLines } from "@/utils/logUtil"; import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"; import scrollIntoView from "scroll-into-view-if-needed"; 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"; import { isIOS, isMobile } from "react-device-detect"; import { isPWA } from "@/utils/isPWA"; import { isInIframe } from "@/utils/isIFrame"; import { useTranslation } from "react-i18next"; function Logs() { const { t } = useTranslation(["views/system"]); const [logService, setLogService] = useState("frigate"); const tabsRef = useRef(null); const lazyLogWrapperRef = useRef(null); const [logs, setLogs] = useState([]); const [filterSeverity, setFilterSeverity] = useState(); const [selectedLog, setSelectedLog] = useState(); const lazyLogRef = useRef(null); const [isLoading, setIsLoading] = useState(true); const lastFetchedIndexRef = useRef(-1); useEffect(() => { document.title = t("documentTitle.logs." + logService); }, [logService, t]); useEffect(() => { if (tabsRef.current) { const element = tabsRef.current.querySelector( `[data-nav-item="${logService}"]`, ); if (element instanceof HTMLElement) { scrollIntoView(element, { behavior: isMobile && isIOS && !isPWA && isInIframe ? "auto" : "smooth", inline: "start", }); } } }, [tabsRef, logService]); // log settings const [logSettings, setLogSettings] = useState({ disableStreaming: false, }); // filter const filterLines = useCallback( (lines: string[]) => { if (!filterSeverity?.length) return lines; return lines.filter((line) => { const parsedLine = parseLogLines(logService, [line])[0]; return filterSeverity.includes(parsedLine.severity); }); }, [filterSeverity, logService], ); // 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( t("logs.toast.error.fetchingLogsFailed", { errorMessage }), { position: "top-center", }, ); } return []; }, [logService, filterLines, t], ); 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(t("logs.toast.error.fetchingLogsFailed", { errorMessage }), { position: "top-center", }); } finally { setIsLoading(false); } }, [logService, filterLines, filterSeverity, t]); const abortControllerRef = useRef(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, ): Promise => { 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 => { 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((error) => { if (error.name !== "AbortError") { const errorMessage = error instanceof Error ? error.message : "An unknown error occurred"; toast.error( t("logs.toast.error.whileStreamingLogs", { errorMessage }), ); } }); }, [logService, filterSeverity, t]); useEffect(() => { setIsLoading(true); setLogs([]); lastFetchedIndexRef.current = -1; fetchInitialLogs().then(() => { // Start streaming after initial load if (!logSettings.disableStreaming) { fetchLogsStream(); } }); return () => { abortControllerRef.current?.abort(); }; // we know that these deps are correct // eslint-disable-next-line react-hooks/exhaustive-deps }, [logService, filterSeverity]); // 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.length) { fetchInitialLogs() .then(() => { copy(logs.join("\n")); toast.success(t("logs.copy.success")); }) .catch(() => { toast.error(t("logs.copy.error")); }); } }, [logs, fetchInitialLogs, t]); const handleDownloadLogs = useCallback(() => { axios .get(`logs/${logService}?download=true`) .then((resp) => { const element = document.createElement("a"); element.setAttribute( "href", "data:text/plain;charset=utf-8," + encodeURIComponent(resp.data), ); element.setAttribute("download", `${logService}-logs.txt`); element.style.display = "none"; document.body.appendChild(element); element.click(); document.body.removeChild(element); }) .catch(() => {}); }, [logService]); const handleRowClick = useCallback( (rowInfo: { lineNumber: number; rowIndex: number }) => { const clickedLine = parseLogLines(logService, [ logs[rowInfo.rowIndex], ])[0]; setSelectedLog(clickedLine); }, [logs, logService], ); // keyboard listener useKeyboardListener( ["PageDown", "PageUp", "ArrowDown", "ArrowUp"], (key, modifiers) => { if (!key || !modifiers.down || !lazyLogWrapperRef.current) { return true; } const container = lazyLogWrapperRef.current.querySelector(".react-lazylog"); const logLineHeight = container?.querySelector(".log-line")?.clientHeight; if (!logLineHeight) { return true; } const scrollAmount = key.includes("Page") ? logLineHeight * 10 : logLineHeight; const direction = key.includes("Down") ? 1 : -1; container?.scrollBy({ top: scrollAmount * direction }); return true; }, ); // format lines const lineBufferRef = useRef(""); 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 ( setFilterSeverity([parsedLine.severity])} onSelect={() => setSelectedLog(parsedLine)} /> ); } } return null; }, [logService, setFilterSeverity, setSelectedLog], ); useEffect(() => { const handleCopy = (e: ClipboardEvent) => { e.preventDefault(); if (!lazyLogWrapperRef.current) return; const selection = window.getSelection(); if (!selection) return; const range = selection.getRangeAt(0); const fragment = range.cloneContents(); const extractLogData = (element: Element) => { const severity = element.querySelector(".log-severity")?.textContent?.trim() || ""; const dateStamp = element.querySelector(".log-timestamp")?.textContent?.trim() || ""; const section = element.querySelector(".log-section")?.textContent?.trim() || ""; const content = element.querySelector(".log-content")?.textContent?.trim() || ""; return { severity, dateStamp, section, content }; }; let copyData: { severity: string; dateStamp: string; section: string; content: string; }[] = []; if (fragment.querySelectorAll(".grid").length > 0) { // Multiple grid elements copyData = Array.from(fragment.querySelectorAll(".grid")).map( extractLogData, ); } else { // Try to find the closest grid element or use the first child element const gridElement = fragment.querySelector(".grid") || (fragment.firstChild as Element); if (gridElement) { const data = extractLogData(gridElement); if (data.severity || data.dateStamp || data.section || data.content) { copyData.push(data); } } } if (copyData.length === 0) return; // No valid data to copy // Calculate maximum widths for each column const maxWidths = { severity: Math.max(...copyData.map((d) => d.severity.length)), dateStamp: Math.max(...copyData.map((d) => d.dateStamp.length)), section: Math.max(...copyData.map((d) => d.section.length)), }; const pad = (str: string, length: number) => str.padEnd(length, " "); // Create the formatted copy text const copyText = copyData .map( (d) => `${pad(d.severity, maxWidths.severity)} | ${pad(d.dateStamp, maxWidths.dateStamp)} | ${pad(d.section, maxWidths.section)} | ${d.content}`, ) .join("\n"); e.clipboardData?.setData("text/plain", copyText); }; const content = lazyLogWrapperRef.current; content?.addEventListener("copy", handleCopy); return () => { content?.removeEventListener("copy", handleCopy); }; }, []); return (
{ if (value) { setLogs([]); setFilterSeverity(undefined); setLogService(value); } }} > {Object.values(logTypes).map((item) => (
{item}
))}
{t("logs.type.label")}
{t("logs.type.timestamp")}
{t("logs.type.tag")}
{t("logs.type.message")}
{isLoading ? ( ) : ( ( <> {follow && !logSettings.disableStreaming && (
{t("logs.tips")}
)} } loading={isLoading} /> )} /> )}
); } type LogLineDataProps = { className?: string; line: LogLine; logService: string; onClickSeverity: () => void; onSelect: () => void; }; function LogLineData({ className, line, logService, onClickSeverity, onSelect, }: LogLineDataProps) { return (
{line.dateStamp}
{line.section}
{line.content}
); } export default Logs;