import { Button } from "@/components/ui/button"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import { LogLine, LogSeverity, LogType, logTypes } from "@/types/log"; import copy from "copy-to-clipboard"; import { useCallback, useEffect, 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, 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"; function Logs() { 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); useEffect(() => { document.title = `${logService[0].toUpperCase()}${logService.substring(1)} Logs - Frigate`; }, [logService]); useEffect(() => { if (tabsRef.current) { const element = tabsRef.current.querySelector( `[data-nav-item="${logService}"]`, ); if (element instanceof HTMLElement) { scrollIntoView(element, { behavior: "smooth", inline: "start", }); } } }, [tabsRef, logService]); // handlers const handleCopyLogs = useCallback(() => { if (logs.length) { copy(logs.join("\n")); toast.success("Copied logs to clipboard"); } else { toast.error("Could not copy logs to clipboard"); } }, [logs]); const handleDownloadLogs = useCallback(() => { axios .get(`api/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], ); // filter const filterLines = useCallback( (lines: string[]) => { // console.log(lines); if (!filterSeverity?.length) return lines; return lines.filter((line) => { // console.log(line); const parsedLine = parseLogLines(logService, [line])[0]; return filterSeverity.includes(parsedLine.severity); }); }, [filterSeverity, logService], ); // fetchers const fetchInitialLogs = useCallback(async () => { try { const response = await axios.get(`logs/${logService}`); if ( response.status === 200 && response.data && Array.isArray(response.data.lines) ) { const filteredLines = filterLines(response.data.lines); setLogs(filteredLines); } } catch (error) { const errorMessage = error instanceof Error ? error.message : "An unknown error occurred"; toast.error(`Error fetching logs: ${errorMessage}`, { position: "top-center", }); } }, [logService, filterLines]); const [isStreaming, setIsStreaming] = useState(false); 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(`Error while streaming logs: ${errorMessage}`); setIsStreaming(false); } }); }, [logService, filterSeverity]); useEffect(() => { fetchInitialLogs().then(() => { // Start streaming after initial load setIsStreaming(true); fetchLogsStream(); }); return () => { abortControllerRef.current?.abort(); setIsStreaming(false); }; }, [fetchInitialLogs, fetchLogsStream]); // keyboard listener useKeyboardListener( ["PageDown", "PageUp", "ArrowDown", "ArrowUp"], (key, modifiers) => { if (!key || !modifiers.down || !lazyLogWrapperRef.current) { return; } 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(""); 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], ); return (
{ if (value) { setLogs([]); setFilterSeverity(undefined); setLogService(value); } }} > {Object.values(logTypes).map((item) => (
{item}
))}
Type
Timestamp
Tag
Message
{isStreaming && (
)}
( } /> )} />
); } 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;