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.* click == 8.1.*
# FastAPI # FastAPI
aiohttp == 3.11.2 aiohttp == 3.11.2

View File

@ -1,5 +1,6 @@
"""Main api runner.""" """Main api runner."""
import asyncio
import copy import copy
import json import json
import logging import logging
@ -10,12 +11,13 @@ from functools import reduce
from io import StringIO from io import StringIO
from typing import Any, Optional from typing import Any, Optional
import aiofiles
import requests import requests
import ruamel.yaml import ruamel.yaml
from fastapi import APIRouter, Body, Path, Request, Response from fastapi import APIRouter, Body, Path, Request, Response
from fastapi.encoders import jsonable_encoder from fastapi.encoders import jsonable_encoder
from fastapi.params import Depends from fastapi.params import Depends
from fastapi.responses import JSONResponse, PlainTextResponse from fastapi.responses import JSONResponse, PlainTextResponse, StreamingResponse
from markupsafe import escape from markupsafe import escape
from peewee import operator from peewee import operator
from prometheus_client import CONTENT_TYPE_LATEST, generate_latest 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 ( from frigate.util.services import (
ffprobe_stream, ffprobe_stream,
get_nvidia_driver_info, get_nvidia_driver_info,
process_logs,
restart_frigate, restart_frigate,
vainfo_hwaccel, vainfo_hwaccel,
) )
@ -455,9 +458,10 @@ def nvinfo():
@router.get("/logs/{service}", tags=[Tags.logs]) @router.get("/logs/{service}", tags=[Tags.logs])
def logs( async def logs(
service: str = Path(enum=["frigate", "nginx", "go2rtc"]), service: str = Path(enum=["frigate", "nginx", "go2rtc"]),
download: Optional[str] = None, download: Optional[str] = None,
stream: Optional[bool] = False,
start: Optional[int] = 0, start: Optional[int] = 0,
end: Optional[int] = None, end: Optional[int] = None,
): ):
@ -476,6 +480,27 @@ def logs(
status_code=500, 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 = { log_locations = {
"frigate": "/dev/shm/logs/frigate/current", "frigate": "/dev/shm/logs/frigate/current",
"go2rtc": "/dev/shm/logs/go2rtc/current", "go2rtc": "/dev/shm/logs/go2rtc/current",
@ -492,48 +517,17 @@ def logs(
if download: if download:
return download_logs(service_location) return download_logs(service_location)
if stream:
return StreamingResponse(stream_logs(service_location), media_type="text/plain")
# For full logs initially
try: try:
file = open(service_location, "r") async with aiofiles.open(service_location, "r") as file:
contents = file.read() contents = await 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)
total_lines, log_lines = process_logs(contents, service, start, end)
return JSONResponse( return JSONResponse(
content={"totalLines": len(logLines), "lines": logLines[start:end]}, content={"totalLines": total_lines, "lines": log_lines},
status_code=200, status_code=200,
) )
except FileNotFoundError as e: except FileNotFoundError as e:

View File

@ -8,7 +8,8 @@ import re
import signal import signal
import subprocess as sp import subprocess as sp
import traceback import traceback
from typing import Optional from datetime import datetime
from typing import List, Optional, Tuple
import cv2 import cv2
import psutil import psutil
@ -635,3 +636,54 @@ async def get_video_properties(
result["fourcc"] = fourcc result["fourcc"] = fourcc
return result 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": { "dependencies": {
"@cycjimmy/jsmpeg-player": "^6.1.1", "@cycjimmy/jsmpeg-player": "^6.1.1",
"@hookform/resolvers": "^3.9.0", "@hookform/resolvers": "^3.9.0",
"@melloware/react-logviewer": "^6.1.2",
"@radix-ui/react-alert-dialog": "^1.1.2", "@radix-ui/react-alert-dialog": "^1.1.2",
"@radix-ui/react-aspect-ratio": "^1.1.0", "@radix-ui/react-aspect-ratio": "^1.1.0",
"@radix-ui/react-checkbox": "^1.1.2", "@radix-ui/react-checkbox": "^1.1.2",
@ -1002,6 +1003,22 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@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": { "node_modules/@mswjs/interceptors": {
"version": "0.29.1", "version": "0.29.1",
"resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.29.1.tgz", "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.29.1.tgz",
@ -5511,6 +5528,15 @@
"integrity": "sha512-wA66nnYFvQa1o4DO/BFgLNRKnBTVXpNeldGRBJ2Y0SvFtdwvFKCbqa9zhHoZLoxHhZ+jYsj3aIBkWQQCPNOhMw==", "integrity": "sha512-wA66nnYFvQa1o4DO/BFgLNRKnBTVXpNeldGRBJ2Y0SvFtdwvFKCbqa9zhHoZLoxHhZ+jYsj3aIBkWQQCPNOhMw==",
"license": "Apache-2.0" "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": { "node_modules/html-encoding-sniffer": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", "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": ">=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": { "node_modules/mock-socket": {
"version": "9.3.1", "version": "9.3.1",
"resolved": "https://registry.npmjs.org/mock-socket/-/mock-socket-9.3.1.tgz", "resolved": "https://registry.npmjs.org/mock-socket/-/mock-socket-9.3.1.tgz",
@ -7425,6 +7457,15 @@
"react-dom": ">=16.8" "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": { "node_modules/react-style-singleton": {
"version": "2.2.1", "version": "2.2.1",
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.1.tgz", "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" "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": { "node_modules/vite": {
"version": "5.4.0", "version": "5.4.0",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.0.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.0.tgz",

View File

@ -16,6 +16,7 @@
"dependencies": { "dependencies": {
"@cycjimmy/jsmpeg-player": "^6.1.1", "@cycjimmy/jsmpeg-player": "^6.1.1",
"@hookform/resolvers": "^3.9.0", "@hookform/resolvers": "^3.9.0",
"@melloware/react-logviewer": "^6.1.2",
"@radix-ui/react-alert-dialog": "^1.1.2", "@radix-ui/react-alert-dialog": "^1.1.2",
"@radix-ui/react-aspect-ratio": "^1.1.0", "@radix-ui/react-aspect-ratio": "^1.1.0",
"@radix-ui/react-checkbox": "^1.1.2", "@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 { Button } from "../ui/button";
import { FaFilter } from "react-icons/fa"; import { FaCog } from "react-icons/fa";
import { isMobile } from "react-device-detect"; import { isMobile } from "react-device-detect";
import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer"; import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer";
import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover"; import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
import { LogSeverity } from "@/types/log"; import { LogSettingsType, LogSeverity } from "@/types/log";
import { Label } from "../ui/label"; import { Label } from "../ui/label";
import { Switch } from "../ui/switch"; import { Switch } from "../ui/switch";
import { DropdownMenuSeparator } from "../ui/dropdown-menu"; import { DropdownMenuSeparator } from "../ui/dropdown-menu";
import { cn } from "@/lib/utils";
import FilterSwitch from "./FilterSwitch";
type LogLevelFilterButtonProps = { type LogSettingsButtonProps = {
selectedLabels?: LogSeverity[]; selectedLabels?: LogSeverity[];
updateLabelFilter: (labels: LogSeverity[] | undefined) => void; updateLabelFilter: (labels: LogSeverity[] | undefined) => void;
logSettings?: LogSettingsType;
setLogSettings: (logSettings: LogSettingsType) => void;
}; };
export function LogLevelFilterButton({ export function LogSettingsButton({
selectedLabels, selectedLabels,
updateLabelFilter, updateLabelFilter,
}: LogLevelFilterButtonProps) { logSettings,
setLogSettings,
}: LogSettingsButtonProps) {
const trigger = ( const trigger = (
<Button <Button
size="sm" size="sm"
className="flex items-center gap-2" className="flex items-center gap-2"
aria-label="Filter log level" aria-label="Filter log level"
> >
<FaFilter className="text-secondary-foreground" /> <FaCog className="text-secondary-foreground" />
<div className="hidden text-primary md:block">Filter</div> <div className="hidden text-primary md:block">Settings</div>
</Button> </Button>
); );
const content = ( const content = (
<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 <GeneralFilterContent
selectedLabels={selectedLabels} selectedLabels={selectedLabels}
updateLabelFilter={updateLabelFilter} 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) { if (isMobile) {
@ -63,7 +100,7 @@ export function GeneralFilterContent({
return ( return (
<> <>
<div className="scrollbar-container h-auto overflow-y-auto overflow-x-hidden"> <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 <Label
className="mx-2 cursor-pointer text-primary" className="mx-2 cursor-pointer text-primary"
htmlFor="allLabels" htmlFor="allLabels"
@ -81,10 +118,9 @@ export function GeneralFilterContent({
}} }}
/> />
</div> </div>
<DropdownMenuSeparator />
<div className="my-2.5 flex flex-col gap-2.5"> <div className="my-2.5 flex flex-col gap-2.5">
{["debug", "info", "warning", "error"].map((item) => ( {["debug", "info", "warning", "error"].map((item) => (
<div className="flex items-center justify-between"> <div className="flex items-center justify-between" key={item}>
<Label <Label
className="mx-2 w-full cursor-pointer capitalize text-primary" className="mx-2 w-full cursor-pointer capitalize text-primary"
htmlFor={item} htmlFor={item}

View File

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

View File

@ -66,7 +66,14 @@ export default function LogInfoDialog({
</div> </div>
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<div className="text-sm text-primary/40">Message</div> <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> </div>
{helpfulLinks.length > 0 && ( {helpfulLinks.length > 0 && (
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">

View File

@ -179,3 +179,8 @@ html {
border: 3px solid #a00000 !important; border: 3px solid #a00000 !important;
opacity: 0.5 !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 { Button } from "@/components/ui/button";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; 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 copy from "copy-to-clipboard";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import axios from "axios"; import axios from "axios";
import LogInfoDialog from "@/components/overlay/LogInfoDialog"; import LogInfoDialog from "@/components/overlay/LogInfoDialog";
import { LogChip } from "@/components/indicators/Chip"; import { LogChip } from "@/components/indicators/Chip";
import { LogLevelFilterButton } from "@/components/filter/LogLevelFilter"; import { LogSettingsButton } from "@/components/filter/LogSettingsButton";
import { FaCopy } from "react-icons/fa6"; import { FaCopy, FaDownload } from "react-icons/fa";
import { Toaster } from "@/components/ui/sonner"; import { Toaster } from "@/components/ui/sonner";
import { toast } from "sonner"; import { toast } from "sonner";
import {
isDesktop,
isMobile,
isMobileOnly,
isTablet,
} from "react-device-detect";
import ActivityIndicator from "@/components/indicators/activity-indicator"; import ActivityIndicator from "@/components/indicators/activity-indicator";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { MdVerticalAlignBottom } from "react-icons/md";
import { parseLogLines } from "@/utils/logUtil"; import { parseLogLines } from "@/utils/logUtil";
import useKeyboardListener from "@/hooks/use-keyboard-listener";
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"; import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
import scrollIntoView from "scroll-into-view-if-needed"; import scrollIntoView from "scroll-into-view-if-needed";
import { FaDownload } from "react-icons/fa"; import { LazyLog } from "@melloware/react-logviewer";
import useKeyboardListener from "@/hooks/use-keyboard-listener";
type LogRange = { start: number; end: number }; 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() { function Logs() {
const [logService, setLogService] = useState<LogType>("frigate"); const [logService, setLogService] = useState<LogType>("frigate");
const tabsRef = useRef<HTMLDivElement | null>(null); 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(() => { useEffect(() => {
document.title = `${logService[0].toUpperCase()}${logService.substring(1)} Logs - Frigate`; document.title = `${logService[0].toUpperCase()}${logService.substring(1)} Logs - Frigate`;
@ -49,92 +61,233 @@ function Logs() {
} }
}, [tabsRef, logService]); }, [tabsRef, logService]);
// log data handling // log settings
const logPageSize = useMemo(() => { const [logSettings, setLogSettings] = useState<LogSettingsType>({
if (isMobileOnly) { disableStreaming: false,
return 15; });
// filter
const filterLines = useCallback(
(lines: string[]) => {
if (!filterSeverity?.length) return lines;
return lines.filter((line) => {
const parsedLine = parseLogLines(logService, [line])[0];
return filterSeverity.includes(parsedLine.severity);
});
},
[filterSeverity, logService],
);
// 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) {
if (isTablet) { const errorMessage =
return 25; error instanceof Error ? error.message : "An unknown error occurred";
toast.error(`Error fetching logs: ${errorMessage}`, {
position: "top-center",
});
} }
return [];
},
[logService, filterLines],
);
return 40; 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 [logRange, setLogRange] = useState<LogRange>({ start: 0, end: 0 }); const abortControllerRef = useRef<AbortController | null>(null);
const [logs, setLogs] = useState<string[]>([]);
const [logLines, setLogLines] = useState<LogLine[]>([]); 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((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(() => { useEffect(() => {
axios setIsLoading(true);
.get(`logs/${logService}?start=-${logPageSize}`) setLogs([]);
.then((resp) => { lastFetchedIndexRef.current = -1;
if (resp.status == 200) { fetchInitialLogs().then(() => {
const data = resp.data as LogData; // Start streaming after initial load
setLogRange({ if (!logSettings.disableStreaming) {
start: Math.max(0, data.totalLines - logPageSize), fetchLogsStream();
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 () => { return () => {
if (id) { abortControllerRef.current?.abort();
clearTimeout(id);
}
}; };
// 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 // 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(() => { const handleCopyLogs = useCallback(() => {
if (logs) { if (logs.length) {
fetchInitialLogs()
.then(() => {
copy(logs.join("\n")); copy(logs.join("\n"));
toast.success( toast.success("Copied logs to clipboard");
logRange.start == 0 })
? "Copied logs to clipboard" .catch(() => {
: "Copied visible logs to clipboard",
);
} else {
toast.error("Could not copy logs to clipboard"); toast.error("Could not copy logs to clipboard");
});
} }
}, [logs, logRange]); }, [logs, fetchInitialLogs]);
const handleDownloadLogs = useCallback(() => { const handleDownloadLogs = useCallback(() => {
axios axios
@ -157,153 +310,76 @@ function Logs() {
.catch(() => {}); .catch(() => {});
}, [logService]); }, [logService]);
// scroll to bottom const handleRowClick = useCallback(
(rowInfo: { lineNumber: number; rowIndex: number }) => {
const [initialScroll, setInitialScroll] = useState(false); const clickedLine = parseLogLines(logService, [
logs[rowInfo.rowIndex],
const contentRef = useRef<HTMLDivElement | null>(null); ])[0];
const [endVisible, setEndVisible] = useState(true); setSelectedLog(clickedLine);
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
}
}, },
[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;
}
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(() => { // keyboard listener
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
useKeyboardListener( useKeyboardListener(
["PageDown", "PageUp", "ArrowDown", "ArrowUp"], ["PageDown", "PageUp", "ArrowDown", "ArrowUp"],
(key, modifiers) => { (key, modifiers) => {
if (!modifiers.down) { if (!key || !modifiers.down || !lazyLogWrapperRef.current) {
return; return;
} }
switch (key) { const container =
case "PageDown": lazyLogWrapperRef.current.querySelector(".react-lazylog");
contentRef.current?.scrollBy({
top: 480, const logLineHeight = container?.querySelector(".log-line")?.clientHeight;
});
break; if (!logLineHeight) {
case "PageUp": return;
contentRef.current?.scrollBy({
top: -480,
});
break;
case "ArrowDown":
contentRef.current?.scrollBy({
top: 48,
});
break;
case "ArrowUp":
contentRef.current?.scrollBy({
top: -48,
});
break;
} }
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(() => { useEffect(() => {
const handleCopy = (e: ClipboardEvent) => { const handleCopy = (e: ClipboardEvent) => {
e.preventDefault(); e.preventDefault();
if (!contentRef.current) return; if (!lazyLogWrapperRef.current) return;
const selection = window.getSelection(); const selection = window.getSelection();
if (!selection) return; if (!selection) return;
@ -371,7 +447,7 @@ function Logs() {
e.clipboardData?.setData("text/plain", copyText); e.clipboardData?.setData("text/plain", copyText);
}; };
const content = contentRef.current; const content = lazyLogWrapperRef.current;
content?.addEventListener("copy", handleCopy); content?.addEventListener("copy", handleCopy);
return () => { return () => {
content?.removeEventListener("copy", handleCopy); content?.removeEventListener("copy", handleCopy);
@ -393,11 +469,10 @@ function Logs() {
onValueChange={(value: LogType) => { onValueChange={(value: LogType) => {
if (value) { if (value) {
setLogs([]); setLogs([]);
setLogLines([]);
setFilterSeverity(undefined); setFilterSeverity(undefined);
setLogService(value); setLogService(value);
} }
}} // don't allow the severity to be unselected }}
> >
{Object.values(logTypes).map((item) => ( {Object.values(logTypes).map((item) => (
<ToggleGroupItem <ToggleGroupItem
@ -435,128 +510,146 @@ function Logs() {
<FaDownload className="text-secondary-foreground" /> <FaDownload className="text-secondary-foreground" />
<div className="hidden text-primary md:block">Download</div> <div className="hidden text-primary md:block">Download</div>
</Button> </Button>
<LogLevelFilterButton <LogSettingsButton
selectedLabels={filterSeverity} selectedLabels={filterSeverity}
updateLabelFilter={setFilterSeverity} updateLabelFilter={setFilterSeverity}
logSettings={logSettings}
setLogSettings={setLogSettings}
/> />
</div> </div>
</div> </div>
{initialScroll && !endVisible && ( <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">
<Button <div className="grid grid-cols-5 *:px-0 *:py-3 *:text-sm *:text-primary/40 md:grid-cols-12">
className="absolute bottom-8 left-[50%] z-20 flex -translate-x-[50%] items-center gap-1 rounded-md p-2" <div className="col-span-3 lg:col-span-2">
aria-label="Jump to bottom of logs" <div className="flex w-full flex-row items-center">
onClick={() => <div className="ml-1 min-w-16 capitalize lg:min-w-20">Type</div>
contentRef.current?.scrollTo({ <div className="mr-3">Timestamp</div>
top: contentRef.current?.scrollHeight, </div>
behavior: "smooth", </div>
}) <div
} className={cn(
> "flex items-center",
<MdVerticalAlignBottom /> logService == "frigate" ? "col-span-2" : "col-span-1",
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>
<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 && Tag
[...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> </div>
{logLines.length == 0 && ( <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={lazyLogWrapperRef} className="size-full">
{isLoading ? (
<ActivityIndicator className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" /> <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" />
}
loading={isLoading}
/>
</>
)}
/>
)}
</div>
</div> </div>
</div> </div>
); );
} }
type LogLineDataProps = { type LogLineDataProps = {
startRef?: (node: HTMLDivElement | null) => void; className?: string;
className: string;
line: LogLine; line: LogLine;
logService: string;
onClickSeverity: () => void; onClickSeverity: () => void;
onSelect: () => void; onSelect: () => void;
}; };
function LogLineData({ function LogLineData({
startRef,
className, className,
line, line,
logService,
onClickSeverity, onClickSeverity,
onSelect, onSelect,
}: LogLineDataProps) { }: LogLineDataProps) {
return ( return (
<div <div
ref={startRef}
className={cn( 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, className,
"*:text-sm", "text-xs lg:text-sm/5",
)} )}
onClick={onSelect} onClick={onSelect}
> >
<div className="log-severity flex h-full items-center gap-2 p-1"> <div className="col-span-3 flex h-full items-center gap-2 lg:col-span-2">
<LogChip severity={line.severity} onClickSeverity={onClickSeverity} /> <div className="flex w-full flex-row items-center">
<div className="log-severity p-1">
<LogChip
severity={line.severity}
onClickSeverity={onClickSeverity}
/>
</div> </div>
<div className="log-timestamp col-span-2 flex h-full items-center sm:col-span-1"> <div className="log-timestamp whitespace-normal">
{line.dateStamp} {line.dateStamp}
</div> </div>
<div className="log-section col-span-2 flex size-full items-center pr-2"> </div>
</div>
<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"> <div className="w-full overflow-hidden text-ellipsis whitespace-nowrap">
{line.section} {line.section}
</div> </div>
</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"> <div className="w-full overflow-hidden text-ellipsis whitespace-nowrap">
{line.content} {line.content}
</div> </div>

View File

@ -14,3 +14,7 @@ export type LogLine = {
export const logTypes = ["frigate", "go2rtc", "nginx"] as const; export const logTypes = ["frigate", "go2rtc", "nginx"] as const;
export type LogType = (typeof logTypes)[number]; 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) { if (!match) {
const infoIndex = line.indexOf("[INFO]"); 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) { if (infoIndex != -1) {
return { return {
dateStamp: line.substring(0, 19), dateStamp: line.substring(0, 19),
severity: "info", severity: "info",
section: "startup", 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), dateStamp: line.substring(0, 19),
severity: "unknown", severity: "unknown",
section: "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(), section: sectionMatch.toString(),
content: line content: line
.substring(line.indexOf(":", match.index + match[0].length) + 2) .substring(line.indexOf(":", match.index + match[0].length) + 2)
.trim(), .trim()
.replace(/\u200b/g, "\n"),
}; };
}) })
.filter((value) => value != null) as LogLine[]; .filter((value) => value != null) as LogLine[];
@ -86,6 +106,15 @@ export function parseLogLines(logService: LogType, logs: string[]) {
contentStart = line.indexOf(section) + section.length + 2; 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; let severityCat: LogSeverity;
switch (severity?.at(0)?.toString().trim()) { switch (severity?.at(0)?.toString().trim()) {
case "INF": case "INF":
@ -116,18 +145,68 @@ export function parseLogLines(logService: LogType, logs: string[]) {
} else if (logService == "nginx") { } else if (logService == "nginx") {
return logs return logs
.map((line) => { .map((line) => {
if (line.length == 0) { if (line.trim().length === 0) return null;
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];
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 { return {
dateStamp: line.substring(0, 19), dateStamp,
severity: "info", severity: "info",
section: httpMethods.exec(line)?.at(0)?.toString() ?? "META", section,
content: line.substring(line.indexOf(" ", 20)).trim(), 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 []; return [];