diff --git a/frigate/api/app.py b/frigate/api/app.py index 6fdedab90..3900aac05 100644 --- a/frigate/api/app.py +++ b/frigate/api/app.py @@ -9,14 +9,7 @@ from datetime import datetime, timedelta from functools import reduce import requests -from flask import ( - Blueprint, - Flask, - current_app, - jsonify, - make_response, - request, -) +from flask import Blueprint, Flask, current_app, jsonify, make_response, request from markupsafe import escape from peewee import operator from playhouse.sqliteq import SqliteQueueDatabase @@ -425,11 +418,49 @@ def logs(service: str): 404, ) + start = request.args.get("start", type=int, default=0) + end = request.args.get("end", type=int) + try: file = open(service_location, "r") contents = file.read() file.close() - return contents, 200 + + # use the start timestamp to group logs together`` + logLines = [] + keyLength = 0 + dateEnd = 0 + currentKey = "" + currentLine = "" + + for rawLine in contents.splitlines(): + cleanLine = rawLine.strip() + + if len(cleanLine) < 10: + continue + + if dateEnd == 0: + dateEnd = cleanLine.index(" ") + keyLength = dateEnd - (6 if service_location == "frigate" else 0) + + newKey = cleanLine[0:keyLength] + + if newKey == currentKey: + currentLine += f"\n{cleanLine[dateEnd:].strip()}" + continue + else: + if len(currentLine) > 0: + logLines.append(currentLine) + + currentKey = newKey + currentLine = cleanLine + + logLines.append(currentLine) + + return make_response( + jsonify({"totalLines": len(logLines), "lines": logLines[start:end]}), + 200, + ) except FileNotFoundError as e: logger.error(e) return make_response( diff --git a/web/src/pages/Logs.tsx b/web/src/pages/Logs.tsx index e8ca9002f..3983a38d2 100644 --- a/web/src/pages/Logs.tsx +++ b/web/src/pages/Logs.tsx @@ -1,56 +1,283 @@ 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, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { IoIosAlert } from "react-icons/io"; +import { GoAlertFill } from "react-icons/go"; import { LuCopy } from "react-icons/lu"; -import useSWR from "swr"; +import axios from "axios"; 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 )|(WARN )|(ERR )/; +const goSection = /\[[\w]*]/; + +const ngSeverity = /(GET)|(POST)|(PUT)|(PATCH)|(DELETE)/; + function Logs() { const [logService, setLogService] = useState("frigate"); - const { data: frigateLogs } = useSWR("logs/frigate", { - refreshInterval: 1000, - }); - const { data: go2rtcLogs } = useSWR("logs/go2rtc", { refreshInterval: 1000 }); - const { data: nginxLogs } = useSWR("logs/nginx", { refreshInterval: 1000 }); - const logs = useMemo(() => { - if (logService == "frigate") { - return frigateLogs; - } else if (logService == "go2rtc") { - return go2rtcLogs; - } else if (logService == "nginx") { - return nginxLogs; - } else { - return "unknown logs"; + // 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; } - }, [logService, frigateLogs, go2rtcLogs, nginxLogs]); + + 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 null; + } + + 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; + } + + return { + dateStamp: line.substring(0, 19), + severity: "INFO", + 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(() => { - copy(logs); + if (logs) { + copy(logs.join("\n")); + } }, [logs]); - // scroll to bottom button + // scroll to bottom + + const [initialScroll, setInitialScroll] = useState(false); const contentRef = useRef(null); const [endVisible, setEndVisible] = useState(true); - const observer = useRef(null); + const endObserver = useRef(null); const endLogRef = useCallback( (node: HTMLElement | null) => { - if (observer.current) observer.current.disconnect(); + if (endObserver.current) endObserver.current.disconnect(); try { - observer.current = new IntersectionObserver((entries) => { + endObserver.current = new IntersectionObserver((entries) => { setEndVisible(entries[0].isIntersecting); }); - if (node) observer.current.observe(node); + 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]); return (
@@ -60,9 +287,12 @@ function Logs() { type="single" size="sm" value={logService} - onValueChange={(value: LogType) => - value ? setLogService(value) : null - } // don't allow the severity to be unselected + onValueChange={(value: LogType) => { + if (value) { + setLogs([]); + setLogService(value); + } + }} // don't allow the severity to be unselected > {Object.values(logTypes).map((item) => (
- {!endVisible && ( + {initialScroll && !endVisible && ( + )} ); diff --git a/web/src/types/log.ts b/web/src/types/log.ts new file mode 100644 index 000000000..a9a4342ff --- /dev/null +++ b/web/src/types/log.ts @@ -0,0 +1,13 @@ +export type LogData = { + totalLines: number; + lines: string[]; +}; + +export type LogSeverity = "info" | "warning" | "error" | "debug"; + +export type LogLine = { + dateStamp: string; + severity: LogSeverity; + section: string; + content: string; +}; diff --git a/web/vite.config.ts b/web/vite.config.ts index 5afefa331..cc3ead707 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -12,24 +12,24 @@ export default defineConfig({ server: { proxy: { "/api": { - target: "http://localhost:5000", + target: "http://192.168.50.106:5000", ws: true, }, "/vod": { - target: "http://localhost:5000", + target: "http://192.168.50.106:5000", }, "/clips": { - target: "http://localhost:5000", + target: "http://192.168.50.106:5000", }, "/exports": { - target: "http://localhost:5000", + target: "http://192.168.50.106:5000", }, "/ws": { - target: "ws://localhost:5000", + target: "ws://192.168.50.106:5000", ws: true, }, "/live": { - target: "ws://localhost:5000", + target: "ws://192.168.50.106:5000", changeOrigin: true, ws: true, },