import { Button } from "@/components/ui/button"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import { LogData, LogLine, LogSeverity } 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 } from "react-device-detect"; import ActivityIndicator from "@/components/indicators/activity-indicator"; const logTypes = ["frigate", "go2rtc", "nginx"] as const; type LogType = (typeof logTypes)[number]; type LogRange = { start: number; end: number }; const frigateDateStamp = /\[[\d\s-:]*]/; const frigateSeverity = /(DEBUG)|(INFO)|(WARNING)|(ERROR)/; const frigateSection = /[\w.]*/; const goSeverity = /(DEB )|(INF )|(WRN )|(ERR )/; const goSection = /\[[\w]*]/; const ngSeverity = /(GET)|(POST)|(PUT)|(PATCH)|(DELETE)/; function Logs() { const [logService, setLogService] = useState("frigate"); useEffect(() => { document.title = `${logService[0].toUpperCase()}${logService.substring(1)} Stats - Frigate`; }, [logService]); // log data handling const [logRange, setLogRange] = useState({ start: 0, end: 0 }); const [logs, setLogs] = useState([]); useEffect(() => { axios .get(`logs/${logService}?start=-100`) .then((resp) => { if (resp.status == 200) { const data = resp.data as LogData; setLogRange({ start: Math.max(0, data.totalLines - 100), end: data.totalLines, }); setLogs(data.lines); } }) .catch(() => {}); }, [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]); } } }) .catch(() => {}); }, 5000); return () => { if (id) { clearTimeout(id); } }; }, [logs, logService, logRange]); // convert to log data const logLines = useMemo(() => { if (!logs) { return []; } if (logService == "frigate") { return logs .map((line) => { const match = frigateDateStamp.exec(line); if (!match) { const infoIndex = line.indexOf("[INFO]"); if (infoIndex != -1) { return { dateStamp: line.substring(0, 19), severity: "info", section: "startup", content: line.substring(infoIndex + 6).trim(), }; } return { dateStamp: line.substring(0, 19), severity: "unknown", section: "unknown", content: line.substring(30).trim(), }; } const sectionMatch = frigateSection.exec( line.substring(match.index + match[0].length).trim(), ); if (!sectionMatch) { return null; } return { dateStamp: match.toString().slice(1, -1), severity: frigateSeverity .exec(line) ?.at(0) ?.toString() ?.toLowerCase() as LogSeverity, section: sectionMatch.toString(), content: line .substring(line.indexOf(":", match.index + match[0].length) + 2) .trim(), }; }) .filter((value) => value != null) as LogLine[]; } else if (logService == "go2rtc") { return logs .map((line) => { if (line.length == 0) { return null; } const severity = goSeverity.exec(line); let section = goSection.exec(line)?.toString()?.slice(1, -1) ?? "startup"; if (frigateSeverity.exec(section)) { section = "startup"; } let contentStart; if (section == "startup") { if (severity) { contentStart = severity.index + severity[0].length; } else { contentStart = line.lastIndexOf("]") + 1; } } else { contentStart = line.indexOf(section) + section.length + 2; } let severityCat: LogSeverity; switch (severity?.at(0)?.toString().trim()) { case "INF": severityCat = "info"; break; case "WRN": severityCat = "warning"; break; case "ERR": severityCat = "error"; break; case "DBG": case "TRC": severityCat = "debug"; break; default: severityCat = "info"; } return { dateStamp: line.substring(0, 19), severity: severityCat, section: section, content: line.substring(contentStart).trim(), }; }) .filter((value) => value != null) as LogLine[]; } else if (logService == "nginx") { return logs .map((line) => { if (line.length == 0) { return null; } return { dateStamp: line.substring(0, 19), severity: "info", section: ngSeverity.exec(line)?.at(0)?.toString() ?? "META", content: line.substring(line.indexOf(" ", 20)).trim(), }; }) .filter((value) => value != null) as LogLine[]; } else { return []; } }, [logs, logService]); const handleCopyLogs = useCallback(() => { if (logs) { copy(logs.join("\n")); toast.success( logRange.start == 0 ? "Coplied 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 - 100); 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]); } } }) .catch(() => {}); } }); 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(); return (
{ if (value) { setLogs([]); 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;