Improve UI logs (#16434)

* use react-logviewer and backend streaming

* layout adjustments

* readd copy handler

* reorder and fix key

* add loading state

* handle frigate log consolidation

* handle newlines in sheet

* update react-logviewer

* fix scrolling and use chunked log download

* don't combine frigate log lines with timestamp

* basic deduplication

* use react-logviewer and backend streaming

* layout adjustments

* readd copy handler

* reorder and fix key

* add loading state

* handle frigate log consolidation

* handle newlines in sheet

* update react-logviewer

* fix scrolling and use chunked log download

* don't combine frigate log lines with timestamp

* basic deduplication

* move process logs function to services util

* improve layout and scrolling behavior

* clean up
This commit is contained in:
Josh Hawkins 2025-02-10 09:38:56 -06:00 committed by GitHub
parent e207b2f50b
commit 2a28964e63
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 813 additions and 377 deletions

View File

@ -1,3 +1,4 @@
aiofiles == 24.1.*
click == 8.1.*
# FastAPI
aiohttp == 3.11.2

View File

@ -1,5 +1,6 @@
"""Main api runner."""
import asyncio
import copy
import json
import logging
@ -10,12 +11,13 @@ from functools import reduce
from io import StringIO
from typing import Any, Optional
import aiofiles
import requests
import ruamel.yaml
from fastapi import APIRouter, Body, Path, Request, Response
from fastapi.encoders import jsonable_encoder
from fastapi.params import Depends
from fastapi.responses import JSONResponse, PlainTextResponse
from fastapi.responses import JSONResponse, PlainTextResponse, StreamingResponse
from markupsafe import escape
from peewee import operator
from prometheus_client import CONTENT_TYPE_LATEST, generate_latest
@ -35,6 +37,7 @@ from frigate.util.config import find_config_file
from frigate.util.services import (
ffprobe_stream,
get_nvidia_driver_info,
process_logs,
restart_frigate,
vainfo_hwaccel,
)
@ -455,9 +458,10 @@ def nvinfo():
@router.get("/logs/{service}", tags=[Tags.logs])
def logs(
async def logs(
service: str = Path(enum=["frigate", "nginx", "go2rtc"]),
download: Optional[str] = None,
stream: Optional[bool] = False,
start: Optional[int] = 0,
end: Optional[int] = None,
):
@ -476,6 +480,27 @@ def logs(
status_code=500,
)
async def stream_logs(file_path: str):
"""Asynchronously stream log lines."""
buffer = ""
try:
async with aiofiles.open(file_path, "r") as file:
await file.seek(0, 2)
while True:
line = await file.readline()
if line:
buffer += line
# Process logs only when there are enough lines in the buffer
if "\n" in buffer:
_, processed_lines = process_logs(buffer, service)
buffer = ""
for processed_line in processed_lines:
yield f"{processed_line}\n"
else:
await asyncio.sleep(0.1)
except FileNotFoundError:
yield "Log file not found.\n"
log_locations = {
"frigate": "/dev/shm/logs/frigate/current",
"go2rtc": "/dev/shm/logs/go2rtc/current",
@ -492,48 +517,17 @@ def logs(
if download:
return download_logs(service_location)
if stream:
return StreamingResponse(stream_logs(service_location), media_type="text/plain")
# For full logs initially
try:
file = open(service_location, "r")
contents = file.read()
file.close()
# 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
# handle cases where S6 does not include date in log line
if " " not in cleanLine:
cleanLine = f"{datetime.now()} {cleanLine}"
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)
async with aiofiles.open(service_location, "r") as file:
contents = await file.read()
total_lines, log_lines = process_logs(contents, service, start, end)
return JSONResponse(
content={"totalLines": len(logLines), "lines": logLines[start:end]},
content={"totalLines": total_lines, "lines": log_lines},
status_code=200,
)
except FileNotFoundError as e:

View File

@ -8,7 +8,8 @@ import re
import signal
import subprocess as sp
import traceback
from typing import Optional
from datetime import datetime
from typing import List, Optional, Tuple
import cv2
import psutil
@ -635,3 +636,54 @@ async def get_video_properties(
result["fourcc"] = fourcc
return result
def process_logs(
contents: str,
service: Optional[str] = None,
start: Optional[int] = None,
end: Optional[int] = None,
) -> Tuple[int, List[str]]:
log_lines = []
last_message = None
last_timestamp = None
repeat_count = 0
for raw_line in contents.splitlines():
clean_line = raw_line.strip()
if len(clean_line) < 10:
continue
# Handle cases where S6 does not include date in log line
if " " not in clean_line:
clean_line = f"{datetime.now()} {clean_line}"
# Find the position of the first double space to extract timestamp and message
date_end = clean_line.index(" ")
timestamp = clean_line[:date_end]
message_part = clean_line[date_end:].strip()
if message_part == last_message:
repeat_count += 1
continue
else:
if repeat_count > 0:
# Insert a deduplication message formatted the same way as logs
dedup_message = f"{last_timestamp} [LOGGING] Last message repeated {repeat_count} times"
log_lines.append(dedup_message)
repeat_count = 0
log_lines.append(clean_line)
last_timestamp = timestamp
last_message = message_part
# If there were repeated messages at the end, log the count
if repeat_count > 0:
dedup_message = (
f"{last_timestamp} [LOGGING] Last message repeated {repeat_count} times"
)
log_lines.append(dedup_message)
return len(log_lines), log_lines[start:end]

71
web/package-lock.json generated
View File

@ -10,6 +10,7 @@
"dependencies": {
"@cycjimmy/jsmpeg-player": "^6.1.1",
"@hookform/resolvers": "^3.9.0",
"@melloware/react-logviewer": "^6.1.2",
"@radix-ui/react-alert-dialog": "^1.1.2",
"@radix-ui/react-aspect-ratio": "^1.1.0",
"@radix-ui/react-checkbox": "^1.1.2",
@ -1002,6 +1003,22 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@melloware/react-logviewer": {
"version": "6.1.2",
"resolved": "https://registry.npmjs.org/@melloware/react-logviewer/-/react-logviewer-6.1.2.tgz",
"integrity": "sha512-WDw3VIGqhoXxDn93HFDicwRhi4+FQyaKiVTB07bWerT82gTgyWV7bOciVV33z25N3WJrz62j5FKVzvFZCu17/A==",
"license": "MPL-2.0",
"dependencies": {
"hotkeys-js": "3.13.9",
"mitt": "3.0.1",
"react-string-replace": "1.1.1",
"virtua": "0.39.3"
},
"peerDependencies": {
"react": ">=17.0.0",
"react-dom": ">=17.0.0"
}
},
"node_modules/@mswjs/interceptors": {
"version": "0.29.1",
"resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.29.1.tgz",
@ -5511,6 +5528,15 @@
"integrity": "sha512-wA66nnYFvQa1o4DO/BFgLNRKnBTVXpNeldGRBJ2Y0SvFtdwvFKCbqa9zhHoZLoxHhZ+jYsj3aIBkWQQCPNOhMw==",
"license": "Apache-2.0"
},
"node_modules/hotkeys-js": {
"version": "3.13.9",
"resolved": "https://registry.npmjs.org/hotkeys-js/-/hotkeys-js-3.13.9.tgz",
"integrity": "sha512-3TRCj9u9KUH6cKo25w4KIdBfdBfNRjfUwrljCLDC2XhmPDG0SjAZFcFZekpUZFmXzfYoGhFDcdx2gX/vUVtztQ==",
"license": "MIT",
"funding": {
"url": "https://jaywcjlove.github.io/#/sponsor"
}
},
"node_modules/html-encoding-sniffer": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz",
@ -6273,6 +6299,12 @@
"node": ">=16 || 14 >=14.17"
}
},
"node_modules/mitt": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz",
"integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==",
"license": "MIT"
},
"node_modules/mock-socket": {
"version": "9.3.1",
"resolved": "https://registry.npmjs.org/mock-socket/-/mock-socket-9.3.1.tgz",
@ -7425,6 +7457,15 @@
"react-dom": ">=16.8"
}
},
"node_modules/react-string-replace": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/react-string-replace/-/react-string-replace-1.1.1.tgz",
"integrity": "sha512-26TUbLzLfHQ5jO5N7y3Mx88eeKo0Ml0UjCQuX4BMfOd/JX+enQqlKpL1CZnmjeBRvQE8TR+ds9j1rqx9CxhKHQ==",
"license": "MIT",
"engines": {
"node": ">=0.12.0"
}
},
"node_modules/react-style-singleton": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.1.tgz",
@ -8766,6 +8807,36 @@
"react-dom": "^16.8 || ^17.0 || ^18.0"
}
},
"node_modules/virtua": {
"version": "0.39.3",
"resolved": "https://registry.npmjs.org/virtua/-/virtua-0.39.3.tgz",
"integrity": "sha512-Ep3aiJXSGPm1UUniThr5mGDfG0upAleP7pqQs5mvvCgM1wPhII1ZKa7eNCWAJRLkC+InpXKokKozyaaj/aMYOQ==",
"license": "MIT",
"peerDependencies": {
"react": ">=16.14.0",
"react-dom": ">=16.14.0",
"solid-js": ">=1.0",
"svelte": ">=5.0",
"vue": ">=3.2"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-dom": {
"optional": true
},
"solid-js": {
"optional": true
},
"svelte": {
"optional": true
},
"vue": {
"optional": true
}
}
},
"node_modules/vite": {
"version": "5.4.0",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.0.tgz",

View File

@ -16,6 +16,7 @@
"dependencies": {
"@cycjimmy/jsmpeg-player": "^6.1.1",
"@hookform/resolvers": "^3.9.0",
"@melloware/react-logviewer": "^6.1.2",
"@radix-ui/react-alert-dialog": "^1.1.2",
"@radix-ui/react-aspect-ratio": "^1.1.0",
"@radix-ui/react-checkbox": "^1.1.2",

View File

@ -0,0 +1,91 @@
import { useRef, useCallback, useEffect, type ReactNode } from "react";
import { ScrollFollow } from "@melloware/react-logviewer";
export type ScrollFollowProps = {
startFollowing?: boolean;
render: (renderProps: ScrollFollowRenderProps) => ReactNode;
onCustomScroll?: (
scrollTop: number,
scrollHeight: number,
clientHeight: number,
) => void;
};
export type ScrollFollowRenderProps = {
follow: boolean;
onScroll: (args: {
scrollTop: number;
scrollHeight: number;
clientHeight: number;
}) => void;
startFollowing: () => void;
stopFollowing: () => void;
onCustomScroll?: (
scrollTop: number,
scrollHeight: number,
clientHeight: number,
) => void;
};
const SCROLL_BUFFER = 5;
export default function EnhancedScrollFollow(props: ScrollFollowProps) {
const followRef = useRef(props.startFollowing || false);
const prevScrollTopRef = useRef<number | undefined>(undefined);
useEffect(() => {
prevScrollTopRef.current = undefined;
}, []);
const wrappedRender = useCallback(
(renderProps: ScrollFollowRenderProps) => {
const wrappedOnScroll = (args: {
scrollTop: number;
scrollHeight: number;
clientHeight: number;
}) => {
// Check if scrolling up and immediately stop following
if (
prevScrollTopRef.current !== undefined &&
args.scrollTop < prevScrollTopRef.current
) {
if (followRef.current) {
renderProps.stopFollowing();
followRef.current = false;
}
}
const bottomThreshold =
args.scrollHeight - args.clientHeight - SCROLL_BUFFER;
const isNearBottom = args.scrollTop >= bottomThreshold;
if (isNearBottom && !followRef.current) {
renderProps.startFollowing();
followRef.current = true;
} else if (!isNearBottom && followRef.current) {
renderProps.stopFollowing();
followRef.current = false;
}
prevScrollTopRef.current = args.scrollTop;
renderProps.onScroll(args);
if (props.onCustomScroll) {
props.onCustomScroll(
args.scrollTop,
args.scrollHeight,
args.clientHeight,
);
}
};
return props.render({
...renderProps,
onScroll: wrappedOnScroll,
follow: followRef.current,
});
},
[props],
);
return <ScrollFollow {...props} render={wrappedRender} />;
}

View File

@ -1,36 +1,73 @@
import { Button } from "../ui/button";
import { FaFilter } from "react-icons/fa";
import { FaCog } from "react-icons/fa";
import { isMobile } from "react-device-detect";
import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer";
import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
import { LogSeverity } from "@/types/log";
import { LogSettingsType, LogSeverity } from "@/types/log";
import { Label } from "../ui/label";
import { Switch } from "../ui/switch";
import { DropdownMenuSeparator } from "../ui/dropdown-menu";
import { cn } from "@/lib/utils";
import FilterSwitch from "./FilterSwitch";
type LogLevelFilterButtonProps = {
type LogSettingsButtonProps = {
selectedLabels?: LogSeverity[];
updateLabelFilter: (labels: LogSeverity[] | undefined) => void;
logSettings?: LogSettingsType;
setLogSettings: (logSettings: LogSettingsType) => void;
};
export function LogLevelFilterButton({
export function LogSettingsButton({
selectedLabels,
updateLabelFilter,
}: LogLevelFilterButtonProps) {
logSettings,
setLogSettings,
}: LogSettingsButtonProps) {
const trigger = (
<Button
size="sm"
className="flex items-center gap-2"
aria-label="Filter log level"
>
<FaFilter className="text-secondary-foreground" />
<div className="hidden text-primary md:block">Filter</div>
<FaCog className="text-secondary-foreground" />
<div className="hidden text-primary md:block">Settings</div>
</Button>
);
const content = (
<GeneralFilterContent
selectedLabels={selectedLabels}
updateLabelFilter={updateLabelFilter}
/>
<div className={cn("my-3 space-y-3 py-3 md:mt-0 md:py-0")}>
<div className="space-y-4">
<div className="space-y-0.5">
<div className="text-md">Filter</div>
<div className="space-y-1 text-xs text-muted-foreground">
Filter logs by severity.
</div>
</div>
<GeneralFilterContent
selectedLabels={selectedLabels}
updateLabelFilter={updateLabelFilter}
/>
</div>
<DropdownMenuSeparator />
<div className="space-y-4">
<div className="space-y-0.5">
<div className="text-md">Loading</div>
<div className="mt-2.5 flex flex-col gap-2.5">
<div className="space-y-1 text-xs text-muted-foreground">
When the log pane is scrolled to the bottom, new logs
automatically stream as they are added.
</div>
<FilterSwitch
label="Disable log streaming"
isChecked={logSettings?.disableStreaming ?? false}
onCheckedChange={(isChecked) => {
setLogSettings({
disableStreaming: isChecked,
});
}}
/>
</div>
</div>
</div>
</div>
);
if (isMobile) {
@ -63,7 +100,7 @@ export function GeneralFilterContent({
return (
<>
<div className="scrollbar-container h-auto overflow-y-auto overflow-x-hidden">
<div className="my-2.5 flex items-center justify-between">
<div className="mb-5 flex items-center justify-between">
<Label
className="mx-2 cursor-pointer text-primary"
htmlFor="allLabels"
@ -81,10 +118,9 @@ export function GeneralFilterContent({
}}
/>
</div>
<DropdownMenuSeparator />
<div className="my-2.5 flex flex-col gap-2.5">
{["debug", "info", "warning", "error"].map((item) => (
<div className="flex items-center justify-between">
<div className="flex items-center justify-between" key={item}>
<Label
className="mx-2 w-full cursor-pointer capitalize text-primary"
htmlFor={item}

View File

@ -70,17 +70,19 @@ export function LogChip({ severity, onClickSeverity }: LogChipProps) {
}, [severity]);
return (
<div
className={`rounded-md px-1 py-[1px] text-xs capitalize ${onClickSeverity ? "cursor-pointer" : ""} ${severityClassName}`}
onClick={(e) => {
e.stopPropagation();
<div className="min-w-16 lg:min-w-20">
<span
className={`rounded-md px-1 py-[1px] text-xs capitalize ${onClickSeverity ? "cursor-pointer" : ""} ${severityClassName}`}
onClick={(e) => {
e.stopPropagation();
if (onClickSeverity) {
onClickSeverity();
}
}}
>
{severity}
if (onClickSeverity) {
onClickSeverity();
}
}}
>
{severity}
</span>
</div>
);
}

View File

@ -66,7 +66,14 @@ export default function LogInfoDialog({
</div>
<div className="flex flex-col gap-1.5">
<div className="text-sm text-primary/40">Message</div>
<div className="text-sm">{logLine.content}</div>
<div className="text-sm">
{logLine.content.split("\n").map((line) => (
<>
{line}
<br />
</>
))}
</div>
</div>
{helpfulLinks.length > 0 && (
<div className="flex flex-col gap-1.5">

View File

@ -179,3 +179,8 @@ html {
border: 3px solid #a00000 !important;
opacity: 0.5 !important;
}
.react-lazylog,
.react-lazylog-searchbar {
background-color: transparent !important;
}

View File

@ -1,35 +1,47 @@
import { Button } from "@/components/ui/button";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { LogData, LogLine, LogSeverity, LogType, logTypes } from "@/types/log";
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 { LogLevelFilterButton } from "@/components/filter/LogLevelFilter";
import { FaCopy } from "react-icons/fa6";
import { LogSettingsButton } from "@/components/filter/LogSettingsButton";
import { FaCopy, FaDownload } from "react-icons/fa";
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";
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
import scrollIntoView from "scroll-into-view-if-needed";
import { FaDownload } from "react-icons/fa";
type LogRange = { start: number; end: number };
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";
function Logs() {
const [logService, setLogService] = useState<LogType>("frigate");
const tabsRef = useRef<HTMLDivElement | null>(null);
const lazyLogWrapperRef = useRef<HTMLDivElement>(null);
const [logs, setLogs] = useState<string[]>([]);
const [filterSeverity, setFilterSeverity] = useState<LogSeverity[]>();
const [selectedLog, setSelectedLog] = useState<LogLine>();
const lazyLogRef = useRef<LazyLog>(null);
const [isLoading, setIsLoading] = useState(true);
const lastFetchedIndexRef = useRef(-1);
useEffect(() => {
document.title = `${logService[0].toUpperCase()}${logService.substring(1)} Logs - Frigate`;
@ -49,92 +61,233 @@ function Logs() {
}
}, [tabsRef, logService]);
// log data handling
// log settings
const logPageSize = useMemo(() => {
if (isMobileOnly) {
return 15;
}
const [logSettings, setLogSettings] = useState<LogSettingsType>({
disableStreaming: false,
});
if (isTablet) {
return 25;
}
// filter
return 40;
}, []);
const filterLines = useCallback(
(lines: string[]) => {
if (!filterSeverity?.length) return lines;
const [logRange, setLogRange] = useState<LogRange>({ start: 0, end: 0 });
const [logs, setLogs] = useState<string[]>([]);
const [logLines, setLogLines] = useState<LogLine[]>([]);
return lines.filter((line) => {
const parsedLine = parseLogLines(logService, [line])[0];
return filterSeverity.includes(parsedLine.severity);
});
},
[filterSeverity, logService],
);
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));
// 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(`Error fetching logs: ${errorMessage}`, {
position: "top-center",
});
}
return [];
},
[logService, filterLines],
);
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(`Error fetching logs: ${errorMessage}`, {
position: "top-center",
});
} finally {
setIsLoading(false);
}
}, [logService, filterLines, filterSeverity]);
const abortControllerRef = useRef<AbortController | null>(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<Uint8Array>,
): Promise<void> => {
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<void> => {
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(() => {});
}, [logPageSize, logService]);
.catch((error) => {
if (error.name !== "AbortError") {
const errorMessage =
error instanceof Error
? error.message
: "An unknown error occurred";
toast.error(`Error while streaming logs: ${errorMessage}`);
}
});
}, [logService, filterSeverity]);
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);
setIsLoading(true);
setLogs([]);
lastFetchedIndexRef.current = -1;
fetchInitialLogs().then(() => {
// Start streaming after initial load
if (!logSettings.disableStreaming) {
fetchLogsStream();
}
});
return () => {
if (id) {
clearTimeout(id);
}
abortControllerRef.current?.abort();
};
// we need to listen on the current range of visible items
// we know that these deps are correct
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [logLines, logService, logRange]);
}, [logService, filterSeverity]);
// convert to log data
// 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) {
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");
if (logs.length) {
fetchInitialLogs()
.then(() => {
copy(logs.join("\n"));
toast.success("Copied logs to clipboard");
})
.catch(() => {
toast.error("Could not copy logs to clipboard");
});
}
}, [logs, logRange]);
}, [logs, fetchInitialLogs]);
const handleDownloadLogs = useCallback(() => {
axios
@ -157,153 +310,76 @@ function Logs() {
.catch(() => {});
}, [logService]);
// scroll to bottom
const [initialScroll, setInitialScroll] = useState(false);
const contentRef = useRef<HTMLDivElement | null>(null);
const [endVisible, setEndVisible] = useState(true);
const endObserver = useRef<IntersectionObserver | null>(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
}
const handleRowClick = useCallback(
(rowInfo: { lineNumber: number; rowIndex: number }) => {
const clickedLine = parseLogLines(logService, [
logs[rowInfo.rowIndex],
])[0];
setSelectedLog(clickedLine);
},
[setEndVisible],
);
const startObserver = useRef<IntersectionObserver | null>(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],
[logs, logService],
);
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<LogSeverity[]>();
// log selection
const [selectedLog, setSelectedLog] = useState<LogLine>();
// interaction
// keyboard listener
useKeyboardListener(
["PageDown", "PageUp", "ArrowDown", "ArrowUp"],
(key, modifiers) => {
if (!modifiers.down) {
if (!key || !modifiers.down || !lazyLogWrapperRef.current) {
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;
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<string>("");
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 (
<LogLineData
line={parsedLine}
logService={logService}
onClickSeverity={() => setFilterSeverity([parsedLine.severity])}
onSelect={() => setSelectedLog(parsedLine)}
/>
);
}
}
return null;
},
[logService, setFilterSeverity, setSelectedLog],
);
useEffect(() => {
const handleCopy = (e: ClipboardEvent) => {
e.preventDefault();
if (!contentRef.current) return;
if (!lazyLogWrapperRef.current) return;
const selection = window.getSelection();
if (!selection) return;
@ -371,7 +447,7 @@ function Logs() {
e.clipboardData?.setData("text/plain", copyText);
};
const content = contentRef.current;
const content = lazyLogWrapperRef.current;
content?.addEventListener("copy", handleCopy);
return () => {
content?.removeEventListener("copy", handleCopy);
@ -393,11 +469,10 @@ function Logs() {
onValueChange={(value: LogType) => {
if (value) {
setLogs([]);
setLogLines([]);
setFilterSeverity(undefined);
setLogService(value);
}
}} // don't allow the severity to be unselected
}}
>
{Object.values(logTypes).map((item) => (
<ToggleGroupItem
@ -435,128 +510,146 @@ function Logs() {
<FaDownload className="text-secondary-foreground" />
<div className="hidden text-primary md:block">Download</div>
</Button>
<LogLevelFilterButton
<LogSettingsButton
selectedLabels={filterSeverity}
updateLabelFilter={setFilterSeverity}
logSettings={logSettings}
setLogSettings={setLogSettings}
/>
</div>
</div>
{initialScroll && !endVisible && (
<Button
className="absolute bottom-8 left-[50%] z-20 flex -translate-x-[50%] items-center gap-1 rounded-md p-2"
aria-label="Jump to bottom of logs"
onClick={() =>
contentRef.current?.scrollTo({
top: contentRef.current?.scrollHeight,
behavior: "smooth",
})
}
>
<MdVerticalAlignBottom />
Jump to Bottom
</Button>
)}
<div className="font-mono relative my-2 flex size-full flex-col overflow-hidden whitespace-pre-wrap rounded-md border border-secondary bg-background_alt text-sm sm:p-2">
<div className="grid grid-cols-5 *:px-2 *:py-3 *:text-sm *:text-primary/40 sm:grid-cols-8 md:grid-cols-12">
<div className="flex items-center p-1 capitalize">Type</div>
<div className="col-span-2 flex items-center sm:col-span-1">
Timestamp
<div className="font-mono relative my-2 flex size-full flex-col overflow-hidden whitespace-pre-wrap rounded-md border border-secondary bg-background_alt text-xs sm:p-1">
<div className="grid grid-cols-5 *:px-0 *:py-3 *:text-sm *:text-primary/40 md:grid-cols-12">
<div className="col-span-3 lg:col-span-2">
<div className="flex w-full flex-row items-center">
<div className="ml-1 min-w-16 capitalize lg:min-w-20">Type</div>
<div className="mr-3">Timestamp</div>
</div>
</div>
<div className="col-span-2 flex items-center">Tag</div>
<div className="col-span-5 flex items-center sm:col-span-4 md:col-span-8">
Message
<div
className={cn(
"flex items-center",
logService == "frigate" ? "col-span-2" : "col-span-1",
)}
>
Tag
</div>
<div
className={cn(
"col-span-5 flex items-center",
logService == "frigate"
? "md:col-span-7 lg:col-span-8"
: "md:col-span-8 lg:col-span-9",
)}
>
<div className="flex flex-1">Message</div>
</div>
</div>
<div
ref={contentRef}
className="no-scrollbar flex w-full flex-col overflow-y-auto overscroll-contain"
>
{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 (
<div
ref={idx == logRange.start + 10 ? startLogRef : undefined}
/>
);
}
return (
<LogLineData
key={`${idx}-${logService}`}
startRef={
idx == logRange.start + 10 ? startLogRef : undefined
<div ref={lazyLogWrapperRef} className="size-full">
{isLoading ? (
<ActivityIndicator className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" />
) : (
<EnhancedScrollFollow
startFollowing={!isLoading}
onCustomScroll={handleScroll}
render={({ follow, onScroll }) => (
<>
{follow && !logSettings.disableStreaming && (
<div className="absolute right-1 top-3">
<Tooltip>
<TooltipTrigger>
<MdCircle className="mr-2 size-2 animate-pulse cursor-default text-selected shadow-selected drop-shadow-md" />
</TooltipTrigger>
<TooltipContent>
Logs are streaming from the server
</TooltipContent>
</Tooltip>
</div>
)}
<LazyLog
ref={lazyLogRef}
enableLineNumbers={false}
selectableLines
lineClassName="text-primary bg-background"
highlightLineClassName="bg-primary/20"
onRowClick={handleRowClick}
formatPart={formatPart}
text={logs.join("\n")}
follow={follow}
onScroll={onScroll}
loadingComponent={
<ActivityIndicator className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" />
}
className={initialScroll ? "" : "invisible"}
line={line}
onClickSeverity={() => setFilterSeverity([line.severity])}
onSelect={() => setSelectedLog(line)}
loading={isLoading}
/>
);
}
return (
<div
key={`${idx}-${logService}`}
className={isDesktop ? "h-12" : "h-16"}
/>
);
})}
{logLines.length > 0 && <div id="page-bottom" ref={endLogRef} />}
</>
)}
/>
)}
</div>
{logLines.length == 0 && (
<ActivityIndicator className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" />
)}
</div>
</div>
);
}
type LogLineDataProps = {
startRef?: (node: HTMLDivElement | null) => void;
className: string;
className?: string;
line: LogLine;
logService: string;
onClickSeverity: () => void;
onSelect: () => void;
};
function LogLineData({
startRef,
className,
line,
logService,
onClickSeverity,
onSelect,
}: LogLineDataProps) {
return (
<div
ref={startRef}
className={cn(
"grid w-full cursor-pointer grid-cols-5 gap-2 border-t border-secondary py-2 hover:bg-muted sm:grid-cols-8 md:grid-cols-12",
"grid w-full cursor-pointer grid-cols-5 gap-2 border-t border-secondary bg-background_alt py-1 hover:bg-muted md:grid-cols-12 md:py-0",
className,
"*:text-sm",
"text-xs lg:text-sm/5",
)}
onClick={onSelect}
>
<div className="log-severity flex h-full items-center gap-2 p-1">
<LogChip severity={line.severity} onClickSeverity={onClickSeverity} />
<div className="col-span-3 flex h-full items-center gap-2 lg:col-span-2">
<div className="flex w-full flex-row items-center">
<div className="log-severity p-1">
<LogChip
severity={line.severity}
onClickSeverity={onClickSeverity}
/>
</div>
<div className="log-timestamp whitespace-normal">
{line.dateStamp}
</div>
</div>
</div>
<div className="log-timestamp col-span-2 flex h-full items-center sm:col-span-1">
{line.dateStamp}
</div>
<div className="log-section col-span-2 flex size-full items-center pr-2">
<div
className={cn(
"log-section flex size-full items-center pr-2",
logService == "frigate" ? "col-span-2" : "col-span-1",
)}
>
<div className="w-full overflow-hidden text-ellipsis whitespace-nowrap">
{line.section}
</div>
</div>
<div className="log-content col-span-5 flex size-full items-center justify-between pl-2 pr-2 sm:col-span-4 sm:pl-0 md:col-span-8">
<div
className={cn(
"log-content col-span-5 flex size-full items-center justify-between px-2 md:px-0 md:pr-2",
logService == "frigate"
? "md:col-span-7 lg:col-span-8"
: "md:col-span-8 lg:col-span-9",
)}
>
<div className="w-full overflow-hidden text-ellipsis whitespace-nowrap">
{line.content}
</div>

View File

@ -14,3 +14,7 @@ export type LogLine = {
export const logTypes = ["frigate", "go2rtc", "nginx"] as const;
export type LogType = (typeof logTypes)[number];
export type LogSettingsType = {
disableStreaming: boolean;
};

View File

@ -18,13 +18,29 @@ export function parseLogLines(logService: LogType, logs: string[]) {
if (!match) {
const infoIndex = line.indexOf("[INFO]");
const loggingIndex = line.indexOf("[LOGGING]");
if (loggingIndex != -1) {
return {
dateStamp: line.substring(0, 19),
severity: "info",
section: "logging",
content: line
.substring(loggingIndex + 9)
.trim()
.replace(/\u200b/g, "\n"),
};
}
if (infoIndex != -1) {
return {
dateStamp: line.substring(0, 19),
severity: "info",
section: "startup",
content: line.substring(infoIndex + 6).trim(),
content: line
.substring(infoIndex + 6)
.trim()
.replace(/\u200b/g, "\n"),
};
}
@ -32,7 +48,10 @@ export function parseLogLines(logService: LogType, logs: string[]) {
dateStamp: line.substring(0, 19),
severity: "unknown",
section: "unknown",
content: line.substring(30).trim(),
content: line
.substring(30)
.trim()
.replace(/\u200b/g, "\n"),
};
}
@ -54,7 +73,8 @@ export function parseLogLines(logService: LogType, logs: string[]) {
section: sectionMatch.toString(),
content: line
.substring(line.indexOf(":", match.index + match[0].length) + 2)
.trim(),
.trim()
.replace(/\u200b/g, "\n"),
};
})
.filter((value) => value != null) as LogLine[];
@ -86,6 +106,15 @@ export function parseLogLines(logService: LogType, logs: string[]) {
contentStart = line.indexOf(section) + section.length + 2;
}
if (line.includes("[LOGGING]")) {
return {
dateStamp: line.substring(0, 19),
severity: "info",
section: "logging",
content: line.substring(line.indexOf("[LOGGING]") + 9).trim(),
};
}
let severityCat: LogSeverity;
switch (severity?.at(0)?.toString().trim()) {
case "INF":
@ -116,18 +145,68 @@ export function parseLogLines(logService: LogType, logs: string[]) {
} else if (logService == "nginx") {
return logs
.map((line) => {
if (line.length == 0) {
return null;
}
if (line.trim().length === 0) return null;
return {
dateStamp: line.substring(0, 19),
severity: "info",
section: httpMethods.exec(line)?.at(0)?.toString() ?? "META",
content: line.substring(line.indexOf(" ", 20)).trim(),
};
// Match full timestamp including nanoseconds
const timestampRegex = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d+/;
const timestampMatch = timestampRegex.exec(line);
const fullTimestamp = timestampMatch ? timestampMatch[0] : "";
// Remove nanoseconds from the final output
const dateStamp = fullTimestamp.split(".")[0];
if (line.includes("[LOGGING]")) {
return {
dateStamp,
severity: "info",
section: "logging",
content: line.slice(line.indexOf("[LOGGING]") + 9).trim(),
};
} else if (line.includes("[INFO]")) {
return {
dateStamp,
severity: "info",
section: "startup",
content: line.slice(fullTimestamp.length).trim(),
};
} else if (line.includes("[error]")) {
// Error log
const errorMatch = line.match(/(\[error\].*?,.*request: "[^"]*")/);
const content = errorMatch ? errorMatch[1] : line;
return {
dateStamp,
severity: "error",
section: "error",
content,
};
} else if (
line.includes("GET") ||
line.includes("POST") ||
line.includes("HTTP")
) {
// HTTP request log
const httpMethodMatch = httpMethods.exec(line);
const section = httpMethodMatch ? httpMethodMatch[0] : "META";
const contentStart = line.indexOf('"', fullTimestamp.length);
const content =
contentStart !== -1 ? line.slice(contentStart).trim() : line;
return {
dateStamp,
severity: "info",
section,
content,
};
} else {
// Fallback: unknown format
return {
dateStamp,
severity: "unknown",
section: "unknown",
content: line.slice(fullTimestamp.length).trim(),
};
}
})
.filter((value) => value != null) as LogLine[];
.filter((value) => value !== null) as LogLine[];
}
return [];