ytptube/app/routes/api/logs.py

225 lines
7.1 KiB
Python

import asyncio
import logging
import os
import re
from pathlib import Path
from aiohttp import web
from aiohttp.web import Request, Response
from app.library.config import Config
from app.library.encoder import Encoder
from app.library.router import route
LOG: logging.Logger = logging.getLogger(__name__)
DT_PATTERN: re.Pattern[str] = re.compile(r"^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:[+-]\d{2}:\d{2}))\s?")
"Match ISO8601."
async def _read_logfile(file: Path, offset: int = 0, limit: int = 50) -> dict:
"""
Read a log file and return a set of log lines along with pagination metadata.
Args:
file (Path): The log file path.
offset (int): Number of lines to skip from the end (newer entries).
limit (int): Number of lines to return.
Returns:
dict: A dictionary containing:
- logs: List of log entries.
- next_offset: Offset for the next page or None.
- end_is_reached: True if there are no older logs.
"""
from hashlib import sha256
from anyio import open_file
if not file.exists():
return {"logs": [], "next_offset": None, "end_is_reached": True}
result = []
try:
async with await open_file(file, "rb") as f:
await f.seek(0, os.SEEK_END)
file_size: int = await f.tell()
block_size = 1024
block_end: int = file_size
buffer: bytes = b""
lines: list = []
required_count: int = offset + limit + 1
while len(lines) < required_count and block_end > 0:
block_start: int = max(0, block_end - block_size)
await f.seek(block_start)
chunk: bytes = await f.read(block_end - block_start)
buffer: bytes = chunk + buffer # prepend the chunk
lines = buffer.splitlines()
block_end = block_start
if len(lines) > offset + limit:
next_offset: int = offset + limit
end_is_reached = False
else:
next_offset = None
end_is_reached = True
for line in lines[-(offset + limit) : -offset] if offset else lines[-limit:]:
line_bytes: bytes | str = line if isinstance(line, bytes) else line.encode()
msg: str = line.decode(errors="replace")
dt_match: re.Match[str] | None = DT_PATTERN.match(msg)
result.append(
{
"id": sha256(line_bytes).hexdigest(),
"line": msg[dt_match.end() :] if dt_match else msg,
"datetime": dt_match.group(1) if dt_match else None,
}
)
return {"logs": result, "next_offset": next_offset, "end_is_reached": end_is_reached}
except Exception:
return {"logs": [], "next_offset": None, "end_is_reached": True}
async def _tail_log(file: Path, emitter: callable, sleep_time: float = 0.5):
"""
Live tail.
Args:
file (str): The log file path.
emitter (callable): A callable to emit new lines.
sleep_time (float): The time to sleep between reads.
"""
from hashlib import sha256
from anyio import open_file
if not file.exists():
return
try:
async with await open_file(file, "rb") as f:
await f.seek(0, os.SEEK_END)
while True:
line: bytes = await f.readline()
if not line:
await asyncio.sleep(sleep_time)
continue
msg: str = line.decode(errors="replace")
dt_match: re.Match[str] | None = DT_PATTERN.match(msg)
await emitter(
{
"id": sha256(line if isinstance(line, bytes) else line.encode()).hexdigest(),
"line": msg[dt_match.end() :] if dt_match else msg,
"datetime": dt_match.group(1) if dt_match else None,
}
)
except Exception as e:
LOG.error(f"Error while tailing log file '{file!s}': {e!s}")
return
@route("GET", "api/logs/", "logs")
async def logs(request: Request, config: Config, encoder: Encoder) -> Response:
"""
Get recent logs
Args:
request (Request): The request object.
config (Config): The configuration instance.
encoder (Encoder): The encoder instance.
Returns:
Response: The response object.
"""
if not config.file_logging:
return web.json_response(data={"error": "File logging is not enabled."}, status=web.HTTPNotFound.status_code)
offset = int(request.query.get("offset", 0))
limit = int(request.query.get("limit", 100))
if limit < 1 or limit > 150:
limit = 50
logs_data = await _read_logfile(
file=Path(config.config_path) / "logs" / "app.log",
offset=offset,
limit=limit,
)
return web.json_response(
data={
"logs": logs_data["logs"],
"offset": offset,
"limit": limit,
"next_offset": logs_data["next_offset"],
"end_is_reached": logs_data["end_is_reached"],
},
status=web.HTTPOk.status_code,
dumps=encoder.encode,
)
@route("GET", "api/logs/stream", "logs.stream")
async def stream_logs(request: Request, config: Config, encoder: Encoder) -> Response | web.StreamResponse:
if not config.file_logging:
return web.json_response(
data={"error": "File logging is not enabled."},
status=web.HTTPNotFound.status_code,
)
log_file = Path(config.config_path) / "logs" / "app.log"
if not log_file.exists():
return web.json_response(
data={"error": "Log file is not available."},
status=web.HTTPNotFound.status_code,
)
response = web.StreamResponse(
status=web.HTTPOk.status_code,
headers={
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
"Connection": "keep-alive",
},
)
await response.prepare(request)
async def emit_log(data: dict) -> None:
if request.transport is None or request.transport.is_closing():
raise asyncio.CancelledError
payload = f"event: log_lines\ndata: {encoder.encode(data)}\n\n"
await response.write(payload.encode("utf-8"))
from app.features.core.utils import gen_random
log_task: asyncio.Task[None] = asyncio.create_task(
_tail_log(file=log_file, emitter=emit_log),
name=f"log_stream_{gen_random(8)}",
)
try:
LOG.debug("Log streaming connected.")
while not log_task.done():
await asyncio.sleep(1.0)
if request.transport is None or request.transport.is_closing():
log_task.cancel()
break
await log_task
except asyncio.CancelledError:
pass
finally:
LOG.debug("Log streaming disconnected.")
try:
await response.write_eof()
except ConnectionResetError:
pass
return response