use react-logviewer and backend streaming

This commit is contained in:
Josh Hawkins 2025-01-13 19:37:29 -06:00
parent 81bd956ae8
commit 5310874568
8 changed files with 514 additions and 414 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
@ -454,10 +456,53 @@ def nvinfo():
return JSONResponse(content=get_nvidia_driver_info())
def process_logs(
contents: str,
service: Optional[str] = None,
start: Optional[int] = None,
end: Optional[int] = None,
) -> list:
log_lines = []
key_length = 0
date_end = 0
current_key = ""
current_line = ""
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}"
if date_end == 0:
date_end = clean_line.index(" ")
key_length = date_end - (6 if service == "frigate" else 0)
new_key = clean_line[:key_length]
if new_key == current_key:
current_line += f"\n{clean_line[date_end:].strip()}"
continue
else:
if current_line:
log_lines.append(current_line)
current_key = new_key
current_line = clean_line
log_lines.append(current_line)
return log_lines[start:end]
@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 +521,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 +558,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()
log_lines = process_logs(contents, service, start, end)
return JSONResponse(
content={"totalLines": len(logLines), "lines": logLines[start:end]},
content={"totalLines": len(log_lines), "lines": log_lines},
status_code=200,
)
except FileNotFoundError as e:

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.1",
"@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.1",
"resolved": "https://registry.npmjs.org/@melloware/react-logviewer/-/react-logviewer-6.1.1.tgz",
"integrity": "sha512-bbjHvTAVDyQj1yE/56Y90FGH94bxOOQo0e/J2jTZTprAM11gchpcp5tTJUV5ZidmVmF4uUSUs/NtJxceA3Wrig==",
"license": "MPL-2.0",
"dependencies": {
"hotkeys-js": "3.13.9",
"mitt": "3.0.1",
"react-string-replace": "1.1.1",
"virtua": "0.39.2"
},
"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.2",
"resolved": "https://registry.npmjs.org/virtua/-/virtua-0.39.2.tgz",
"integrity": "sha512-KhDYmfDe36L1W5ir1b5+jeV40+u4bb5bRiZggWwGinreGXKnxxcLGdk+yVZlO5dNdBq/nxj4v4w6yxuwgLXSBg==",
"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.1",
"@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,69 @@
import { useRef, useCallback, ReactNode } from "react";
import { ScrollFollow } from "@melloware/react-logviewer";
export type ScrollFollowProps = {
startFollowing?: boolean;
render: (renderProps: ScrollFollowRenderProps) => ReactNode;
};
export type ScrollFollowRenderProps = {
follow: boolean;
onScroll: (args: {
scrollTop: number;
scrollHeight: number;
clientHeight: number;
}) => void;
startFollowing: () => void;
stopFollowing: () => void;
};
const SCROLL_BUFFER = 5; // Additional buffer for scroll checks
export default function EnhancedScrollFollow(props: ScrollFollowProps) {
const followRef = useRef(props.startFollowing || false);
const wrappedRender = useCallback(
(renderProps: ScrollFollowRenderProps) => {
const wrappedOnScroll = (args: {
scrollTop: number;
scrollHeight: number;
clientHeight: number;
}) => {
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;
}
renderProps.onScroll(args);
};
const wrappedStartFollowing = () => {
renderProps.startFollowing();
followRef.current = true;
};
const wrappedStopFollowing = () => {
renderProps.stopFollowing();
followRef.current = false;
};
return props.render({
...renderProps,
onScroll: wrappedOnScroll,
startFollowing: wrappedStartFollowing,
stopFollowing: wrappedStopFollowing,
follow: followRef.current,
});
},
[props],
);
return <ScrollFollow {...props} render={wrappedRender} />;
}

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,33 @@
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, LogSeverity, LogType, logTypes } from "@/types/log";
import copy from "copy-to-clipboard";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
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 } from "react-icons/fa6";
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";
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);
useEffect(() => {
document.title = `${logService[0].toUpperCase()}${logService.substring(1)} Logs - Frigate`;
@ -49,96 +47,20 @@ function Logs() {
}
}, [tabsRef, logService]);
// log data handling
const logPageSize = useMemo(() => {
if (isMobileOnly) {
return 15;
}
if (isTablet) {
return 25;
}
return 40;
}, []);
const [logRange, setLogRange] = useState<LogRange>({ start: 0, end: 0 });
const [logs, setLogs] = useState<string[]>([]);
const [logLines, setLogLines] = useState<LogLine[]>([]);
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
// handlers
const handleCopyLogs = useCallback(() => {
if (logs) {
if (logs.length) {
copy(logs.join("\n"));
toast.success(
logRange.start == 0
? "Copied logs to clipboard"
: "Copied visible logs to clipboard",
);
toast.success("Copied logs to clipboard");
} else {
toast.error("Could not copy logs to clipboard");
}
}, [logs, logRange]);
}, [logs]);
const handleDownloadLogs = useCallback(() => {
axios
.get(`logs/${logService}?download=true`)
.get(`api/logs/${logService}?download=true`)
.then((resp) => {
const element = document.createElement("a");
element.setAttribute(
@ -157,226 +79,192 @@ 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],
[logs, logService],
);
const startObserver = useRef<IntersectionObserver | null>(null);
const startLogRef = useCallback(
(node: HTMLElement | null) => {
if (startObserver.current) startObserver.current.disconnect();
if (logs.length == 0 || !initialScroll) {
return;
}
// filter
try {
startObserver.current = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && logRange.start > 0) {
const start = Math.max(0, logRange.start - logPageSize);
const filterLines = useCallback(
(lines: string[]) => {
// console.log(lines);
if (!filterSeverity?.length) return lines;
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,
return lines.filter((line) => {
// console.log(line);
const parsedLine = parseLogLines(logService, [line])[0];
return filterSeverity.includes(parsedLine.severity);
});
setLogs([...data.lines, ...logs]);
setLogLines([
...parseLogLines(logService, data.lines),
...logLines,
]);
},
[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<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);
})
.catch(() => {});
contentRef.current?.scrollBy({
top: 10,
: lines;
if (filteredLines.length > 0) {
lazyLogRef.current?.appendLines(filteredLines);
}
}
// Process next chunk
return processStreamChunk(reader);
});
}
},
{ rootMargin: `${10 * (isMobile ? 64 : 48)}px 0px 0px 0px` },
};
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}`,
);
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],
);
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(() => {
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",
fetchInitialLogs().then(() => {
// Start streaming after initial load
setIsStreaming(true);
fetchLogsStream();
});
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
return () => {
abortControllerRef.current?.abort();
setIsStreaming(false);
};
}, [fetchInitialLogs, fetchLogsStream]);
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 });
},
);
useEffect(() => {
const handleCopy = (e: ClipboardEvent) => {
e.preventDefault();
if (!contentRef.current) return;
// format lines
const selection = window.getSelection();
if (!selection) return;
const lineBufferRef = useRef<string>("");
const range = selection.getRangeAt(0);
const fragment = range.cloneContents();
const formatPart = useCallback(
(text: string) => {
lineBufferRef.current += text;
const extractLogData = (element: Element) => {
const severity =
element.querySelector(".log-severity")?.textContent?.trim() || "";
const dateStamp =
element.querySelector(".log-timestamp")?.textContent?.trim() || "";
const section =
element.querySelector(".log-section")?.textContent?.trim() || "";
const content =
element.querySelector(".log-content")?.textContent?.trim() || "";
if (text.endsWith("\n")) {
const completeLine = lineBufferRef.current.trim();
lineBufferRef.current = "";
return { severity, dateStamp, section, content };
};
let copyData: {
severity: string;
dateStamp: string;
section: string;
content: string;
}[] = [];
if (fragment.querySelectorAll(".grid").length > 0) {
// Multiple grid elements
copyData = Array.from(fragment.querySelectorAll(".grid")).map(
extractLogData,
if (completeLine) {
const parsedLine = parseLogLines(logService, [completeLine])[0];
return (
<LogLineData
line={parsedLine}
logService={logService}
onClickSeverity={() => setFilterSeverity([parsedLine.severity])}
onSelect={() => setSelectedLog(parsedLine)}
/>
);
} else {
// Try to find the closest grid element or use the first child element
const gridElement =
fragment.querySelector(".grid") || (fragment.firstChild as Element);
if (gridElement) {
const data = extractLogData(gridElement);
if (data.severity || data.dateStamp || data.section || data.content) {
copyData.push(data);
}
}
}
if (copyData.length === 0) return; // No valid data to copy
// Calculate maximum widths for each column
const maxWidths = {
severity: Math.max(...copyData.map((d) => d.severity.length)),
dateStamp: Math.max(...copyData.map((d) => d.dateStamp.length)),
section: Math.max(...copyData.map((d) => d.section.length)),
};
const pad = (str: string, length: number) => str.padEnd(length, " ");
// Create the formatted copy text
const copyText = copyData
.map(
(d) =>
`${pad(d.severity, maxWidths.severity)} | ${pad(d.dateStamp, maxWidths.dateStamp)} | ${pad(d.section, maxWidths.section)} | ${d.content}`,
)
.join("\n");
e.clipboardData?.setData("text/plain", copyText);
};
const content = contentRef.current;
content?.addEventListener("copy", handleCopy);
return () => {
content?.removeEventListener("copy", handleCopy);
};
}, []);
return null;
},
[logService, setFilterSeverity, setSelectedLog],
);
return (
<div className="flex size-full flex-col p-2">
@ -393,11 +281,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
@ -438,125 +325,111 @@ function Logs() {
<LogLevelFilterButton
selectedLabels={filterSeverity}
updateLabelFilter={setFilterSeverity}
// setStreaming={setStreaming}
/>
</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="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 sm:grid-cols-8 md:grid-cols-12">
<div className="ml-1 flex items-center p-1 capitalize">Type</div>
<div className="col-span-2 flex items-center sm:col-span-1">
Timestamp
</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>
</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
}
className={initialScroll ? "" : "invisible"}
line={line}
onClickSeverity={() => setFilterSeverity([line.severity])}
onSelect={() => setSelectedLog(line)}
/>
);
}
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" />
className={cn(
"flex items-center",
logService == "frigate" ? "col-span-2" : "col-span-1",
)}
>
Tag
</div>
<div
className={cn(
"col-span-4 flex items-center",
logService == "frigate" ? "md:col-span-8" : "md:col-span-9",
)}
>
<div className="flex flex-1">Message</div>
{isStreaming && (
<div className="flex flex-row justify-end">
<MdCircle className="mr-2 size-2 animate-pulse text-selected shadow-selected drop-shadow-md" />
</div>
)}
</div>
</div>
<div ref={lazyLogWrapperRef} className="size-full">
<EnhancedScrollFollow
startFollowing={true}
render={({ follow, onScroll }) => (
<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 />}
/>
)}
/>
</div>
</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-8 gap-2 border-t border-secondary py-0 hover:bg-muted md:grid-cols-12",
className,
"*:text-sm",
"*:text-xs",
)}
onClick={onSelect}
>
<div className="log-severity flex h-full items-center gap-2 p-1">
<LogChip severity={line.severity} onClickSeverity={onClickSeverity} />
</div>
<div className="log-timestamp col-span-2 flex h-full items-center sm:col-span-1">
<div className="log-timestamp col-span-1 flex h-full items-center">
{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-4 flex size-full items-center justify-between pr-2",
logService == "frigate" ? "md:col-span-8" : "md:col-span-9",
)}
>
<div className="w-full overflow-hidden text-ellipsis whitespace-nowrap">
{line.content}
</div>

View File

@ -116,18 +116,63 @@ 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;
// 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];
// Handle different types of lines
if (line.includes("[INFO]")) {
// Info log
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: line.substring(0, 19),
dateStamp,
severity: "info",
section: httpMethods.exec(line)?.at(0)?.toString() ?? "META",
content: line.substring(line.indexOf(" ", 20)).trim(),
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 [];