import { Button } from "@/components/ui/button"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import { LogData, LogLine, 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 { 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"; type LogRange = { start: number; end: number }; function Logs() { const [logService, setLogService] = useState("frigate"); useEffect(() => { document.title = `${logService[0].toUpperCase()}${logService.substring(1)} Logs - Frigate`; }, [logService]); // log data handling const logPageSize = useMemo(() => { if (isMobileOnly) { return 15; } if (isTablet) { return 25; } return 40; }, []); const [logRange, setLogRange] = useState({ start: 0, end: 0 }); const [logs, setLogs] = useState([]); const [logLines, setLogLines] = useState([]); 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)); } }) .catch(() => {}); }, [logPageSize, logService]); 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); return () => { if (id) { clearTimeout(id); } }; // we need to listen on the current range of visible items // eslint-disable-next-line react-hooks/exhaustive-deps }, [logLines, logService, logRange]); // convert to log data 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"); } }, [logs, logRange]); // scroll to bottom const [initialScroll, setInitialScroll] = useState(false); const contentRef = useRef(null); const [endVisible, setEndVisible] = useState(true); const endObserver = useRef(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 } }, [setEndVisible], ); const startObserver = useRef(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], ); 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(); // log selection const [selectedLog, setSelectedLog] = useState(); // interaction useKeyboardListener( ["PageDown", "PageUp", "ArrowDown", "ArrowUp"], (key, modifiers) => { if (!modifiers.down) { 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; } }, ); return (
{ if (value) { setLogs([]); setLogLines([]); setFilterSeverity(undefined); setLogService(value); } }} // don't allow the severity to be unselected > {Object.values(logTypes).map((item) => (
{item}
))}
{initialScroll && !endVisible && ( )}
Type
Timestamp
Tag
Message
{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 (
); } return ( setFilterSeverity([line.severity])} onSelect={() => setSelectedLog(line)} /> ); } return (
); })} {logLines.length > 0 &&
}
{logLines.length == 0 && ( )}
); } type LogLineDataProps = { startRef?: (node: HTMLDivElement | null) => void; className: string; line: LogLine; onClickSeverity: () => void; onSelect: () => void; }; function LogLineData({ startRef, className, line, onClickSeverity, onSelect, }: LogLineDataProps) { return (
{line.dateStamp}
{line.section}
{line.content}
); } export default Logs;